feat(stage1-r): R sweep families + rank-by-SQN
Realizes #133 (cycle 0066): SQN becomes the single-number objective for ranking a Stage-1 sweep family by R signal quality. aura-registry — metric_cmp learns three higher-is-better R metrics (sqn, expectancy_r, net_expectancy_r) that reach into RunReport.metrics.r; a member with r:None sorts to the worst position (treated as -inf via total_cmp), so a pip-only family ranked by an R metric degrades to ordinal order without error. The UnknownMetric "known:" list is extended. No persistence change — the family store already carries the r block. aura-cli — `aura sweep --strategy stage1-r` sweeps the stage1-r harness over a fast×slow SIGNAL grid (4 members), each folding the dense R-record via summarize_r so its RunReport carries r:Some(..). The grid varies only the signal: the stop and sizing are held fixed because risk_budget is R-invariant (#128) and bias.scale is sign-only under flat-1R (both degenerate axes for the ranked metric), and because the stop defines the R unit — varying it would break cross-member SQN comparability (the motivation for the deferred SQN100, #130). The stage1-r topology is extracted into a shared stage1_r_graph(.., fast_len, slow_len) helper: the single run binds Some(2)/Some(4) (output byte-unchanged), only the sweep floats the knobs. Per-member --trace tracing is deferred (a follow-up): rather than accept the flag and silently write nothing (a quality-review catch), `aura sweep --strategy stage1-r --trace` is refused explicitly (exit 2); --name records the rankable family. Verification — a golden characterization test (stage1_r_single_run_output_golden, added against HEAD before the refactor) verifies the helper extraction is byte-preserving; full workspace suite 521 passed / 0 failed; clippy --all-targets -D warnings clean. The grounding-check that signed the spec first BLOCKed a false "already byte-pinned" claim, which is why the golden is added rather than assumed. All load-bearing forks (signal-only grid, None-sorts-worst, --trace refusal, helper extraction, golden shape) are recorded on the #133 decision log. closes #133 refs #117 #130
This commit is contained in:
+104
-20
@@ -1097,6 +1097,59 @@ fn momentum_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily
|
||||
.expect("the momentum named grid matches the momentum param-space")
|
||||
}
|
||||
|
||||
/// The honest broker label for the dual-tap stage1-r harness: it runs a RiskExecutor
|
||||
/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report
|
||||
/// it (#132). Shared by the single run and the sweep so the two cannot drift.
|
||||
fn stage1_r_broker_label(pip_size: f64) -> String {
|
||||
format!("sim-optimal+risk-executor(pip_size={pip_size})")
|
||||
}
|
||||
|
||||
/// `aura sweep --strategy stage1-r`: sweep the stage1-r harness over a fast×slow
|
||||
/// SIGNAL grid (the stop + sizing stay fixed — see the cycle-0066 / #133 decision
|
||||
/// log: risk_budget is R-invariant and bias.scale is sign-only under flat-1R, both
|
||||
/// 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 {
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window();
|
||||
// a single throwaway floated build, only to resolve param_space (borrow) then
|
||||
// seed the named axes (move) — its taps are never drained, the per-point run_one
|
||||
// rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self,
|
||||
// .axis() consumes self, so one build suffices.
|
||||
let (tx_eq, _) = mpsc::channel();
|
||||
let (tx_ex, _) = mpsc::channel();
|
||||
let (tx_r, _) = mpsc::channel();
|
||||
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])
|
||||
.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 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");
|
||||
h.run(data.run_sources());
|
||||
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 named = zip_params(&space, point);
|
||||
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
|
||||
manifest.broker = stage1_r_broker_label(pip);
|
||||
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 }
|
||||
})
|
||||
.expect("the stage1-r named grid matches the stage1-r param-space")
|
||||
}
|
||||
|
||||
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||
/// production (`run_sweep`) renders *and* persists per point.
|
||||
#[cfg(test)]
|
||||
@@ -1122,10 +1175,11 @@ fn default_registry() -> Registry {
|
||||
enum Strategy {
|
||||
SmaCross,
|
||||
Momentum,
|
||||
Stage1R,
|
||||
}
|
||||
|
||||
/// Parse the `sweep` tail:
|
||||
/// `[--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
|
||||
/// `[--strategy <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
|
||||
/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura
|
||||
/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms,
|
||||
/// `i64`) require `--real` (there is no synthetic window knob). Pure (no I/O /
|
||||
@@ -1133,7 +1187,7 @@ enum Strategy {
|
||||
/// token, a flag without its value, an unknown strategy, both name flags, or a
|
||||
/// window flag without `--real` rejects.
|
||||
fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice), String> {
|
||||
let usage = || "sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
|
||||
let usage = || "sweep [--strategy <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
|
||||
let mut strategy = Strategy::SmaCross;
|
||||
let mut name: Option<(String, bool)> = None; // (name, persist)
|
||||
let mut real = RealWindowGrammar::default();
|
||||
@@ -1149,6 +1203,7 @@ fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice
|
||||
strategy = match *value {
|
||||
"sma" => Strategy::SmaCross,
|
||||
"momentum" => Strategy::Momentum,
|
||||
"stage1-r" => Strategy::Stage1R,
|
||||
_ => return Err(usage()),
|
||||
};
|
||||
}
|
||||
@@ -1212,12 +1267,25 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>]`: run the
|
||||
/// `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`, also persist each member's streams
|
||||
/// under `runs/traces/<n>/<member_key>/` (opt-in).
|
||||
/// 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`).
|
||||
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)
|
||||
{
|
||||
@@ -1228,6 +1296,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) {
|
||||
let family = match strategy {
|
||||
Strategy::SmaCross => sweep_family(persist.then_some(name), &data),
|
||||
Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data),
|
||||
Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data),
|
||||
};
|
||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
Ok(id) => id,
|
||||
@@ -1727,23 +1796,40 @@ fn stage1_r_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
static COL_PORTS: LazyLock<Vec<String>> =
|
||||
LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect());
|
||||
|
||||
/// The Stage-1 R harness: the SMA-cross → `Bias` signal fanned into BOTH a `SimBroker`
|
||||
/// (pip equity, the existing pip yardstick) AND the `risk_executor` (the dense R-record,
|
||||
/// the new R yardstick), so one run scores the same signal in pips and in R. Four taps:
|
||||
/// equity (SimBroker), exposure (Bias), the 14-column R-record (drained into
|
||||
/// `summarize_r`), and `r_equity = cum_realized_r + unrealized_r` (a single series for
|
||||
/// `aura chart --tap r_equity`). All params bound at build → compiles with an empty point.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn stage1_r_blueprint(
|
||||
/// The Stage-1 R harness topology, shared by the single run and the sweep. The two
|
||||
/// signal knobs are bound when `Some` (single run — identical to the old
|
||||
/// build-time bind, output byte-unchanged) and left free when `None` (sweep — they
|
||||
/// land in `param_space` as `fast.length` / `slow.length`). Four taps: equity
|
||||
/// (SimBroker), exposure (Bias), the 14-column R-record (→ summarize_r), and
|
||||
/// r_equity = cum_realized_r + unrealized_r.
|
||||
///
|
||||
/// The six-arg signature is a conscious keep, not an oversight: the four `tx_*`
|
||||
/// are the per-tap recorder channels (one Recorder edge each — equity, exposure,
|
||||
/// the R-record, r_equity), and `fast_len`/`slow_len` are the two floatable signal
|
||||
/// knobs. A sender bundle would only rename the same four channels into one struct
|
||||
/// field without removing an edge, trading the lint for indirection; the
|
||||
/// `too_many_arguments` allow is preferred over that churn.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn stage1_r_graph(
|
||||
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
fast_len: Option<i64>,
|
||||
slow_len: Option<i64>,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_r");
|
||||
// SMA-cross signal → Bias (the same signal the pip sample uses).
|
||||
let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2)));
|
||||
let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4)));
|
||||
let mut fast_b = Sma::builder().named("fast");
|
||||
if let Some(l) = fast_len {
|
||||
fast_b = fast_b.bind("length", Scalar::i64(l));
|
||||
}
|
||||
let fast = g.add(fast_b);
|
||||
let mut slow_b = Sma::builder().named("slow");
|
||||
if let Some(l) = slow_len {
|
||||
slow_b = slow_b.bind("length", Scalar::i64(l));
|
||||
}
|
||||
let slow = g.add(slow_b);
|
||||
let spread = g.add(Sub::builder());
|
||||
let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5)));
|
||||
// pip branch (verbatim from sample_blueprint_with_sinks).
|
||||
@@ -1803,7 +1889,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport {
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let flat = stage1_r_blueprint(tx_eq, tx_ex, tx_r, tx_req)
|
||||
let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4))
|
||||
.compile_with_params(&[])
|
||||
.expect("valid stage1-r blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness");
|
||||
@@ -1839,9 +1925,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport {
|
||||
0,
|
||||
pip_size,
|
||||
);
|
||||
// #132: the dual-tap harness runs a RiskExecutor branch alongside the SimBroker, so
|
||||
// the plain "sim-optimal" label would under-report it — record both honestly.
|
||||
manifest.broker = format!("sim-optimal+risk-executor(pip_size={pip_size})");
|
||||
manifest.broker = stage1_r_broker_label(pip_size);
|
||||
if let Some(name) = trace {
|
||||
persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows);
|
||||
}
|
||||
@@ -1971,7 +2055,7 @@ fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
|
||||
@@ -1598,3 +1598,111 @@ fn run_harness_stage1_r_is_byte_deterministic_across_runs() {
|
||||
};
|
||||
assert_eq!(run(), run(), "the stage1-r run must be byte-identical across runs (C1)");
|
||||
}
|
||||
|
||||
/// Golden characterization (cycle 0066): pins the EXACT metric output of the
|
||||
/// stage1-r single run, so the `stage1_r_graph` helper extraction (Task 2) is
|
||||
/// VERIFIED byte-preserving, not assumed. Captured against HEAD before the
|
||||
/// refactor; the refactor must keep it identical (a drift is a behaviour change →
|
||||
/// debug). Commit-agnostic: pins only the `metrics` object, since the manifest
|
||||
/// carries the volatile build commit.
|
||||
#[test]
|
||||
fn stage1_r_single_run_output_golden() {
|
||||
let out = Command::new(BIN).args(["run", "--harness", "stage1-r"]).output().unwrap();
|
||||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
let key = "\"metrics\":";
|
||||
let start = s.find(key).expect("stdout has a metrics object");
|
||||
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]}}}"#,
|
||||
"stage1-r single-run metric output drifted from the golden: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#133): `aura sweep --strategy stage1-r` runs the stage1-r harness over
|
||||
/// the fast×slow signal grid (4 members), each carrying an R block, persisted as a
|
||||
/// rankable family; `runs family <id> rank sqn` orders them highest-SQN first.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_r_ranks_a_family_by_sqn() {
|
||||
let cwd = temp_cwd("sweep-stage1-r");
|
||||
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");
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines.len(), 4, "stage1-r sweep must print 4 member lines: {stdout:?}");
|
||||
for line in &lines {
|
||||
assert!(line.contains("\"r\":{"), "member must carry an r block: {line}");
|
||||
assert!(line.contains("\"sqn\""), "member r block must carry sqn: {line}");
|
||||
}
|
||||
// rank the family by sqn — highest first.
|
||||
let rank = Command::new(BIN)
|
||||
.args(["runs", "family", "sweep-0", "rank", "sqn"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn rank sqn");
|
||||
assert!(rank.status.success(), "rank sqn exit: {:?}", rank.status);
|
||||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||||
assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
|
||||
let sqns: Vec<f64> = rank_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!(sqns.windows(2).all(|w| w[0] >= w[1]), "rank not descending by sqn: {sqns:?}");
|
||||
// an unknown metric is a usage error (exit 2).
|
||||
let bogus = Command::new(BIN)
|
||||
.args(["runs", "family", "sweep-0", "rank", "bogus"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn bogus");
|
||||
assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);
|
||||
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.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_r_trace_is_refused_name_works() {
|
||||
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
|
||||
);
|
||||
// --name records the rankable family (no per-member streams) — succeeds.
|
||||
let named = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-r", "--name", "n1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy stage1-r --name");
|
||||
assert!(
|
||||
named.status.success(),
|
||||
"stage1-r --name must succeed: {:?}; stderr: {}",
|
||||
named.status,
|
||||
String::from_utf8_lossy(&named.stderr)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
@@ -101,13 +101,21 @@ enum Metric {
|
||||
TotalPips,
|
||||
MaxDrawdown,
|
||||
BiasSignFlips,
|
||||
Sqn,
|
||||
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`
|
||||
/// 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`.
|
||||
/// 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;
|
||||
/// `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
|
||||
/// and use `total_cmp`: a member with `r: None` injects `f64::NEG_INFINITY` (the
|
||||
/// worst rank for higher-is-better), and `total_cmp` orders that — and any stray
|
||||
/// `NaN` — deterministically without panicking. An unknown metric name is a
|
||||
/// `RegistryError::UnknownMetric`.
|
||||
///
|
||||
/// The single source of truth for "best" — both [`rank_by`] (sort by it) and
|
||||
/// [`optimize`] (argmax by it) call this, so the per-metric direction lives in
|
||||
@@ -121,8 +129,16 @@ fn metric_cmp(
|
||||
"max_drawdown" => Metric::MaxDrawdown,
|
||||
// accept the new name AND the pre-rename one (CLI back-compat).
|
||||
"bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips,
|
||||
"sqn" => Metric::Sqn,
|
||||
"expectancy_r" => Metric::ExpectancyR,
|
||||
"net_expectancy_r" => Metric::NetExpectancyR,
|
||||
other => return Err(RegistryError::UnknownMetric(other.to_string())),
|
||||
};
|
||||
// The R metrics live in an optional block; a missing `r` sorts to the bottom
|
||||
// for these higher-is-better keys (NEG_INFINITY < any finite R).
|
||||
fn r_get(rep: &RunReport, f: impl Fn(&aura_engine::RMetrics) -> f64) -> f64 {
|
||||
rep.metrics.r.as_ref().map(f).unwrap_or(f64::NEG_INFINITY)
|
||||
}
|
||||
Ok(move |a: &RunReport, b: &RunReport| match metric {
|
||||
// higher-is-better -> better when greater
|
||||
Metric::TotalPips => b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap(),
|
||||
@@ -131,6 +147,15 @@ fn metric_cmp(
|
||||
Metric::BiasSignFlips => {
|
||||
a.metrics.bias_sign_flips.cmp(&b.metrics.bias_sign_flips)
|
||||
}
|
||||
// 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::ExpectancyR => {
|
||||
r_get(b, |r| r.expectancy_r).total_cmp(&r_get(a, |r| r.expectancy_r))
|
||||
}
|
||||
Metric::NetExpectancyR => {
|
||||
r_get(b, |r| r.net_expectancy_r).total_cmp(&r_get(a, |r| r.net_expectancy_r))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,7 +208,7 @@ impl fmt::Display for RegistryError {
|
||||
}
|
||||
RegistryError::UnknownMetric(m) => write!(
|
||||
f,
|
||||
"unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips)"
|
||||
"unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips, sqn, expectancy_r, net_expectancy_r)"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -201,6 +226,7 @@ impl From<std::io::Error> for RegistryError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{Cell, Scalar, Timestamp};
|
||||
use aura_engine::RMetrics;
|
||||
use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
|
||||
|
||||
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
|
||||
@@ -216,6 +242,65 @@ 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.
|
||||
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 {
|
||||
expectancy_r,
|
||||
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,
|
||||
net_expectancy_r,
|
||||
conviction_terciles_r: [0.0, 0.0, 0.0],
|
||||
});
|
||||
rep
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_by_sqn_orders_members_descending() {
|
||||
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").expect("rank sqn");
|
||||
let sqns: Vec<f64> = ranked.iter().map(|r| r.metrics.r.as_ref().unwrap().sqn).collect();
|
||||
assert_eq!(sqns, vec![2.0, 1.0, 0.5], "sqn 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)];
|
||||
let by_exp = rank_by(reports.clone(), "expectancy_r").expect("rank expectancy_r");
|
||||
assert_eq!(by_exp[0].metrics.r.as_ref().unwrap().expectancy_r, 0.3);
|
||||
let by_net = rank_by(reports, "net_expectancy_r").expect("rank net_expectancy_r");
|
||||
assert_eq!(by_net[0].metrics.r.as_ref().unwrap().net_expectancy_r, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_r_metric_sorts_none_member_last() {
|
||||
// a pip-only member (r: None) ranks below every member with an R block.
|
||||
let reports = vec![report_with(1.0, 0.5, 1), report_with_r(0.5, 0.1, 0.1)];
|
||||
let ranked = rank_by(reports, "sqn").expect("rank sqn with a None member");
|
||||
assert!(ranked[0].metrics.r.is_some(), "the R member ranks first");
|
||||
assert!(ranked[1].metrics.r.is_none(), "the None member sorts last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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("expectancy_r"), "msg: {msg}");
|
||||
assert!(msg.contains("net_expectancy_r"), "msg: {msg}");
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
// unique per test + per process; no external tempfile dependency
|
||||
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
|
||||
|
||||
Reference in New Issue
Block a user