feat(stage1-r): Sizer + RiskExecutor + R-metric enrichment (iter 2)

Iteration 2 of cycle 0065 (Stage-1 R signal quality) — the node + metric layer.
Builds on iter-1's PositionManagement dense R-record + core summarize_r.

What ships:

- PositionManagement gains a 4th `size` input (slot 3 -> col 10), fed by the
  Sizer. R is computed size-INVARIANTLY (pure stop-distance ratio), so size
  never touches realized_r — pinned by a RED test (scaling size leaves every
  realized_r unchanged) at the node and again end-to-end through the executor.

- Sizer (aura-std): `size = risk_budget / stop_distance` — flat-1R (risk_budget
  1.0 => one risk unit per trade, size inversely proportional to the stop, not a
  constant). Reads bias (firing/presence) + stop_distance; the Stage-2
  fixed-fractional sizer slots in unchanged (swap risk_budget for
  risk_fraction*equity, same node shape).

- summarize_r enrichment: SQN (sqrt(n)*mean_R/sample-stdev_R; n<2 or zero-variance
  -> 0), conviction_terciles_r (E[R] by |bias_at_entry| tercile), net_expectancy_r
  (gross minus one round-trip cost per trade, charged in R via the latched_dist
  recovered from the entry_price/stop_price columns). summarize_r gains a
  `round_trip_cost` param (price units). The net-of-cost recovery is tested
  through the real producer->consumer seam, not just hand-built rows.

- RunMetrics.r: Option<RMetrics> with #[serde(default, skip_serializing_if =
  "Option::is_none")] — legacy runs.jsonl (no `r` key) deserialise to None and a
  pip-only run's on-disk shape stays byte-unchanged (C14/C18 back-compat). Every
  RunMetrics literal threaded (report.rs, aura-registry, aura-engine/mc).

