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:
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user