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
This commit is contained in:
2026-06-24 13:11:46 +02:00
parent 34e8bbc803
commit c6c1d3bb10
9 changed files with 59 additions and 39 deletions
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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,
},
},
+32 -17
View File
@@ -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<f64> = 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}");
}
+1 -1
View File
@@ -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");
@@ -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)"),
}
@@ -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. -----------------------------------
@@ -146,7 +146,7 @@ fn main() {
exit,
m.total_pips,
m.max_drawdown,
m.exposure_sign_flips,
m.bias_sign_flips,
);
}
println!();
+13 -8
View File
@@ -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]