feat(0067): SQN100 metric + stage1-r sweep --trace
Two additive follow-ups to the cycle-0065/0066 R-sweep surface. Part A (#130) — n-normalized SQN (SQN100): RMetrics gains sqn_normalized = (mean_R/stdev_R)·√(min(n,100)), a turnover-robust ranking objective comparable across sweep members with different trade counts. The raw sqn is kept byte-identical (computed sqrt(n)*mean/sd, NOT via a factored shared ratio, so the existing metric does not drift even by 1 ULP). The field carries #[serde(default)] for C18 back-compat (a pre-0067 r: block deserialises with sqn_normalized = 0.0). The registry learns Metric::SqnNormalized (opt-in; the default ranker and raw sqn are unchanged), so `runs family rank sqn_normalized` works. The 0066 single-run golden is re-baselined (the r: block gains the field; for n=3 ≤ 100 the cap is inactive so sqn_normalized == sqn). Part B (#135) — wire --trace for the stage1-r sweep: stage1_r_sweep_family now honours --trace, persisting each member's equity/exposure/r_equity under runs/traces/<n>/<member_key>/ via the existing persist_traces_r (mirroring momentum_sweep_family); the interim run_sweep refusal guard is dropped. --trace is now symmetric across all three sweep strategies and a swept member is chartable (chart <n>/<member> --tap r_equity). Tests: SQN100 cap / below-cap / empty + serde back-compat (engine); rank-by sqn_normalized + unknown-metric message (registry); an E2E guard that both SQN values fold from recorded records and agree below the cap (the cross-crate column seam); inverted stage1-r --trace persistence + a CLI rank-by- sqn_normalized integration. Full workspace suite green (527 passed, 0 failed); clippy --all-targets -D warnings clean. Derived fork decisions logged on #130 and #135 (cap = fixed const 100; opt-in metric, default ranker unchanged; serde(default); Part B mirrors momentum_sweep_family + reuses persist_traces_r). closes #130 closes #135
This commit is contained in:
+18
-22
@@ -1110,10 +1110,11 @@ fn stage1_r_broker_label(pip_size: f64) -> String {
|
||||
/// degenerate axes; the stop defines the R unit, so varying it would break
|
||||
/// cross-member SQN comparability). Each member folds the dense R-record via
|
||||
/// summarize_r, so its RunReport carries `r: Some(..)` and the family is rankable
|
||||
/// by sqn / expectancy_r / net_expectancy_r. Per-member tracing is a follow-up:
|
||||
/// `--trace` is refused for this strategy in `run_sweep` (rather than silently
|
||||
/// no-op'd), so this `_trace` slot — kept for dispatch uniformity — is always None.
|
||||
fn stage1_r_sweep_family(_trace: Option<&str>, data: &DataSource) -> SweepFamily {
|
||||
/// by sqn / sqn_normalized / expectancy_r / net_expectancy_r. With `--trace`, each
|
||||
/// member's equity / exposure / r_equity streams are persisted under
|
||||
/// `runs/traces/<name>/<member_key>/` via `persist_traces_r` (mirroring
|
||||
/// `momentum_sweep_family`), so a swept member is chartable.
|
||||
fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily {
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window();
|
||||
// a single throwaway floated build, only to resolve param_space (borrow) then
|
||||
@@ -1126,13 +1127,14 @@ fn stage1_r_sweep_family(_trace: Option<&str>, data: &DataSource) -> SweepFamily
|
||||
let (tx_req, _) = mpsc::channel();
|
||||
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None);
|
||||
let space = bp.param_space();
|
||||
bp.axis("fast.length", [2, 3])
|
||||
.axis("slow.length", [6, 12])
|
||||
let binder = bp.axis("fast.length", [2, 3]).axis("slow.length", [6, 12]);
|
||||
let varying: HashSet<String> = binder.varying_axes().into_iter().collect();
|
||||
binder
|
||||
.sweep(|point| {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, _rx_req) = mpsc::channel();
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None)
|
||||
.bootstrap_with_cells(point)
|
||||
.expect("stage1-r grid points are kind-checked against param_space");
|
||||
@@ -1140,6 +1142,7 @@ fn stage1_r_sweep_family(_trace: Option<&str>, data: &DataSource) -> SweepFamily
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
|
||||
// record the swept knobs PLUS the fixed R-defining params (the stop and
|
||||
// bias scale), matching the single run: a family member must be
|
||||
// reproducible from its own manifest (C18), and the constant stop — which
|
||||
@@ -1149,8 +1152,12 @@ fn stage1_r_sweep_family(_trace: Option<&str>, data: &DataSource) -> SweepFamily
|
||||
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
|
||||
named.push(("stop_length".to_string(), Scalar::i64(STAGE1_R_STOP_LENGTH)));
|
||||
named.push(("stop_k".to_string(), Scalar::f64(STAGE1_R_STOP_K)));
|
||||
let key = member_key(&named, &varying);
|
||||
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
|
||||
manifest.broker = stage1_r_broker_label(pip);
|
||||
if let Some(name) = trace {
|
||||
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows);
|
||||
}
|
||||
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
metrics.r = Some(summarize_r(&r_rows, 0.0));
|
||||
RunReport { manifest, metrics }
|
||||
@@ -1278,22 +1285,11 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
||||
/// `aura sweep [--strategy <sma|momentum|stage1-r>] [--name <n>|--trace <n>]`: run the
|
||||
/// selected built-in sweep, persist it as a *family* (related records sharing one
|
||||
/// `family_id`, C18/C21) via `append_family`, and print each point's record line
|
||||
/// carrying the assigned id. With `--trace`, the `sma`/`momentum` strategies also
|
||||
/// persist each member's streams under `runs/traces/<n>/<member_key>/` (opt-in);
|
||||
/// `stage1-r` ignores the trace slot for now — the family is persisted but no
|
||||
/// per-member streams are written (per-member stage1-r tracing is a follow-up, see
|
||||
/// `stage1_r_sweep_family`).
|
||||
/// carrying the assigned id. With `--trace`, every strategy
|
||||
/// (`sma`/`momentum`/`stage1-r`) persists each member's streams under
|
||||
/// `runs/traces/<n>/<member_key>/` (opt-in); the `stage1-r` member also carries
|
||||
/// the `r_equity` tap (via `persist_traces_r`).
|
||||
fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) {
|
||||
// --trace (per-member stream tracing) is not yet wired for the stage1-r sweep
|
||||
// (4-tap members; a documented follow-up). Refuse it explicitly rather than
|
||||
// accept the flag and silently write nothing — `--name` records the family.
|
||||
if persist && strategy == Strategy::Stage1R {
|
||||
eprintln!(
|
||||
"aura: --trace is not yet supported for --strategy stage1-r \
|
||||
(per-member tracing is a follow-up); use --name to record the rankable family"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
if persist
|
||||
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
|
||||
{
|
||||
|
||||
@@ -1615,7 +1615,7 @@ fn stage1_r_single_run_output_golden() {
|
||||
let metrics = s[start..].trim_end();
|
||||
assert_eq!(
|
||||
metrics,
|
||||
r#""metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}}}"#,
|
||||
r#""metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}}}"#,
|
||||
"stage1-r single-run metric output drifted from the golden: {s}"
|
||||
);
|
||||
}
|
||||
@@ -1681,25 +1681,138 @@ fn sweep_strategy_stage1_r_ranks_a_family_by_sqn() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#133): `--trace` (per-member stream tracing) is not yet wired for the
|
||||
/// stage1-r sweep, so it is REFUSED explicitly (exit 2) rather than accepted and
|
||||
/// silently writing nothing — unlike sma/momentum, whose `--trace` writes member
|
||||
/// dirs. `--name` (record the rankable family, no per-member streams) still works.
|
||||
/// Property (#130): `sqn_normalized` (SQN100) is a working rank key *through the
|
||||
/// binary*, not just an in-memory `metric_cmp` arm. A persisted stage1-r family
|
||||
/// must carry the new `sqn_normalized` field in each member's on-disk `r` block,
|
||||
/// and `aura runs family <id> rank sqn_normalized` must parse the new key (it was
|
||||
/// an `UnknownMetric` exit-2 error before #130), read the field back from
|
||||
/// `families.jsonl`, and order the members highest-first. This exercises the CLI
|
||||
/// parse path + the serde round-trip of the new field through the family store,
|
||||
/// which the registry unit test (synthetic in-memory reports) never touches. The
|
||||
/// signal-only grid means each member's n_trades is small (cap inactive), so
|
||||
/// `sqn_normalized == sqn` per member and the SQN100 ranking matches the raw-SQN
|
||||
/// ranking — asserted as a cross-check.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_r_trace_is_refused_name_works() {
|
||||
fn sweep_strategy_stage1_r_ranks_a_family_by_sqn_normalized() {
|
||||
let cwd = temp_cwd("sweep-stage1-r-sqn100");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-r"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy stage1-r");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"stage1-r sweep exit: {:?}; stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
// every persisted member carries the new field in its r block.
|
||||
for line in stdout.lines() {
|
||||
assert!(
|
||||
line.contains("\"sqn_normalized\""),
|
||||
"member r block must carry sqn_normalized: {line}"
|
||||
);
|
||||
}
|
||||
// rank by the NEW key — must succeed (was UnknownMetric/exit-2 before #130).
|
||||
let rank = Command::new(BIN)
|
||||
.args(["runs", "family", "sweep-0", "rank", "sqn_normalized"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn rank sqn_normalized");
|
||||
assert!(
|
||||
rank.status.success(),
|
||||
"rank sqn_normalized must succeed (exit 0): {:?}; stderr: {}",
|
||||
rank.status,
|
||||
String::from_utf8_lossy(&rank.stderr)
|
||||
);
|
||||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||||
assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
|
||||
let norms: Vec<f64> = rank_out
|
||||
.lines()
|
||||
.map(|l| {
|
||||
let key = "\"sqn_normalized\":";
|
||||
let start = l.find(key).expect("line has sqn_normalized") + key.len();
|
||||
let tail = &l[start..];
|
||||
let end = tail.find([',', '}']).expect("token end");
|
||||
tail[..end].parse().expect("sqn_normalized is an f64")
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
norms.windows(2).all(|w| w[0] >= w[1]),
|
||||
"rank not descending by sqn_normalized: {norms:?}"
|
||||
);
|
||||
// cap inactive at these trade counts -> per member sqn_normalized == sqn, so the
|
||||
// SQN100 ranking is the same as the raw-sqn ranking.
|
||||
let by_sqn = Command::new(BIN)
|
||||
.args(["runs", "family", "sweep-0", "rank", "sqn"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn rank sqn");
|
||||
let by_sqn_out = String::from_utf8(by_sqn.stdout).expect("utf-8");
|
||||
let sqns: Vec<f64> = by_sqn_out
|
||||
.lines()
|
||||
.map(|l| {
|
||||
let key = "\"sqn\":";
|
||||
let start = l.find(key).expect("line has sqn") + key.len();
|
||||
let tail = &l[start..];
|
||||
let end = tail.find([',', '}']).expect("token end");
|
||||
tail[..end].parse().expect("sqn is an f64")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(norms, sqns, "below the cap, SQN100 ranking must equal raw-sqn ranking");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#135): `aura sweep --strategy stage1-r --trace <n>` now persists one
|
||||
/// member dir per grid point (it was refused with exit 2 before). The 2×2 fast×slow
|
||||
/// grid yields 4 members; each carries the THIRD `r_equity` tap (the stage1-r member
|
||||
/// has the R curve, unlike the two-tap sma/momentum members), and a member is
|
||||
/// chartable via `chart <n>/<member> --tap r_equity`. `--name` (record the rankable
|
||||
/// family, no per-member streams) still works.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_r_trace_persists_member_dirs_with_r_equity() {
|
||||
let cwd = temp_cwd("sweep-stage1-r-trace");
|
||||
let traced = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-r", "--trace", "t1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy stage1-r --trace");
|
||||
assert_eq!(
|
||||
traced.status.code(),
|
||||
Some(2),
|
||||
"stage1-r --trace must be refused (exit 2): {:?}",
|
||||
traced.status
|
||||
assert!(
|
||||
traced.status.success(),
|
||||
"stage1-r --trace must persist members (exit 0): {:?}; stderr: {}",
|
||||
traced.status,
|
||||
String::from_utf8_lossy(&traced.stderr)
|
||||
);
|
||||
// --name records the rankable family (no per-member streams) — succeeds.
|
||||
let base = cwd.join("runs/traces/t1");
|
||||
let members: Vec<String> = std::fs::read_dir(&base)
|
||||
.expect("read t1 trace dir")
|
||||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(members.len(), 4, "2x2 fast×slow stage1-r grid = 4 member dirs; got {members:?}");
|
||||
for m in &members {
|
||||
assert!(
|
||||
m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||||
"non-portable member dir name: {m}"
|
||||
);
|
||||
assert!(
|
||||
base.join(m).join("r_equity.json").exists(),
|
||||
"member {m} must carry the r_equity tap"
|
||||
);
|
||||
}
|
||||
// one member is chartable via chart <name>/<member> --tap r_equity.
|
||||
let one = &members[0];
|
||||
let chart = Command::new(BIN)
|
||||
.args(["chart", &format!("t1/{one}"), "--tap", "r_equity"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura chart t1/<member> --tap r_equity");
|
||||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&chart.stdout).contains("r_equity"),
|
||||
"chart must render the r_equity series"
|
||||
);
|
||||
// --name records the rankable family (no per-member streams) — still succeeds.
|
||||
let named = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-r", "--name", "n1"])
|
||||
.current_dir(&cwd)
|
||||
|
||||
@@ -52,6 +52,11 @@ pub struct RMetrics {
|
||||
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
|
||||
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
|
||||
pub sqn: f64, // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0
|
||||
/// (mean_R / sample-stdev_R) · √(min(n, SQN_CAP)): the n-normalized SQN
|
||||
/// ("SQN score", Van Tharp), turnover-robust vs. the raw `sqn`. n<2 or
|
||||
/// zero-variance -> 0.0.
|
||||
#[serde(default)]
|
||||
pub sqn_normalized: f64,
|
||||
pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest
|
||||
pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0]
|
||||
}
|
||||
@@ -71,6 +76,10 @@ mod r_col {
|
||||
pub const UNREALIZED_R: usize = 12;
|
||||
}
|
||||
|
||||
/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN
|
||||
/// score"): capping n stops the single-number objective rewarding sheer turnover.
|
||||
const SQN_CAP: u64 = 100;
|
||||
|
||||
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
|
||||
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
|
||||
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
|
||||
@@ -118,6 +127,7 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
max_r_drawdown: 0.0,
|
||||
n_open_at_end: 0,
|
||||
sqn: 0.0,
|
||||
sqn_normalized: 0.0,
|
||||
net_expectancy_r: 0.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
};
|
||||
@@ -144,13 +154,24 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
max_dd = dd;
|
||||
}
|
||||
}
|
||||
// SQN = √n · mean / sample-stdev. n < 2 or zero variance -> 0.0 (dispersion undefined).
|
||||
let sqn = if n < 2 {
|
||||
0.0
|
||||
// SQN = √n · mean / sample-stdev (raw, Van Tharp). SQN100 = √(min(n,SQN_CAP)) ·
|
||||
// mean / sd — the same dispersion ratio with the trade count capped
|
||||
// (turnover-robust). n < 2 or zero variance -> 0.0 for both (dispersion undefined).
|
||||
let (sqn, sqn_normalized) = if n < 2 {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
|
||||
let sd = var.sqrt();
|
||||
if sd > 0.0 { (n as f64).sqrt() * mean / sd } else { 0.0 }
|
||||
if sd > 0.0 {
|
||||
// raw sqn kept bit-identical to the pre-0067 expression (sqrt(n)*mean/sd,
|
||||
// not factored via a shared ratio) so the existing metric is byte-unchanged.
|
||||
(
|
||||
(n as f64).sqrt() * mean / sd,
|
||||
(n.min(SQN_CAP) as f64).sqrt() * mean / sd,
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
};
|
||||
// net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R
|
||||
// by dividing by that trade's latched R-distance (a zero distance contributes no cost).
|
||||
@@ -190,6 +211,7 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
max_r_drawdown: max_dd,
|
||||
n_open_at_end,
|
||||
sqn,
|
||||
sqn_normalized,
|
||||
net_expectancy_r,
|
||||
conviction_terciles_r,
|
||||
}
|
||||
@@ -690,6 +712,7 @@ mod tests {
|
||||
assert_eq!(m.expectancy_r, 0.0);
|
||||
assert_eq!(m.max_r_drawdown, 0.0);
|
||||
assert_eq!(m.sqn, 0.0);
|
||||
assert_eq!(m.sqn_normalized, 0.0);
|
||||
assert_eq!(m.net_expectancy_r, 0.0);
|
||||
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
|
||||
}
|
||||
@@ -748,6 +771,40 @@ mod tests {
|
||||
assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_sqn_normalized_caps_trade_count_at_100() {
|
||||
// 144 closed trades alternating R = 1.0 / 2.0 (nonzero variance, sd > 0).
|
||||
// raw sqn = √144·q, SQN100 = √(min(144,100))·q = √100·q for the SAME ratio
|
||||
// q = mean/sd, so sqn / sqn_normalized = √(144/100) = 1.2, independent of q.
|
||||
let rec: Vec<_> = (0..144)
|
||||
.map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0))
|
||||
.collect();
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert_eq!(m.n_trades, 144);
|
||||
assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized);
|
||||
assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100");
|
||||
assert!(
|
||||
(m.sqn / m.sqn_normalized - 1.2).abs() < 1e-9,
|
||||
"sqn / sqn_normalized = √(144/100) = 1.2; got {}",
|
||||
m.sqn / m.sqn_normalized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_sqn_normalized_equals_raw_sqn_below_cap() {
|
||||
// n = 4 <= 100 -> cap inactive -> SQN100 == raw sqn (same ledger as
|
||||
// summarize_r_sqn_is_sqrt_n_mean_over_stdev, where sqn = 1.0).
|
||||
let rec = vec![
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, -1.0, false, 0.0),
|
||||
];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn");
|
||||
assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_conviction_terciles_order_by_bias() {
|
||||
// Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three
|
||||
@@ -958,6 +1015,7 @@ mod tests {
|
||||
max_r_drawdown: 0.5,
|
||||
n_open_at_end: 1,
|
||||
sqn: 1.0,
|
||||
sqn_normalized: 1.0,
|
||||
net_expectancy_r: 0.4,
|
||||
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
||||
}),
|
||||
@@ -968,6 +1026,16 @@ mod tests {
|
||||
assert_eq!(back, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rmetrics_deserializes_without_sqn_normalized_field_to_zero() {
|
||||
// C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized
|
||||
// existed) lacks the field; serde(default) must fill it with 0.0.
|
||||
let legacy = r#"{"expectancy_r":1.0,"n_trades":4,"win_rate":0.5,"avg_win_r":1.5,"avg_loss_r":-0.5,"profit_factor":3.0,"max_r_drawdown":0.5,"n_open_at_end":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#;
|
||||
let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes");
|
||||
assert_eq!(m.sqn_normalized, 0.0);
|
||||
assert_eq!(m.sqn, 1.0);
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -239,6 +239,48 @@ fn net_of_cost_is_charged_per_r_so_a_wider_stop_dilutes_it() {
|
||||
assert!(tight_cost > wide_cost, "a tighter stop must pay more R per fixed price-unit cost");
|
||||
}
|
||||
|
||||
/// Property (#130): **`sqn` and `sqn_normalized` (SQN100) are computed from the
|
||||
/// producer's *recorded* dense records, not hand-built rows, and below the
|
||||
/// 100-trade cap the two are identical.** Every `summarize_r` test that touches
|
||||
/// `sqn_normalized` lives in `report.rs` and feeds rows whose column indices are
|
||||
/// hardcoded independently of the producer — the exact seam this file's header
|
||||
/// warns about. Here a real `FixedStop -> PositionManagement` chain produces four
|
||||
/// closed trades (three +1R winners, one -1R loser) whose recorded `realized_r`
|
||||
/// the consumer reads back: mean_R = 0.5, sample-sd = 1.0, so q = mean/sd = 0.5
|
||||
/// and `sqn = √4·q = 1.0`. n = 4 ≤ SQN_CAP (100), so the cap is inactive and
|
||||
/// `sqn_normalized = √(min(4,100))·q = sqn` *exactly*. A producer-layout drift
|
||||
/// that misaligned `realized_r` would break both values here even though the
|
||||
/// hand-built `report.rs` units stayed green.
|
||||
#[test]
|
||||
fn sqn_and_sqn_normalized_fold_from_recorded_records_and_agree_below_cap() {
|
||||
let mut stop = FixedStop::new(10.0);
|
||||
let mut path: Vec<(f64, f64)> = vec![];
|
||||
// three winners: open long @100 (FixedStop dist 10 -> latched 10), exit @110
|
||||
// via bias->0 -> realized_r = (110-100)/10 = +1R each.
|
||||
for _ in 0..3 {
|
||||
path.push((1.0, 100.0));
|
||||
path.push((1.0, 105.0));
|
||||
path.push((0.0, 110.0));
|
||||
}
|
||||
// one loser: open @100, then bias->0 as price touches the 90 stop -> -1R.
|
||||
path.push((1.0, 100.0));
|
||||
path.push((1.0, 100.0));
|
||||
path.push((0.0, 90.0));
|
||||
let m = run_chain(&mut stop, &path);
|
||||
assert_eq!(m.n_trades, 4, "three winners + one loser, all closed before window end");
|
||||
assert_eq!(m.n_open_at_end, 0, "every trade exits via bias->0; nothing open at end");
|
||||
assert!((m.expectancy_r - 0.5).abs() < 1e-9, "mean R = (1+1+1-1)/4; got {}", m.expectancy_r);
|
||||
// sqn = √n · mean/sd = √4 · (0.5/1.0) = 1.0, folded from the recorded realized_r.
|
||||
assert!((m.sqn - 1.0).abs() < 1e-9, "raw sqn = √4·(0.5/1.0); got {}", m.sqn);
|
||||
// n = 4 <= SQN_CAP(100): the cap is inactive, so SQN100 == raw sqn exactly.
|
||||
assert!(
|
||||
(m.sqn_normalized - m.sqn).abs() < 1e-12,
|
||||
"below the cap, sqn_normalized must equal sqn (got {} vs {})",
|
||||
m.sqn_normalized,
|
||||
m.sqn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Contract guard (the lockstep cross-crate column-index agreement `report.rs`
|
||||
/// names): `PM_WIDTH` is the fixed dense-record arity, the indices `summarize_r`
|
||||
/// reads the dense record by must equal the indices the producer's authoritative
|
||||
|
||||
@@ -102,13 +102,14 @@ enum Metric {
|
||||
MaxDrawdown,
|
||||
BiasSignFlips,
|
||||
Sqn,
|
||||
SqnNormalized,
|
||||
ExpectancyR,
|
||||
NetExpectancyR,
|
||||
}
|
||||
|
||||
/// 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` and
|
||||
/// the three R keys (`sqn`, `expectancy_r`, `net_expectancy_r`) higher-is-better;
|
||||
/// the four R keys (`sqn`, `sqn_normalized`, `expectancy_r`, `net_expectancy_r`) higher-is-better;
|
||||
/// `max_drawdown` and `bias_sign_flips` lower-is-better. The top-level `f64` keys
|
||||
/// (`total_pips`, `max_drawdown`) use `partial_cmp` — finite by construction, so
|
||||
/// the `Ordering` always exists. The R keys live in an optional `metrics.r` block
|
||||
@@ -130,6 +131,7 @@ fn metric_cmp(
|
||||
// accept the new name AND the pre-rename one (CLI back-compat).
|
||||
"bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips,
|
||||
"sqn" => Metric::Sqn,
|
||||
"sqn_normalized" => Metric::SqnNormalized,
|
||||
"expectancy_r" => Metric::ExpectancyR,
|
||||
"net_expectancy_r" => Metric::NetExpectancyR,
|
||||
other => return Err(RegistryError::UnknownMetric(other.to_string())),
|
||||
@@ -150,6 +152,9 @@ fn metric_cmp(
|
||||
// higher-is-better; total_cmp so NEG_INFINITY orders deterministically and
|
||||
// no NaN can panic.
|
||||
Metric::Sqn => r_get(b, |r| r.sqn).total_cmp(&r_get(a, |r| r.sqn)),
|
||||
Metric::SqnNormalized => {
|
||||
r_get(b, |r| r.sqn_normalized).total_cmp(&r_get(a, |r| r.sqn_normalized))
|
||||
}
|
||||
Metric::ExpectancyR => {
|
||||
r_get(b, |r| r.expectancy_r).total_cmp(&r_get(a, |r| r.expectancy_r))
|
||||
}
|
||||
@@ -208,7 +213,7 @@ impl fmt::Display for RegistryError {
|
||||
}
|
||||
RegistryError::UnknownMetric(m) => write!(
|
||||
f,
|
||||
"unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips, sqn, expectancy_r, net_expectancy_r)"
|
||||
"unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips, sqn, sqn_normalized, expectancy_r, net_expectancy_r)"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -244,7 +249,7 @@ mod tests {
|
||||
|
||||
/// A family member carrying an R block — mirrors `report_with` but sets
|
||||
/// `metrics.r = Some(..)`. Only sqn / expectancy_r / net_expectancy_r vary per
|
||||
/// call (the rank keys); the rest are fixed plausible values.
|
||||
/// call (the rank keys); `sqn_normalized` mirrors `sqn`; the rest are fixed.
|
||||
fn report_with_r(sqn: f64, expectancy_r: f64, net_expectancy_r: f64) -> RunReport {
|
||||
let mut rep = report_with(0.0, 0.0, 0);
|
||||
rep.metrics.r = Some(RMetrics {
|
||||
@@ -257,6 +262,7 @@ mod tests {
|
||||
max_r_drawdown: 0.5,
|
||||
n_open_at_end: 0,
|
||||
sqn,
|
||||
sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here
|
||||
net_expectancy_r,
|
||||
conviction_terciles_r: [0.0, 0.0, 0.0],
|
||||
});
|
||||
@@ -275,6 +281,23 @@ mod tests {
|
||||
assert_eq!(sqns, vec![2.0, 1.0, 0.5], "sqn must rank highest-first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_by_sqn_normalized_orders_members_descending() {
|
||||
// report_with_r sets sqn_normalized == sqn, so ranking by the new key
|
||||
// orders these members highest-first by their sqn value.
|
||||
let reports = vec![
|
||||
report_with_r(0.5, 0.1, 0.1),
|
||||
report_with_r(2.0, 0.4, 0.4),
|
||||
report_with_r(1.0, 0.2, 0.2),
|
||||
];
|
||||
let ranked = rank_by(reports, "sqn_normalized").expect("rank sqn_normalized");
|
||||
let vals: Vec<f64> = ranked
|
||||
.iter()
|
||||
.map(|r| r.metrics.r.as_ref().unwrap().sqn_normalized)
|
||||
.collect();
|
||||
assert_eq!(vals, vec![2.0, 1.0, 0.5], "sqn_normalized must rank highest-first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_by_expectancy_and_net_expectancy_r() {
|
||||
let reports = vec![report_with_r(1.0, 0.1, 0.05), report_with_r(1.0, 0.3, 0.25)];
|
||||
@@ -297,6 +320,7 @@ mod tests {
|
||||
fn unknown_metric_message_lists_r_metrics() {
|
||||
let msg = RegistryError::UnknownMetric("nope".to_string()).to_string();
|
||||
assert!(msg.contains("sqn"), "msg: {msg}");
|
||||
assert!(msg.contains("sqn_normalized"), "msg: {msg}");
|
||||
assert!(msg.contains("expectancy_r"), "msg: {msg}");
|
||||
assert!(msg.contains("net_expectancy_r"), "msg: {msg}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user