- RiskExecutor (aura-engine integration-test fixture, sibling of
  vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open
  bias+price input roles, price fanned to both the stop and PM, the Sizer's size
  into PM. Bias is produced in-graph from the single price source (a second bias
  *source* would k-way-merge into separate cycles and mark stale prices). The
  Veto is a DOCUMENTED SEAM, not a runtime node (a pass-through identity is what
  C19/C23 DCE deletes). Tests: the composite bootstraps + runs + folds to the
  documented hand value; R invariant under risk_budget while the size column
  scales; a live-folded RMetrics survives the RunMetrics serde round-trip.

The dense-record size column (10) is brought under the cross-crate layout guard
(stage1_r_e2e r_col_indices_match_producer_field_layout) so the executor
fixture's size-invariance read is drift-protected like the others.

Scope: the CLI/recording surface (#129) is sub-split into a separate iteration 3
and is NOT in this commit.

Verified: cargo build --workspace clean; cargo test --workspace 500 passed,
0 failed; cargo clippy --workspace --all-targets -D warnings clean.

refs #117 #127 #128 #129
This commit is contained in:
2026-06-24 02:15:51 +02:00
parent 6e214957d1
commit b4e84335c4
8 changed files with 665 additions and 40 deletions
+2
View File
@@ -31,6 +31,7 @@ mod recorder;
mod resample;
mod session;
mod sim_broker;
mod sizer;
mod sma;
mod sqrt;
mod stop_rule;
@@ -54,6 +55,7 @@ pub use recorder::Recorder;
pub use resample::Resample;
pub use session::Session;
pub use sim_broker::SimBroker;
pub use sizer::Sizer;
pub use sma::Sma;
pub use sqrt::Sqrt;
pub use stop_rule::FixedStop;
+32 -5
View File
@@ -78,6 +78,7 @@ impl PositionManagement {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() },
];
let output = FIELD_NAMES
.iter()
@@ -109,7 +110,7 @@ fn sign0(v: f64) -> f64 {
impl Node for PositionManagement {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1]
vec![1, 1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
@@ -120,6 +121,7 @@ impl Node for PositionManagement {
let price = pw[0];
let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired
let now = ctx.now();
let mut closed = false;
@@ -219,7 +221,7 @@ impl Node for PositionManagement {
Cell::from_f64(d_stop),
Cell::from_f64(d_exit),
Cell::from_f64(d_babs),
Cell::from_f64(1.0), // size: flat-1R placeholder (iter-2 Sizer)
Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant
Cell::from_bool(open),
Cell::from_f64(unrealized),
Cell::from_f64(self.cum_realized_r),
@@ -241,14 +243,20 @@ impl Node for PositionManagement {
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar};
// Drive one cycle: push bias/price/stop into the three slots, eval, return the row.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
// Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row.
fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec<Cell> {
cols[0].push(Scalar::f64(bias)).unwrap();
cols[1].push(Scalar::f64(price)).unwrap();
cols[2].push(Scalar::f64(dist)).unwrap();
cols[3].push(Scalar::f64(size)).unwrap();
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
}
fn cols() -> Vec<AnyColumn> { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
// size defaults to 1.0 (the iter-1 placeholder value), so every existing case is
// behaviour-identical under the new 4-input shape.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
step_sized(n, cols, bias, price, dist, 1.0)
}
fn cols() -> Vec<AnyColumn> { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
#[test]
fn emits_one_dense_record_per_cycle() {
@@ -260,6 +268,25 @@ mod tests {
assert!(!row[11].bool()); // open = false
}
// (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R.
// Scaling size leaves every realized_r identical (the property that keeps Stage 1
// feed-forward); col 10 reflects the size so we know it actually flowed end-to-end.
#[test]
fn realized_r_is_invariant_under_size() {
let run = |size: f64| {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100
step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110
};
let small = run(1.0);
let big = run(7.0);
assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant");
assert_eq!(small[1].f64(), 1.0); // (110-100)/10
assert_eq!(small[10].f64(), 1.0); // size column reflects the input
assert_eq!(big[10].f64(), 7.0);
}
// (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
// 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
// move to 110; the flip closes it THERE, never retroactively flattening it).
+132
View File
@@ -0,0 +1,132 @@
//! `Sizer` — the flat-1R sizing seam (C10 Stage-1). Turns a protective-stop distance
//! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is
//! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the
//! stop — NOT a constant. The `bias` input gates firing (a size is only meaningful when a
//! strategy is taking a position) and is the Stage-2 seam: fixed-fractional sizing swaps
//! `risk_budget` for `risk_fraction · equity` (an added equity input), same node shape.
//! R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates signal
//! quality — it only scales the (Stage-2) currency exposure.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None`
/// until both inputs are present (warm-up filter, C8).
pub struct Sizer {
risk_budget: f64,
out: [Cell; 1],
}
impl Sizer {
/// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R).
pub fn new(risk_budget: f64) -> Self {
assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0");
Self { risk_budget, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Sizer",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
],
output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Sizer::new(p[0].f64())),
)
}
}
impl Node for Sizer {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let bias = ctx.f64_in(0);
let dist = ctx.f64_in(1);
if bias.is_empty() || dist.is_empty() {
return None; // a size needs a (present) bias and a stop distance
}
let d = dist[0];
// a zero/degenerate stop distance yields zero size (no division blow-up); a real
// stop rule emits a strictly positive distance, so this guards only warm-up edges.
let size = if d > 0.0 { self.risk_budget / d } else { 0.0 };
self.out[0] = Cell::from_f64(size);
Some(&self.out)
}
fn label(&self) -> String {
format!("Sizer({})", self.risk_budget)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option<f64> {
let mut c = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
c[0].push(Scalar::f64(bias)).unwrap();
c[1].push(Scalar::f64(dist)).unwrap();
s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64())
}
// (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance.
#[test]
fn sizer_is_flat_1r_invariant() {
for budget in [1.0_f64, 2.5] {
let mut s = Sizer::new(budget);
for dist in [0.5_f64, 2.0, 10.0, 37.5] {
let size = eval_once(&mut s, 1.0, dist).expect("both inputs present");
assert!(
(size * dist - budget).abs() < 1e-9,
"size*dist must equal risk_budget; budget={budget} dist={dist} size={size}"
);
}
}
}
#[test]
#[should_panic(expected = "risk_budget must be > 0")]
fn sizer_panics_on_zero_risk_budget() {
let _ = Sizer::new(0.0);
}
#[test]
#[should_panic(expected = "risk_budget must be > 0")]
fn sizer_panics_on_negative_risk_budget() {
let _ = Sizer::new(-1.0);
}
#[test]
fn sizer_is_none_until_both_inputs_present() {
let mut s = Sizer::new(1.0);
let mut c = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only bias present -> None
c[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None);
// both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1
c[1].push(Scalar::f64(10.0)).unwrap();
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice()));
}
#[test]
fn input_slots_are_named_bias_and_stop_distance() {
let b = Sizer::builder();
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["bias", "stop_distance"]);
}
}