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
+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).