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