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
+1
View File
@@ -209,6 +209,7 @@ mod tests {
total_pips: v,
max_drawdown: v,
exposure_sign_flips: v as u64,
r: None,
},
},
})
+238 -23
View File
@@ -27,11 +27,17 @@ pub struct RunMetrics {
/// A turnover proxy: it counts long<->short reversals *and* transitions
/// into/out of flat — the plain sign-change count over the exposure series.
pub exposure_sign_flips: u64,
/// Optional Stage-1 R metrics block. `None` for a pip-only run (and for legacy
/// `runs.jsonl` written before this field existed — `serde(default)`); omitted from
/// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk
/// shape stays byte-unchanged (C14/C18 back-compat).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r: Option<RMetrics>,
}
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The
/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later.
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries
/// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RMetrics {
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
@@ -42,6 +48,9 @@ pub struct RMetrics {
pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
pub sqn: f64, // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0
pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest
pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile (asc); <3 trades -> [0,0,0]
}
// Dense `PositionManagement` record column indices — the lockstep contract with
@@ -50,6 +59,9 @@ pub struct RMetrics {
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const ENTRY_PRICE: usize = 6;
pub const STOP_PRICE: usize = 7;
pub const BIAS_AT_ENTRY_ABS: usize = 9;
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
@@ -58,29 +70,56 @@ mod r_col {
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
// Rows are the producer's dense `PositionManagement` records, always `PM_WIDTH`
// (14) columns wide — pinned by `stage1_r_e2e.rs`. Trust that layout and index
// every column directly: a guard on one column while the next is a bare `[]`
// index would buy nothing (a short row panics either way).
let mut rs: Vec<f64> = Vec::new();
let mut n_open_at_end = 0u64;
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) -> RMetrics {
// Collect one entry per trade: its realised R, the entry-conviction |bias|, and the
// latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the
// record. The ledger is the closed rows; a position still open on the last row is
// force-closed at its unrealized R (a window-end trade).
struct Trade {
r: f64,
bias_abs: f64,
latched: f64,
}
let mut trades: Vec<Trade> = Vec::new();
for (_, row) in record {
if row[r_col::CLOSED].as_bool() {
rs.push(row[r_col::REALIZED_R].as_f64());
trades.push(Trade {
r: row[r_col::REALIZED_R].as_f64(),
bias_abs: row[r_col::BIAS_AT_ENTRY_ABS].as_f64(),
latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(),
});
}
}
let mut n_open_at_end = 0u64;
if let Some((_, last)) = record.last()
&& last[r_col::OPEN].as_bool()
{
rs.push(last[r_col::UNREALIZED_R].as_f64());
trades.push(Trade {
r: last[r_col::UNREALIZED_R].as_f64(),
bias_abs: last[r_col::BIAS_AT_ENTRY_ABS].as_f64(),
latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(),
});
n_open_at_end = 1;
}
let n = rs.len() as u64;
let n = trades.len() as u64;
if n == 0 {
return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 };
return RMetrics {
expectancy_r: 0.0,
n_trades: 0,
win_rate: 0.0,
avg_win_r: 0.0,
avg_loss_r: 0.0,
profit_factor: 0.0,
max_r_drawdown: 0.0,
n_open_at_end: 0,
sqn: 0.0,
net_expectancy_r: 0.0,
conviction_terciles_r: [0.0; 3],
};
}
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
let sum: f64 = rs.iter().sum();
let mean = sum / n as f64;
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
let sum_win: f64 = wins.iter().sum();
@@ -92,12 +131,52 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
let mut max_dd = 0.0_f64;
for &r in &rs {
cum += r;
if cum > peak { peak = cum; }
if cum > peak {
peak = cum;
}
let dd = peak - cum;
if dd > max_dd { max_dd = dd; }
if dd > max_dd {
max_dd = dd;
}
}
// SQN = √n · mean / sample-stdev. n < 2 or zero variance -> 0.0 (dispersion undefined).
let sqn = if n < 2 {
0.0
} else {
let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
let sd = var.sqrt();
if sd > 0.0 { (n as f64).sqrt() * mean / sd } else { 0.0 }
};
// net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R
// by dividing by that trade's latched R-distance (a zero distance contributes no cost).
let net_sum: f64 = trades
.iter()
.map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 })
.sum();
let net_expectancy_r = net_sum / n as f64;
// conviction terciles: sort by |bias_at_entry| ascending, split into three contiguous
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
let conviction_terciles_r = if n < 3 {
[0.0; 3]
} else {
let mut by_conv: Vec<&Trade> = trades.iter().collect();
by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs));
let nn = by_conv.len();
let mut out = [0.0; 3];
for (i, slot) in out.iter_mut().enumerate() {
let lo = i * nn / 3;
let hi = (i + 1) * nn / 3;
let bucket = &by_conv[lo..hi];
*slot = if bucket.is_empty() {
0.0
} else {
bucket.iter().map(|t| t.r).sum::<f64>() / bucket.len() as f64
};
}
out
};
RMetrics {
expectancy_r: sum / n as f64,
expectancy_r: mean,
n_trades: n,
win_rate: wins.len() as f64 / n as f64,
avg_win_r: avg(&wins),
@@ -105,6 +184,9 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
max_r_drawdown: max_dd,
n_open_at_end,
sqn,
net_expectancy_r,
conviction_terciles_r,
}
}
@@ -235,7 +317,7 @@ pub fn summarize(
prev = Some(s);
}
RunMetrics { total_pips, max_drawdown, exposure_sign_flips }
RunMetrics { total_pips, max_drawdown, exposure_sign_flips, r: None }
}
/// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns
@@ -582,12 +664,29 @@ mod tests {
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
(Timestamp(0), v)
}
// A fuller closed-trade dense row: also sets entry_price (6), stop_price (7) and
// bias_at_entry_abs (9) — the geometry summarize_r recovers latched_dist and
// conviction from. Width up to UNREALIZED_R+1 (summarize_r never reads col 13).
fn r_row_full(realized: f64, entry: f64, stop: f64, bias_abs: f64) -> (Timestamp, Vec<Scalar>) {
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
v[r_col::CLOSED] = Scalar::bool(true);
v[r_col::REALIZED_R] = Scalar::f64(realized);
v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
v[r_col::STOP_PRICE] = Scalar::f64(stop);
v[r_col::BIAS_AT_ENTRY_ABS] = Scalar::f64(bias_abs);
v[r_col::OPEN] = Scalar::bool(false);
(Timestamp(0), v)
}
#[test]
fn summarize_r_is_zero_on_empty() {
let m = summarize_r(&[]);
let m = summarize_r(&[], 0.0);
assert_eq!(m.n_trades, 0);
assert_eq!(m.expectancy_r, 0.0);
assert_eq!(m.max_r_drawdown, 0.0);
assert_eq!(m.sqn, 0.0);
assert_eq!(m.net_expectancy_r, 0.0);
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
}
#[test]
fn summarize_r_expectancy_winrate_profit_factor() {
@@ -598,7 +697,7 @@ mod tests {
r_row(true, -1.0, true, 0.0),
r_row(true, 1.0, false, 0.0), // last row not open
];
let m = summarize_r(&rec);
let m = summarize_r(&rec, 0.0);
assert_eq!(m.n_trades, 3);
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
@@ -609,7 +708,7 @@ mod tests {
fn summarize_r_force_closes_open_position_at_window_end() {
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
let m = summarize_r(&rec);
let m = summarize_r(&rec, 0.0);
assert_eq!(m.n_trades, 2);
assert_eq!(m.n_open_at_end, 1);
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
@@ -618,7 +717,80 @@ mod tests {
fn summarize_r_max_drawdown_on_by_trade_curve() {
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9);
assert!((summarize_r(&rec, 0.0).max_r_drawdown - 2.0).abs() < 1e-9);
}
#[test]
fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() {
// R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1;
// sqn = sqrt(4)*0.5/1 = 1.0.
let rec = vec![
r_row(true, 1.0, false, 0.0),
r_row(true, 1.0, false, 0.0),
r_row(true, 1.0, false, 0.0),
r_row(true, -1.0, false, 0.0),
];
let m = summarize_r(&rec, 0.0);
assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn);
}
#[test]
fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() {
// n < 2 -> 0
assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0);
// identical R -> zero variance -> 0
let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)];
assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0);
}
#[test]
fn summarize_r_conviction_terciles_order_by_bias() {
// Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three
// terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5.
let calibrated = vec![
r_row_full(-2.0, 100.0, 99.0, 0.1),
r_row_full(-1.0, 100.0, 99.0, 0.2),
r_row_full(0.0, 100.0, 99.0, 0.5),
r_row_full(0.0, 100.0, 99.0, 0.6),
r_row_full(1.0, 100.0, 99.0, 0.9),
r_row_full(2.0, 100.0, 99.0, 1.0),
];
let m = summarize_r(&calibrated, 0.0);
assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r);
assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r);
assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated");
// Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the
// metric reads |bias|, not trade order. Same R multiset, |bias| reversed.
let anti = vec![
r_row_full(2.0, 100.0, 99.0, 0.1),
r_row_full(1.0, 100.0, 99.0, 0.2),
r_row_full(0.0, 100.0, 99.0, 0.5),
r_row_full(0.0, 100.0, 99.0, 0.6),
r_row_full(-1.0, 100.0, 99.0, 0.9),
r_row_full(-2.0, 100.0, 99.0, 1.0),
];
let a = summarize_r(&anti, 0.0);
assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r);
}
#[test]
fn summarize_r_conviction_terciles_zero_under_three_trades() {
let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)];
assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]);
}
#[test]
fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() {
// one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price
// units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9.
let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)];
let gross = summarize_r(&rec, 0.0);
let net = summarize_r(&rec, 1.0);
assert!((gross.expectancy_r - 2.0).abs() < 1e-9);
assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross");
assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy");
assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
@@ -712,6 +884,7 @@ mod tests {
total_pips: 12.0,
max_drawdown: 1.0,
exposure_sign_flips: 1,
r: None,
},
};
assert_eq!(
@@ -735,7 +908,7 @@ mod tests {
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None },
};
// stdout (to_json) and disk (serde_json::to_string) are now the same bytes.
assert_eq!(report.to_json(), serde_json::to_string(&report).unwrap());
@@ -755,7 +928,7 @@ mod tests {
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None },
};
let json = serde_json::to_string(&report).expect("serialize RunReport");
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
@@ -764,6 +937,48 @@ mod tests {
assert_eq!(back, report);
}
#[test]
fn runmetrics_with_r_block_round_trips() {
let m = RunMetrics {
total_pips: 3.0,
max_drawdown: 1.0,
exposure_sign_flips: 2,
r: Some(RMetrics {
expectancy_r: 0.5,
n_trades: 4,
win_rate: 0.5,
avg_win_r: 1.5,
avg_loss_r: -0.5,
profit_factor: 3.0,
max_r_drawdown: 0.5,
n_open_at_end: 1,
sqn: 1.0,
net_expectancy_r: 0.4,
conviction_terciles_r: [-0.5, 0.5, 1.5],
}),
};
let json = serde_json::to_string(&m).expect("serialize");
assert!(json.contains("\"r\":{"), "r block present when Some: {json}");
let back: RunMetrics = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, m);
}
#[test]
fn legacy_runmetrics_without_r_field_deserialises_to_none() {
// a pre-`r` runs.jsonl line: no `r` key at all.
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#;
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises");
assert_eq!(m.r, None);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn runmetrics_none_r_is_omitted_from_json() {
let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, exposure_sign_flips: 0, r: None };
let json = serde_json::to_string(&m).expect("serialize");
assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}");
}
#[test]
fn join_on_ts_aligns_streams_of_different_cardinality() {
// spine fires every bar; side A is one row shorter (no ts 10, like cold
+170
View File
@@ -0,0 +1,170 @@
//! The `RiskExecutor` composite (Stage-1, #128): a per-symbol risk-based executor over a
//! bias stream — `stop-rule -> Sizer -> position-management`, exposing the dense R-record.
//! Proves the composite bootstraps, runs end-to-end, and folds to the SAME R-outcomes as
//! the hand-wired iter-1 chain, and that R is invariant under the Sizer's `risk_budget`
//! (Stage-1 feed-forward). The Veto is a DOCUMENTED SEAM, not a runtime node (a
//! pass-through identity is exactly what C19/C23 DCE deletes), so it appears nowhere here.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind, Timestamp,
};
use aura_engine::{summarize_r, Composite, GraphBuilder, RunMetrics, VecSource};
use aura_std::{FixedStop, PositionManagement, Recorder, Sizer, PM_FIELD_NAMES, PM_RECORD_KINDS};
use std::sync::mpsc::channel;
// The dense-record columns this fixture reads, named in lockstep with the sibling
// `stage1_r_e2e.rs` (which names `REALIZED_R = 1` the same way) so the cross-crate
// `PM_FIELD_NAMES` layout is never referenced by a bare literal.
const REALIZED_R: usize = 1;
const SIZE: usize = 10;
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
/// `FixedStop(stop_distance) -> Sizer(risk_budget) -> PositionManagement`, exposing every
/// field of PM's dense R-record. Price fans to BOTH the stop-rule and PM; bias fans to the
/// Sizer and PM; the Sizer's `size` feeds PM's size slot. (Stage-1 ships `FixedStop` here;
/// the volatility stop is a drop-in composite, see `vol_stop_composite.rs`.)
fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite {
let mut g = GraphBuilder::new("risk_executor");
let bias = g.input_role("bias");
let price = g.input_role("price");
let stop = g.add(FixedStop::builder().bind("distance", Scalar::f64(stop_distance)));
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
let pm = g.add(PositionManagement::builder());
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
g.connect(sizer.output("size"), pm.input("size")); // the flat-1R size into PM
for field in PM_FIELD_NAMES {
g.expose(pm.output(field), field);
}
g.build().expect("risk_executor wires")
}
/// An always-long strategy stand-in: emits a constant `+1` bias once price is present. The
/// strategy is upstream of the RiskExecutor; this is the minimal in-graph producer so the
/// whole chain runs off the single price source (a second bias *source* would k-way-merge
/// into separate cycles and mark stale prices — see harness.rs C4 tie-breaking).
struct ConstLongBias {
out: [Cell; 1],
}
impl ConstLongBias {
fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"ConstLongBias",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }),
)
}
}
impl Node for ConstLongBias {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() {
return None;
}
self.out[0] = Cell::from_f64(1.0);
Some(&self.out)
}
fn label(&self) -> String {
"ConstLongBias".into()
}
}
/// Bootstrap a harness: one price source -> ConstLongBias (the strategy) + RiskExecutor;
/// the RiskExecutor's dense R-record into a Recorder. Returns the drained ledger.
fn run_executor(prices: &[f64], stop_distance: f64, risk_budget: f64) -> Vec<(Timestamp, Vec<Scalar>)> {
let (tx, rx) = channel();
let mut g = GraphBuilder::new("risk_harness");
let price = g.source_role("price", ScalarKind::F64);
let strat = g.add(ConstLongBias::builder());
let exec = g.add(risk_executor(stop_distance, risk_budget));
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
g.feed(price, [strat.input("price"), exec.input("price")]);
g.connect(strat.output("bias"), exec.input("bias"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
// `input` takes a `&'static str` (names resolve at the authoring boundary, C23);
// leak the per-column port name so the runtime-built `col[i]` satisfies that bound.
let col: &'static str = format!("col[{i}]").leak();
g.connect(exec.output(field), rec.input(col));
}
let mut h = g
.build()
.expect("risk_harness wires")
.bootstrap_with_params(vec![])
.expect("bootstraps");
let stream: Vec<(Timestamp, Scalar)> = prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p)))
.collect();
h.run(vec![Box::new(VecSource::new(stream))]);
rx.try_iter().collect()
}
/// Property: the composite bootstraps, runs, and folds to the documented hand value. A
/// constant long over a monotonically rising price never stops or flips, so the position
/// is open at window end: entry @100 latched on FixedStop(10), last mark @105 -> window-end
/// R = (105-100)/10 = +0.5, the only trade -> expectancy 0.5 (the bootstrapped-composite
/// twin of the iter-1 hand-wired `open_at_window_end_is_folded_into_expectancy_not_dropped`).
#[test]
fn risk_executor_bootstraps_and_folds_to_expected_rmetric() {
let ledger = run_executor(&[100.0, 102.0, 105.0], 10.0, 1.0);
let m = summarize_r(&ledger, 0.0);
assert_eq!(m.n_open_at_end, 1, "the open position must be counted");
assert_eq!(m.n_trades, 1);
assert!((m.expectancy_r - 0.5).abs() < 1e-9, "window-end R = (105-100)/10; got {}", m.expectancy_r);
}
/// Property: R is invariant under the Sizer's `risk_budget` (Stage-1 feed-forward). The
/// same price path at two budgets yields a bit-identical realised-R ledger, while the
/// `size` column scales with the budget — proving size flows through the Sizer into PM yet
/// never touches R.
#[test]
fn risk_executor_r_invariant_under_risk_budget() {
let path = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0];
let a = run_executor(&path, 5.0, 1.0);
let b = run_executor(&path, 5.0, 8.0);
assert_eq!(a.len(), b.len());
assert!(!a.is_empty());
// index realized_r and size by the producer's dense-record layout (named consts above).
let realized =
|rows: &[(Timestamp, Vec<Scalar>)]| rows.iter().map(|(_, r)| r[REALIZED_R].as_f64()).collect::<Vec<_>>();
let size =
|rows: &[(Timestamp, Vec<Scalar>)]| rows.iter().map(|(_, r)| r[SIZE].as_f64()).collect::<Vec<_>>();
assert_eq!(realized(&a), realized(&b), "realized_r must be invariant under risk_budget");
// size scaled 8x: at least one cycle has a nonzero size that is exactly 8x a's (same
// stop distance per cycle, budget 1 -> 8).
let (sa, sb) = (size(&a), size(&b));
assert!(
sa.iter().zip(&sb).any(|(x, y)| *x > 0.0 && (*y - 8.0 * *x).abs() < 1e-9),
"risk_budget must scale size 8x: a={sa:?} b={sb:?}"
);
}
/// Property: **a real folded `RMetrics` survives the `RunMetrics.r` serde round-trip
/// byte-for-byte (the Stage-1 on-disk back-compat contract), driven from an actual run —
/// not a hand-built literal.** A bootstrapped RiskExecutor run is folded by `summarize_r`
/// and the result is attached as `RunMetrics.r = Some(..)`; serializing then
/// deserializing must reproduce an equal value, and the `r` key must be present. The
/// `report.rs` unit test asserts this on a hand-written `RMetrics`; here the value is
/// whatever the live fold produced, so a future `RMetrics` field that the fold sets but
/// serde forgets to thread would round-trip-diverge here (the literal test cannot see it).
/// The sibling pip-only-`None` path (omitted from JSON) is the inverse, covered in report.rs.
#[test]
fn folded_rmetrics_survives_runmetrics_serde_round_trip() {
let ledger = run_executor(&[100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0], 5.0, 1.0);
let folded = summarize_r(&ledger, 0.0);
assert!(folded.n_trades >= 1, "the run must produce at least one trade to fold");
let m = RunMetrics { total_pips: 0.0, max_drawdown: 0.0, exposure_sign_flips: 0, r: Some(folded) };
let json = serde_json::to_string(&m).expect("serialize a run's RunMetrics with an r block");
assert!(json.contains("\"r\":{"), "the folded r block must be present in the JSON: {json}");
let back: RunMetrics = serde_json::from_str(&json).expect("deserialize round-trips");
assert_eq!(back, m, "a live-folded RMetrics must round-trip byte-for-byte");
}
+89 -11
View File
@@ -36,6 +36,10 @@ const CLOSED: usize = 0;
const REALIZED_R: usize = 1;
const OPEN: usize = 11;
const UNREALIZED_R: usize = 12;
const ENTRY_PRICE: usize = 6;
const STOP_PRICE: usize = 7;
const BIAS_AT_ENTRY_ABS: usize = 9;
const SIZE: usize = 10;
/// Property: the real producer->consumer seam composes. Driving `FixedStop` ->
/// `PositionManagement` directly (node-by-node, not a bootstrapped graph) over a
@@ -51,7 +55,7 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() {
let mut pm = PositionManagement::new();
let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price
let mut pc: Vec<AnyColumn> =
(0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
(0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = vec![];
// price path: up to 110 then down through the 95 stop.
let prices = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0];
@@ -61,6 +65,7 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() {
pc[0].push(Scalar::f64(1.0)).unwrap(); // constant long bias
pc[1].push(Scalar::f64(p)).unwrap();
pc[2].push(Scalar::f64(dist)).unwrap();
pc[3].push(Scalar::f64(1.0)).unwrap(); // flat-1R size; R is size-invariant
if let Some(row) = pm.eval(Ctx::new(&pc, Timestamp(i as i64))) {
ledger.push((
Timestamp(i as i64),
@@ -71,23 +76,23 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() {
));
}
}
let m = summarize_r(&ledger);
let m = summarize_r(&ledger, 0.0);
assert!(m.n_trades >= 1, "expected at least one trade (entry then stop), got {m:?}");
assert!(m.expectancy_r.is_finite());
// entered ~100, stopped at the 95 level (price gapped to 94 through it) -> a loss.
assert!(m.expectancy_r < 0.0 || m.n_open_at_end == 1);
}
/// Drive the real cross-crate chain `stop -> PositionManagement -> summarize_r`
/// over a `(bias, price)` path, collecting the producer's dense records (decoded by
/// the producer's own `PM_RECORD_KINDS`, the wire contract) and folding them. This
/// is the actual producer->consumer seam, node-by-node; the resulting `RMetrics` is
/// what a recorded Stage-1 run would report. Per-cycle bias (not constant) so a
/// fixture can drop bias to 0 to forbid the immediate re-entry after a stop.
fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics {
/// Drive the real cross-crate chain `stop -> PositionManagement` over a `(bias, price)`
/// path, collecting the producer's dense records (decoded by the producer's own
/// `PM_RECORD_KINDS`, the wire contract) into a ledger — the recorded stream a Stage-1
/// run would persist. Per-cycle bias (not constant) so a fixture can drop bias to 0 to
/// forbid the immediate re-entry after a stop. The fold is left to the caller so the
/// same recorded ledger can be summarized at several `round_trip_cost` values.
fn run_chain_ledger(stop: &mut dyn Node, path: &[(f64, f64)]) -> Vec<(Timestamp, Vec<Scalar>)> {
let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price
let mut pc: Vec<AnyColumn> =
(0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
(0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut pm = PositionManagement::new();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = vec![];
for (i, &(bias, p)) in path.iter().enumerate() {
@@ -97,6 +102,7 @@ fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics {
pc[0].push(Scalar::f64(bias)).unwrap();
pc[1].push(Scalar::f64(p)).unwrap();
pc[2].push(Scalar::f64(dist)).unwrap();
pc[3].push(Scalar::f64(1.0)).unwrap(); // flat-1R size; R is size-invariant
if let Some(row) = pm.eval(Ctx::new(&pc, ts)) {
ledger.push((
ts,
@@ -104,7 +110,14 @@ fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics {
));
}
}
summarize_r(&ledger)
ledger
}
/// Drive the real cross-crate chain `stop -> PositionManagement -> summarize_r`
/// over a `(bias, price)` path. This is the actual producer->consumer seam,
/// node-by-node; the resulting `RMetrics` is what a recorded Stage-1 run would report.
fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics {
summarize_r(&run_chain_ledger(stop, path), 0.0)
}
/// A constant-long-bias path (`bias = +1` every cycle) over `prices`.
@@ -177,6 +190,55 @@ fn clean_stop_folds_to_exactly_minus_one_r() {
assert!((m.expectancy_r + 1.0).abs() < 1e-9, "1R = the loss if stopped; got {}", m.expectancy_r);
}
/// Property: **net-of-cost expectancy is gross minus one round-trip cost per trade,
/// charged in R via the `latched_dist` the consumer recovers from the producer's
/// `entry_price`/`stop_price` columns — through the real seam.** A constant long opened
/// at 100 on FixedStop distance 10 (so `latched_dist = |100 - 90| = 10`), price rising to
/// 110, folds to a gross window-end R of +1.0; charging a 2.0 price-unit round-trip cost
/// must lower *net* expectancy to `1.0 - 2.0/10 = 0.8` while leaving gross untouched. The
/// cost is recovered end-to-end (not from a hand-built row): if the producer reordered
/// `entry_price`/`stop_price` or `summarize_r` recovered the distance wrong, net would
/// drift here while gross stayed correct — the failure mode this seam test exists to catch.
#[test]
fn net_of_cost_charges_one_round_trip_per_trade_through_the_recovered_latched_dist() {
let mut stop = FixedStop::new(10.0);
let ledger = run_chain_ledger(&mut stop, &long_path(&[100.0, 105.0, 110.0]));
let gross = summarize_r(&ledger, 0.0);
let net = summarize_r(&ledger, 2.0);
assert_eq!(gross.n_trades, 1);
assert!((gross.expectancy_r - 1.0).abs() < 1e-9, "gross window-end R = (110-100)/10; got {}", gross.expectancy_r);
// gross is untouched by the cost; only net_expectancy_r absorbs it.
assert!((net.expectancy_r - 1.0).abs() < 1e-9, "round_trip_cost must never change gross expectancy");
assert!(
(net.net_expectancy_r - 0.8).abs() < 1e-9,
"net = gross - cost/latched_dist = 1.0 - 2.0/10; got {}",
net.net_expectancy_r,
);
}
/// Property: **a wider stop dilutes the same price-unit round-trip cost in R — the cost
/// is per-R, not per-pip — proven through the producer-recovered `latched_dist`.** The same
/// constant cost (2.0 price units) charged against a tight FixedStop (distance 5, latched 5)
/// costs `2.0/5 = 0.4R`, but against a wide FixedStop (distance 20, latched 20) only
/// `2.0/20 = 0.1R`. Both paths open at 100 and rise so the gross window-end R differs by
/// construction, but the *cost component* (gross - net) must be strictly larger for the
/// tighter stop — a run that charged cost in raw pips (ignoring the latched distance) would
/// collapse these to equal, the regression this guards.
#[test]
fn net_of_cost_is_charged_per_r_so_a_wider_stop_dilutes_it() {
let mut tight = FixedStop::new(5.0);
let tight_l = run_chain_ledger(&mut tight, &long_path(&[100.0, 102.0, 105.0]));
let (tg, tn) = (summarize_r(&tight_l, 0.0), summarize_r(&tight_l, 2.0));
let mut wide = FixedStop::new(20.0);
let wide_l = run_chain_ledger(&mut wide, &long_path(&[100.0, 102.0, 105.0]));
let (wg, wn) = (summarize_r(&wide_l, 0.0), summarize_r(&wide_l, 2.0));
let tight_cost = tg.expectancy_r - tn.net_expectancy_r; // 2.0/5 = 0.4
let wide_cost = wg.expectancy_r - wn.net_expectancy_r; // 2.0/20 = 0.1
assert!((tight_cost - 0.4).abs() < 1e-9, "tight cost = 2/5; got {tight_cost}");
assert!((wide_cost - 0.1).abs() < 1e-9, "wide cost = 2/20; got {wide_cost}");
assert!(tight_cost > wide_cost, "a tighter stop must pay more R per fixed price-unit cost");
}
/// Contract guard (the lockstep cross-crate column-index agreement `report.rs`
/// names): `PM_WIDTH` is the fixed dense-record arity, the indices `summarize_r`
/// reads the dense record by must equal the indices the producer's authoritative
@@ -201,4 +263,20 @@ fn r_col_indices_match_producer_field_layout() {
assert_eq!(PM_RECORD_KINDS[OPEN], ScalarKind::Bool);
assert_eq!(PM_RECORD_KINDS[REALIZED_R], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[UNREALIZED_R], ScalarKind::F64);
// iter-2 reads: entry_price (6), stop_price (7), bias_at_entry_abs (9) — the geometry
// summarize_r recovers latched_dist (net-of-cost) and conviction (terciles) from.
assert_eq!(PM_FIELD_NAMES[ENTRY_PRICE], "entry_price");
assert_eq!(PM_FIELD_NAMES[STOP_PRICE], "stop_price");
assert_eq!(PM_FIELD_NAMES[BIAS_AT_ENTRY_ABS], "bias_at_entry_abs");
assert_eq!(PM_RECORD_KINDS[ENTRY_PRICE], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[STOP_PRICE], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[BIAS_AT_ENTRY_ABS], ScalarKind::F64);
// iter-2 size column (10): the Sizer's `size` flows here, and the sibling
// `risk_executor.rs` fixture asserts its R-invariance by reading this index — so the
// size column's position is pinned against the producer layout too, not just the reads
// `summarize_r` makes (the lockstep claim in that fixture's header relies on this).
assert_eq!(PM_FIELD_NAMES[SIZE], "size");
assert_eq!(PM_RECORD_KINDS[SIZE], ScalarKind::F64);
}