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