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