From c6c1d3bb10917eeddc8cf0b230f242fd10a2de98 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 13:11:46 +0200 Subject: [PATCH] feat(stage1-r): rename the exposure_sign_flips metric to bias_sign_flips (#126) Complete the typed-field half of the exposure->bias rename - the one the cycle-0065 close audit flagged as aging worst (a serde-stable on-disk key). - RunMetrics.exposure_sign_flips -> bias_sign_flips with #[serde(alias = "exposure_sign_flips")], so legacy runs.jsonl still deserialises (pinned by the legacy-read tests, which keep the old key); new output serialises bias_sign_flips (the byte-pin tests updated to the new key). - The registry rank metric: Metric::ExposureSignFlips -> BiasSignFlips, the rank string accepts BOTH "bias_sign_flips" and "exposure_sign_flips" (CLI back-compat), the error/listing updated; the rank tests exercise both names. - The MC aggregate field renamed too (McAggregate is hand-rendered to JSON, not serde-derived - no alias needed). Scope held to the typed metric. The strategy-output PARAM NAMESPACE - the .named("exposure") Bias instance, its exposure.scale knob path (Composite::param_space derives the knob from the node name), and the exposure_scale manifest label - is a coherent ~30-site, sweep-pinned surface (the implement loop correctly flagged renaming the knob as an unscoped expansion touching walkforward + the cli_run byte-pins). Kept uniformly as "exposure" and deferred to a follow-on so the cluster renames as one consistent unit. SimBroker's pre-reframe exposure concept and the on-disk "exposure" tap label stay by the #117 fork decision. cargo test --workspace green; clippy --workspace --all-targets -D warnings clean; a live `aura run --harness stage1-r` emits "bias_sign_flips". closes #126 refs #117 --- crates/aura-cli/src/main.rs | 10 ++-- crates/aura-cli/tests/cli_run.rs | 2 +- crates/aura-engine/src/mc.rs | 6 +-- crates/aura-engine/src/report.rs | 49 ++++++++++++------- crates/aura-engine/tests/risk_executor.rs | 2 +- .../examples/ger40_breakout_compare.rs | 4 +- .../examples/ger40_breakout_real.rs | 2 +- .../examples/ger40_breakout_sweep.rs | 2 +- crates/aura-registry/src/lib.rs | 21 +++++--- 9 files changed, 59 insertions(+), 39 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 5b8637c..6ce7bd6 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1459,7 +1459,7 @@ fn mc_aggregate_json(agg: &McAggregate) -> String { "mc_aggregate": { "total_pips": agg.total_pips, "max_drawdown": agg.max_drawdown, - "exposure_sign_flips": agg.exposure_sign_flips, + "bias_sign_flips": agg.bias_sign_flips, } }) .to_string() @@ -2470,7 +2470,7 @@ mod tests { for line in &lines { assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}"); assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}"); - assert!(line.contains(r#""exposure_sign_flips":"#), "missing flips: {line}"); + assert!(line.contains(r#""bias_sign_flips":"#), "missing flips: {line}"); assert!(line.ends_with('}'), "line not closed: {line}"); } } @@ -2557,7 +2557,7 @@ mod tests { // after warm-up the EMA-of-EMA histogram crosses zero, so the strategy // reverses exposure at least once — a genuinely non-trivial trace. assert!( - r1.metrics.exposure_sign_flips >= 1, + r1.metrics.bias_sign_flips >= 1, "macd trace should flip exposure: {:?}", r1.metrics ); @@ -2724,8 +2724,8 @@ mod tests { assert_eq!(r1.to_json(), r2.to_json()); let m = &r1.metrics; - // exactly one exposure sign flip in the demo trace (rises then reverses). - assert_eq!(m.exposure_sign_flips, 1); + // exactly one bias sign flip in the demo trace (rises then reverses). + assert_eq!(m.bias_sign_flips, 1); // a non-trivial, populated trace: a real drawdown. assert!(m.max_drawdown > 0.0); // hand-computed magnitudes for the chosen stream (float tolerance; the diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 97ae107..b92d485 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -36,7 +36,7 @@ fn run_prints_json_and_exits_zero() { assert!(line.contains("\"window\":[1,7]"), "got: {line}"); assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}"); // the integer sign-flip count is stable across float renderings. - assert!(line.ends_with("\"exposure_sign_flips\":1}}"), "got: {line}"); + assert!(line.ends_with("\"bias_sign_flips\":1}}"), "got: {line}"); } /// Property: the RunManifest is self-identifying — its `commit` carries the diff --git a/crates/aura-engine/src/mc.rs b/crates/aura-engine/src/mc.rs index 2e01eeb..3eb9c97 100644 --- a/crates/aura-engine/src/mc.rs +++ b/crates/aura-engine/src/mc.rs @@ -37,7 +37,7 @@ pub struct McFamily { pub struct McAggregate { pub total_pips: MetricStats, pub max_drawdown: MetricStats, - pub exposure_sign_flips: MetricStats, + pub bias_sign_flips: MetricStats, } /// Mean + a fixed quantile set of one metric across the realizations. `p50` is @@ -85,7 +85,7 @@ impl McAggregate { McAggregate { total_pips: pick(|m| m.total_pips), max_drawdown: pick(|m| m.max_drawdown), - exposure_sign_flips: pick(|m| m.exposure_sign_flips as f64), + bias_sign_flips: pick(|m| m.bias_sign_flips as f64), } } } @@ -208,7 +208,7 @@ mod tests { metrics: RunMetrics { total_pips: v, max_drawdown: v, - exposure_sign_flips: v as u64, + bias_sign_flips: v as u64, r: None, }, }, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 8d94a09..dc13443 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -22,11 +22,14 @@ pub struct RunMetrics { /// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the /// curve is monotonic non-decreasing or empty). pub max_drawdown: f64, - /// Count of adjacent recorded exposure samples whose sign differs (a zero - /// exposure normalizes to sign `0`, so flat is distinct from long/short). + /// Count of adjacent recorded bias samples whose sign differs (a zero + /// bias normalizes to sign `0`, so flat is distinct from long/short). /// 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, + /// into/out of flat — the plain sign-change count over the bias series. + /// The serde alias accepts the pre-rename `exposure_sign_flips` key so legacy + /// `runs.jsonl` lines still deserialise (C14/C18 back-compat). + #[serde(alias = "exposure_sign_flips")] + pub bias_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 @@ -306,20 +309,20 @@ pub fn summarize( } } - // exposure sign-flips: adjacent samples whose normalized sign differs. - let mut exposure_sign_flips = 0u64; + // bias sign-flips: adjacent samples whose normalized sign differs. + let mut bias_sign_flips = 0u64; let mut prev: Option = None; for &(_, v) in exposure { let s = sign0(v); if let Some(p) = prev && s != p { - exposure_sign_flips += 1; + bias_sign_flips += 1; } prev = Some(s); } - RunMetrics { total_pips, max_drawdown, exposure_sign_flips, r: None } + RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None } } /// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns @@ -815,7 +818,7 @@ mod tests { let m = summarize(&[], &[]); assert_eq!(m.total_pips, 0.0); assert_eq!(m.max_drawdown, 0.0); - assert_eq!(m.exposure_sign_flips, 0); + assert_eq!(m.bias_sign_flips, 0); } #[test] @@ -839,14 +842,14 @@ mod tests { // signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3. let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]); let m = summarize(&[], &exposure); - assert_eq!(m.exposure_sign_flips, 3); + assert_eq!(m.bias_sign_flips, 3); } #[test] fn summarize_sign_flips_zero_on_constant_sign() { let exposure = samples(&[0.2, 0.5, 1.0, 0.7]); let m = summarize(&[], &exposure); - assert_eq!(m.exposure_sign_flips, 0); + assert_eq!(m.bias_sign_flips, 0); } #[test] @@ -885,13 +888,13 @@ mod tests { metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, - exposure_sign_flips: 1, + bias_sign_flips: 1, r: None, }, }; assert_eq!( report.to_json(), - r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["exposure_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}}"#, + r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["exposure_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#, ); } @@ -910,7 +913,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, r: None }, + metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_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()); @@ -930,7 +933,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, r: None }, + metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_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) @@ -944,7 +947,7 @@ mod tests { let m = RunMetrics { total_pips: 3.0, max_drawdown: 1.0, - exposure_sign_flips: 2, + bias_sign_flips: 2, r: Some(RMetrics { expectancy_r: 0.5, n_trades: 4, @@ -965,6 +968,18 @@ mod tests { assert_eq!(back, m); } + #[test] + fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() { + // a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it. + let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#; + let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises"); + assert_eq!(m.bias_sign_flips, 3); + // new output serialises the NEW key, and the old key is gone from output. + let json = serde_json::to_string(&m).unwrap(); + assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}"); + assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}"); + } + #[test] fn legacy_runmetrics_without_r_field_deserialises_to_none() { // a pre-`r` runs.jsonl line: no `r` key at all. @@ -976,7 +991,7 @@ mod tests { #[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 m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, bias_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}"); } diff --git a/crates/aura-engine/tests/risk_executor.rs b/crates/aura-engine/tests/risk_executor.rs index f467966..920620a 100644 --- a/crates/aura-engine/tests/risk_executor.rs +++ b/crates/aura-engine/tests/risk_executor.rs @@ -139,7 +139,7 @@ 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 m = RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_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"); diff --git a/crates/aura-ingest/examples/ger40_breakout_compare.rs b/crates/aura-ingest/examples/ger40_breakout_compare.rs index 8032e42..dd36143 100644 --- a/crates/aura-ingest/examples/ger40_breakout_compare.rs +++ b/crates/aura-ingest/examples/ger40_breakout_compare.rs @@ -127,7 +127,7 @@ fn main() { match run_cell(&server, symbol, BAR_MINUTES, from, to) { Some((m, entries)) => println!( "{symbol:<8} {note:<14} {entries:>9} {:>11.1} {:>10.1} {:>7}", - m.total_pips, m.max_drawdown, m.exposure_sign_flips + m.total_pips, m.max_drawdown, m.bias_sign_flips ), None => println!("{symbol:<8} (no overlapping OHLC files — skip)"), } @@ -151,7 +151,7 @@ fn main() { format!("{period}m"), m.total_pips, m.max_drawdown, - m.exposure_sign_flips, + m.bias_sign_flips, ), None => println!("{period}m (no data — skip)"), } diff --git a/crates/aura-ingest/examples/ger40_breakout_real.rs b/crates/aura-ingest/examples/ger40_breakout_real.rs index 3d6aee3..948943e 100644 --- a/crates/aura-ingest/examples/ger40_breakout_real.rs +++ b/crates/aura-ingest/examples/ger40_breakout_real.rs @@ -86,7 +86,7 @@ fn main() { println!("--- metrics (sim-optimal broker, pips = index points) ---"); println!(" total_pips = {:.1}", metrics.total_pips); println!(" max_drawdown = {:.1}", metrics.max_drawdown); - println!(" exposure_sign_flips = {}", metrics.exposure_sign_flips); + println!(" bias_sign_flips = {}", metrics.bias_sign_flips); println!(); // --- 2. The compact entry/exit trace. ----------------------------------- diff --git a/crates/aura-ingest/examples/ger40_breakout_sweep.rs b/crates/aura-ingest/examples/ger40_breakout_sweep.rs index f7c4fd5..7c75001 100644 --- a/crates/aura-ingest/examples/ger40_breakout_sweep.rs +++ b/crates/aura-ingest/examples/ger40_breakout_sweep.rs @@ -146,7 +146,7 @@ fn main() { exit, m.total_pips, m.max_drawdown, - m.exposure_sign_flips, + m.bias_sign_flips, ); } println!(); diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index ee98a6f..eb4c26a 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -100,12 +100,12 @@ impl Registry { enum Metric { TotalPips, MaxDrawdown, - ExposureSignFlips, + BiasSignFlips, } /// The per-metric **best-first ordering** of two reports: `Less` means `a` is /// better than `b`. "Best" is fixed by each metric's meaning: `total_pips` -/// higher-is-better; `max_drawdown` and `exposure_sign_flips` lower-is-better. +/// higher-is-better; `max_drawdown` and `bias_sign_flips` lower-is-better. /// `f64` keys use `partial_cmp` (total, since metrics are finite by /// construction). An unknown metric name is a `RegistryError::UnknownMetric`. /// @@ -119,7 +119,8 @@ fn metric_cmp( let metric = match metric { "total_pips" => Metric::TotalPips, "max_drawdown" => Metric::MaxDrawdown, - "exposure_sign_flips" => Metric::ExposureSignFlips, + // accept the new name AND the pre-rename one (CLI back-compat). + "bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips, other => return Err(RegistryError::UnknownMetric(other.to_string())), }; Ok(move |a: &RunReport, b: &RunReport| match metric { @@ -127,8 +128,8 @@ fn metric_cmp( Metric::TotalPips => b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap(), // lower-is-better -> better when smaller Metric::MaxDrawdown => a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap(), - Metric::ExposureSignFlips => { - a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips) + Metric::BiasSignFlips => { + a.metrics.bias_sign_flips.cmp(&b.metrics.bias_sign_flips) } }) } @@ -182,7 +183,7 @@ impl fmt::Display for RegistryError { } RegistryError::UnknownMetric(m) => write!( f, - "unknown metric '{m}' (known: total_pips, max_drawdown, exposure_sign_flips)" + "unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips)" ), } } @@ -211,7 +212,7 @@ mod tests { seed: 0, broker: "b".to_string(), }, - metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips, r: None }, + metrics: RunMetrics { total_pips, max_drawdown, bias_sign_flips: flips, r: None }, } } @@ -293,8 +294,12 @@ mod tests { assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd"); assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc + // the legacy rank-string still resolves (the dual-accept alias). let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips"); - assert_eq!(by_flips[0].metrics.exposure_sign_flips, 1); // lower-is-better -> asc + assert_eq!(by_flips[0].metrics.bias_sign_flips, 1); // lower-is-better -> asc + // the new rank-string ranks identically. + let by_flips_new = rank_by(reports.clone(), "bias_sign_flips").expect("rank flips (new name)"); + assert_eq!(by_flips_new[0].metrics.bias_sign_flips, 1); } #[test]