feat(0075): walk-forward strategy-selectable + R-reporting (iter 1)
Make `aura walkforward --strategy stage1-r [--real <SYM>]` roll IS->OOS windows, sweep the stage1-r grid in-sample, pick the winner by an R metric (sqn_normalized), run it out-of-sample, and report per-window + pooled OOS R-metrics. The bare SMA walkforward path is byte-identical (its dispatch arm is verbatim today's body; the pooled `oos_r` block is emitted only when a window carries an `r` block). Engine: - RMetrics gains an in-memory `trade_rs: Vec<f64>` (realised R per closed trade), excluded from serde (`#[serde(skip)]`) and from a hand-written PartialEq, so the C18 wire shape and every existing equality assertion / round-trip stay unchanged. summarize_r now retains the per-trade R vector it used to drop. - New `r_metrics_from_rs(&[f64])` reduces a flat (pooled across-window) R series to RMetrics. Its R-distribution arithmetic is copied verbatim from summarize_r (the byte-pinned floats must not be algebraically refactored); the two copies are guarded in lockstep by a cross-reducer equality test. net_expectancy_r = expectancy_r (exact at the Stage-1 cost=0 invariant); conviction_terciles_r = [0,0,0] (per-trade conviction is not pooled). CLI: - walkforward gains --strategy + the four stage1-r grid flags (reusing parse_csv_list / Stage1RGrid); walkforward_family is strategy-dispatched. - Windowed stage1-r helpers: stage1_r_sweep_over (reduce-mode folded IS sweep, O(trades)/member) and run_oos_r (non-reduce OOS run, for the stitched pip-equity curve), plus stage1_r_space. Frictionless Stage-1 R (costs are Stage-2). Verified: cargo build clean, full `cargo test --workspace` green (SMA walkforward, synthetic mc, stage1_r_single_run_output_golden, and the C18 round-trips all preserved), clippy -D warnings clean. Monte-Carlo R-bootstrap is iter 2. refs #139
This commit is contained in:
+224
-44
@@ -14,14 +14,14 @@
|
||||
mod render;
|
||||
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
||||
|
||||
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
||||
use aura_composites::{risk_executor, risk_executor_vol_open, StopRule};
|
||||
use aura_engine::{
|
||||
f64_field, join_on_ts, monte_carlo, param_stability, summarize, summarize_r, walk_forward,
|
||||
window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow,
|
||||
McAggregate, McFamily, RollMode, RunManifest, RunMetrics, RunReport, SourceSpec, SweepFamily,
|
||||
SweepPoint, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller,
|
||||
WindowRun,
|
||||
f64_field, join_on_ts, monte_carlo, param_stability, r_metrics_from_rs, summarize, summarize_r,
|
||||
walk_forward, window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness,
|
||||
JoinedRow, McAggregate, McFamily, RollMode, RunManifest, RunMetrics, RunReport, SourceSpec,
|
||||
SweepFamily, SweepPoint, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds,
|
||||
WindowRoller, WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||
@@ -1274,6 +1274,104 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RG
|
||||
.expect("the stage1-r named grid matches the stage1-r param-space")
|
||||
}
|
||||
|
||||
/// The param-space of the OPEN stage1-r blueprint (all four knobs free) — the kinds
|
||||
/// the per-window `WindowRun::chosen_params` are read against (C7). Mirrors the
|
||||
/// throwaway-build param_space resolution inside `stage1_r_sweep_family`.
|
||||
fn stage1_r_space() -> Vec<ParamSpec> {
|
||||
let (tx_eq, _) = mpsc::channel();
|
||||
let (tx_ex, _) = mpsc::channel();
|
||||
let (tx_r, _) = mpsc::channel();
|
||||
let (tx_req, _) = mpsc::channel();
|
||||
stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true).param_space()
|
||||
}
|
||||
|
||||
/// Windowed reduce-mode stage1-r sweep over `[from, to]` — the in-sample leg of the
|
||||
/// stage1-r walk-forward. Identical grid/axis/fold logic to `stage1_r_sweep_family`,
|
||||
/// but windowed (`windowed_sources`) and always folded (O(trades)/member): each
|
||||
/// member's RunReport carries `metrics.r = Some(summarize_r(..))`, so the family is
|
||||
/// rankable by an R metric.
|
||||
fn stage1_r_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily {
|
||||
let pip = data.pip_size();
|
||||
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, true, true);
|
||||
let space = bp.param_space();
|
||||
let stop_length_axis = space.iter().map(|p| p.name.clone())
|
||||
.find(|n| n.ends_with(STOP_LENGTH_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_length axis");
|
||||
let stop_k_axis = space.iter().map(|p| p.name.clone())
|
||||
.find(|n| n.ends_with(STOP_K_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_k axis");
|
||||
bp
|
||||
.axis("fast.length", grid.fast.clone())
|
||||
.axis("slow.length", grid.slow.clone())
|
||||
.axis(&stop_length_axis, grid.stop_length.clone())
|
||||
.axis(&stop_k_axis, grid.stop_k.clone())
|
||||
.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, true, true)
|
||||
.bootstrap_with_cells(point)
|
||||
.expect("stage1-r grid points are kind-checked against param_space");
|
||||
let sources = data.windowed_sources(from, to);
|
||||
let window = window_of(&sources).expect("non-empty in-sample window");
|
||||
h.run(sources);
|
||||
let mut named: Vec<(String, Scalar)> = zip_params(&space, point).into_iter()
|
||||
.map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect();
|
||||
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
|
||||
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
|
||||
manifest.broker = stage1_r_broker_label(pip);
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let (total_pips, max_drawdown) = rx_eq.try_iter().next()
|
||||
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64())).unwrap_or((0.0, 0.0));
|
||||
let bias_sign_flips = rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
|
||||
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
|
||||
m.r = Some(summarize_r(&r_rows, 0.0));
|
||||
RunReport { manifest, metrics: m }
|
||||
})
|
||||
.expect("the stage1-r named grid matches the stage1-r param-space")
|
||||
}
|
||||
|
||||
/// Run the chosen stage1-r params over an OOS window; return the recorded pip-equity
|
||||
/// segment (for stitching) and the OOS RunReport whose `metrics.r` carries both the
|
||||
/// R metrics and the per-trade `trade_rs`. Non-reduce (raw recorders): one window's
|
||||
/// curve is bounded, and `stitch` needs the full pip-equity series.
|
||||
fn run_oos_r(
|
||||
params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource,
|
||||
) -> (Vec<(Timestamp, f64)>, RunReport) {
|
||||
let pip = data.pip_size();
|
||||
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 bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, false);
|
||||
let space = bp.param_space();
|
||||
let mut h = bp.bootstrap_with_cells(params)
|
||||
.expect("chosen params pre-validated by the in-sample GridSpace::new");
|
||||
let sources = data.windowed_sources(from, to);
|
||||
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
||||
h.run(sources);
|
||||
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
|
||||
let mut named: Vec<(String, Scalar)> = zip_params(&space, params).into_iter()
|
||||
.map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect();
|
||||
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
|
||||
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}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows);
|
||||
}
|
||||
let equity = f64_field(&eq_rows, 0);
|
||||
let exposure = f64_field(&ex_rows, 0);
|
||||
let mut metrics = summarize(&equity, &exposure);
|
||||
metrics.r = Some(summarize_r(&r_rows, 0.0));
|
||||
(equity, RunReport { manifest, metrics })
|
||||
}
|
||||
|
||||
/// `aura sweep --strategy stage1-breakout`: sweep the breakout harness over a channel ×
|
||||
/// stop grid. One `channel` length drives BOTH rolling nodes (parameter-ganging, #61),
|
||||
/// so the family iterates the cartesian product MANUALLY with a fully-bound graph per
|
||||
@@ -1450,6 +1548,21 @@ enum Strategy {
|
||||
Stage1MeanRev,
|
||||
}
|
||||
|
||||
impl Strategy {
|
||||
/// The CLI `--strategy` token this variant parses from — the inverse of the
|
||||
/// `parse_*_args` match arms. Used to echo the offending strategy in error
|
||||
/// messages so they name the actual input.
|
||||
fn cli_token(self) -> &'static str {
|
||||
match self {
|
||||
Strategy::SmaCross => "sma",
|
||||
Strategy::Momentum => "momentum",
|
||||
Strategy::Stage1R => "stage1-r",
|
||||
Strategy::Stage1Breakout => "stage1-breakout",
|
||||
Strategy::Stage1MeanRev => "stage1-meanrev",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting
|
||||
/// any item that fails to parse — the shared validator for the stage1-r grid flags
|
||||
/// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed
|
||||
@@ -1520,13 +1633,18 @@ fn parse_sweep_args(
|
||||
Ok((strategy, name, persist, real.finish(&usage)?, grid))
|
||||
}
|
||||
|
||||
/// Parse the `walkforward` tail: `[--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
|
||||
/// Defaults: synthetic, name "walkforward", no persist. `--name`/`--trace` are
|
||||
/// Parse the `walkforward` tail:
|
||||
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]`.
|
||||
/// Defaults: SMA-cross, synthetic, name "walkforward", no persist. `--name`/`--trace`
|
||||
/// mutually exclusive; `--from`/`--to` require `--real`. Pure (no I/O / exit).
|
||||
fn parse_walkforward_args(rest: &[&str]) -> Result<(String, bool, DataChoice), String> {
|
||||
let usage = || "walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
|
||||
let mut name: Option<(String, bool)> = None; // (name, persist)
|
||||
fn parse_walkforward_args(
|
||||
rest: &[&str],
|
||||
) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> {
|
||||
let usage = || "walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]".to_string();
|
||||
let mut strategy = Strategy::SmaCross;
|
||||
let mut name: Option<(String, bool)> = None;
|
||||
let mut real = RealWindowGrammar::default();
|
||||
let mut grid = Stage1RGrid::default();
|
||||
let mut tail = rest;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
@@ -1535,14 +1653,28 @@ fn parse_walkforward_args(rest: &[&str]) -> Result<(String, bool, DataChoice), S
|
||||
continue;
|
||||
}
|
||||
match *flag {
|
||||
"--strategy" => {
|
||||
strategy = match *value {
|
||||
"sma" => Strategy::SmaCross,
|
||||
"momentum" => Strategy::Momentum,
|
||||
"stage1-r" => Strategy::Stage1R,
|
||||
"stage1-breakout" => Strategy::Stage1Breakout,
|
||||
"stage1-meanrev" => Strategy::Stage1MeanRev,
|
||||
_ => return Err(usage()),
|
||||
};
|
||||
}
|
||||
"--name" if name.is_none() => name = Some(((*value).to_string(), false)),
|
||||
"--trace" if name.is_none() => name = Some(((*value).to_string(), true)),
|
||||
"--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?,
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
tail = t;
|
||||
}
|
||||
let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false));
|
||||
Ok((name, persist, real.finish(&usage)?))
|
||||
Ok((strategy, name, persist, real.finish(&usage)?, grid))
|
||||
}
|
||||
|
||||
/// Render a family-member stdout line: the assigned `family_id` plus the embedded
|
||||
@@ -1612,7 +1744,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr
|
||||
/// print each carrying the assigned id, then the stitched summary line. With
|
||||
/// `--trace`, also persist each OOS member's streams under
|
||||
/// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
|
||||
fn run_walkforward(name: &str, persist: bool, data: DataSource) {
|
||||
fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &Stage1RGrid) {
|
||||
if persist
|
||||
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
|
||||
{
|
||||
@@ -1620,7 +1752,7 @@ fn run_walkforward(name: &str, persist: bool, data: DataSource) {
|
||||
std::process::exit(2);
|
||||
}
|
||||
let reg = default_registry();
|
||||
let result = walkforward_family(persist.then_some(name), &data);
|
||||
let result = walkforward_family(strategy, persist.then_some(name), &data, grid);
|
||||
let id =
|
||||
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
|
||||
{
|
||||
@@ -1638,9 +1770,14 @@ fn run_walkforward(name: &str, persist: bool, data: DataSource) {
|
||||
|
||||
/// The built-in rolling walk-forward: 24-bar in-sample, 12-bar out-of-sample,
|
||||
/// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3
|
||||
/// windows. Each window sweeps the built-in grid in-sample, optimizes by
|
||||
/// total_pips (axis 2), and runs the chosen params out-of-sample.
|
||||
fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResult {
|
||||
/// windows. Each window sweeps a grid in-sample, optimizes by a metric, and runs
|
||||
/// the chosen params out-of-sample — both strategy-dispatched: the `SmaCross` arm
|
||||
/// sweeps the SMA sample grid and optimizes by `total_pips` (axis 2); the
|
||||
/// `Stage1R` arm sweeps the stage1-r grid and optimizes by `sqn_normalized`.
|
||||
/// Other strategies have no walk-forward form yet (exit 2).
|
||||
fn walkforward_family(
|
||||
strategy: Strategy, trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid,
|
||||
) -> WalkForwardResult {
|
||||
let span = data.wf_full_span();
|
||||
let (is_len, oos_len, step) = data.wf_window_sizes();
|
||||
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
|
||||
@@ -1650,19 +1787,39 @@ fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResu
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space();
|
||||
walk_forward(roller, space, |w: WindowBounds| {
|
||||
let is_family = sweep_over(w.is.0, w.is.1, data);
|
||||
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
|
||||
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data);
|
||||
WindowRun {
|
||||
// The tag-free sweep winner is the chosen point; its kinds live on
|
||||
// WalkForwardResult.space (computed once above from the same blueprint).
|
||||
chosen_params: best.params,
|
||||
oos_equity,
|
||||
oos_report,
|
||||
match strategy {
|
||||
Strategy::SmaCross => {
|
||||
let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space();
|
||||
walk_forward(roller, space, |w: WindowBounds| {
|
||||
let is_family = sweep_over(w.is.0, w.is.1, data);
|
||||
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
|
||||
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data);
|
||||
WindowRun {
|
||||
// The tag-free sweep winner is the chosen point; its kinds live on
|
||||
// WalkForwardResult.space (computed once above from the same blueprint).
|
||||
chosen_params: best.params,
|
||||
oos_equity,
|
||||
oos_report,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
Strategy::Stage1R => {
|
||||
let space = stage1_r_space();
|
||||
walk_forward(roller, space, |w: WindowBounds| {
|
||||
let is_family = stage1_r_sweep_over(w.is.0, w.is.1, data, grid);
|
||||
let best = optimize(&is_family, "sqn_normalized").expect("sqn_normalized is a known metric");
|
||||
let (oos_equity, oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data);
|
||||
WindowRun { chosen_params: best.params, oos_equity, oos_report }
|
||||
})
|
||||
}
|
||||
other => {
|
||||
eprintln!(
|
||||
"aura: walkforward has no form for strategy '{}'",
|
||||
other.cli_token()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep the built-in named grid over an in-sample window, sourcing the in-memory
|
||||
@@ -1733,14 +1890,22 @@ fn run_oos(
|
||||
/// (C14).
|
||||
fn walkforward_summary_json(result: &WalkForwardResult) -> String {
|
||||
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0);
|
||||
serde_json::json!({
|
||||
"walkforward": {
|
||||
"windows": result.windows.len(),
|
||||
"stitched_total_pips": total,
|
||||
"param_stability": param_stability(result),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
// pool the per-window OOS per-trade R series in roll order, then reduce.
|
||||
let pooled_rs: Vec<f64> = result.windows.iter()
|
||||
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.trade_rs.clone()).unwrap_or_default())
|
||||
.collect();
|
||||
let mut obj = serde_json::json!({
|
||||
"windows": result.windows.len(),
|
||||
"stitched_total_pips": total,
|
||||
"param_stability": param_stability(result),
|
||||
});
|
||||
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) {
|
||||
// RMetrics serializes its scalar fields (trade_rs is serde-skipped, so the
|
||||
// oos_r block is the clean R-metric summary of the pooled series).
|
||||
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
|
||||
.expect("RMetrics serializes");
|
||||
}
|
||||
serde_json::json!({ "walkforward": obj }).to_string()
|
||||
}
|
||||
|
||||
/// A longer deterministic stream than `showcase_prices` — enough for several
|
||||
@@ -1772,7 +1937,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
|
||||
/// helper (mirrors `sweep_report`).
|
||||
#[cfg(test)]
|
||||
fn walkforward_report() -> String {
|
||||
let result = walkforward_family(None, &DataSource::Synthetic);
|
||||
let result = walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default());
|
||||
let mut out = String::new();
|
||||
for w in &result.windows {
|
||||
out.push_str(&w.run.oos_report.to_json());
|
||||
@@ -2589,7 +2754,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|stage1-r|stage1-breakout|stage1-meanrev>] [--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|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -2635,8 +2800,8 @@ fn main() {
|
||||
}
|
||||
},
|
||||
["walkforward", rest @ ..] => match parse_walkforward_args(rest) {
|
||||
Ok((name, persist, choice)) => {
|
||||
run_walkforward(&name, persist, DataSource::from_choice(choice))
|
||||
Ok((strategy, name, persist, choice, grid)) => {
|
||||
run_walkforward(strategy, &name, persist, DataSource::from_choice(choice), &grid)
|
||||
}
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
@@ -3148,7 +3313,7 @@ mod tests {
|
||||
.append_family(
|
||||
"walkforward",
|
||||
FamilyKind::WalkForward,
|
||||
&walkforward_member_reports(&walkforward_family(None, &DataSource::Synthetic)),
|
||||
&walkforward_member_reports(&walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default())),
|
||||
)
|
||||
.expect("walkforward family");
|
||||
assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0"));
|
||||
@@ -3650,15 +3815,30 @@ mod tests {
|
||||
/// and rejects two name flags or a `--real` missing its symbol.
|
||||
#[test]
|
||||
fn parse_walkforward_args_defaults_and_accepts_real() {
|
||||
assert_eq!(parse_walkforward_args(&[]), Ok(("walkforward".to_string(), false, DataChoice::Synthetic)));
|
||||
assert_eq!(
|
||||
parse_walkforward_args(&[]),
|
||||
Ok((Strategy::SmaCross, "walkforward".to_string(), false, DataChoice::Synthetic, Stage1RGrid::default()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_walkforward_args(&["--real", "EURUSD", "--trace", "w"]),
|
||||
Ok(("w".to_string(), true, DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None }))
|
||||
Ok((Strategy::SmaCross, "w".to_string(), true,
|
||||
DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None },
|
||||
Stage1RGrid::default()))
|
||||
);
|
||||
assert!(parse_walkforward_args(&["--name", "a", "--trace", "b"]).is_err());
|
||||
assert!(parse_walkforward_args(&["--real"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_walkforward_args_accepts_strategy_and_grid_flags() {
|
||||
let r = parse_walkforward_args(&["--strategy", "stage1-r", "--fast", "5,10", "--stop-k", "2.0,3.0"]);
|
||||
let (strategy, _, _, _, grid) = r.expect("valid stage1-r walkforward args");
|
||||
assert_eq!(strategy, Strategy::Stage1R);
|
||||
assert_eq!(grid.fast, vec![5, 10]);
|
||||
assert_eq!(grid.stop_k, vec![2.0, 3.0]);
|
||||
assert!(parse_walkforward_args(&["--strategy", "bogus"]).is_err());
|
||||
}
|
||||
|
||||
/// `parse_walkforward_args` mirrors the shared `RealWindowGrammar` real/window strictness:
|
||||
/// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
|
||||
/// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives.
|
||||
|
||||
@@ -2310,3 +2310,100 @@ fn sweep_strategy_stage1_meanrev_grids_one_member_per_window() {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `aura walkforward --strategy stage1-r` on synthetic data reports R
|
||||
/// quality per window AND pooled across windows — every per-window member line
|
||||
/// carries a `metrics.r` block (the windowed reduce-mode IS sweep folds the dense
|
||||
/// R-record), and the summary line carries a pooled `oos_r` block (the across-window
|
||||
/// reduction of the OOS per-trade R series). Deterministic: a second run is
|
||||
/// byte-identical (C1).
|
||||
#[test]
|
||||
fn walkforward_strategy_stage1_r_reports_oos_r() {
|
||||
let run = || {
|
||||
let cwd = temp_cwd("wf-stage1r");
|
||||
let out = Command::new(BIN)
|
||||
.args(["walkforward", "--strategy", "stage1-r"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward --strategy stage1-r");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"walkforward --strategy stage1-r exit: {:?}; stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
stdout
|
||||
};
|
||||
let out = run();
|
||||
let lines: Vec<&str> = out.trim().lines().collect();
|
||||
let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
|
||||
assert!(summary["walkforward"]["oos_r"].is_object(), "summary carries oos_r");
|
||||
assert!(summary["walkforward"]["oos_r"]["n_trades"].is_number());
|
||||
// each per-window member line carries metrics.r
|
||||
for l in &lines[..lines.len() - 1] {
|
||||
let v: serde_json::Value = serde_json::from_str(l).unwrap();
|
||||
assert!(v["report"]["metrics"]["r"].is_object(), "per-window r block present");
|
||||
}
|
||||
let out2 = run();
|
||||
assert_eq!(out, out2, "stage1-r walkforward is deterministic");
|
||||
}
|
||||
|
||||
/// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved
|
||||
/// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly
|
||||
/// `windows`, `stitched_total_pips`, `param_stability` and NOT the stage1-r-only
|
||||
/// `oos_r` block. The spec promised the bare path's golden is unchanged; the
|
||||
/// signature widening defaults to SmaCross and the summary gates `oos_r` on
|
||||
/// `metrics.r.is_some()`, so a regression that leaked R-reporting (or any other
|
||||
/// key) into the SMA summary would be a silent contract break. This pins the
|
||||
/// negative: the SMA arm produces no `metrics.r`, hence no `oos_r`.
|
||||
#[test]
|
||||
fn walkforward_bare_sma_summary_has_no_oos_r() {
|
||||
let cwd = temp_cwd("wf-bare-sma");
|
||||
let out = Command::new(BIN)
|
||||
.arg("walkforward")
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward");
|
||||
assert!(out.status.success(), "bare walkforward exit: {:?}", out.status);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
let lines: Vec<&str> = stdout.trim().lines().collect();
|
||||
let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
|
||||
let wf = &summary["walkforward"];
|
||||
assert!(wf["oos_r"].is_null(), "bare SMA summary must NOT carry oos_r: {wf}");
|
||||
assert!(wf["windows"].is_number(), "bare SMA summary keeps windows: {wf}");
|
||||
assert!(wf["stitched_total_pips"].is_number(), "bare SMA summary keeps stitched_total_pips: {wf}");
|
||||
assert!(wf["param_stability"].is_array(), "bare SMA summary keeps param_stability: {wf}");
|
||||
// no per-window member line carries a metrics.r block (R-reporting is stage1-r-only)
|
||||
for l in &lines[..lines.len() - 1] {
|
||||
let v: serde_json::Value = serde_json::from_str(l).unwrap();
|
||||
assert!(v["report"]["metrics"]["r"].is_null(), "bare SMA per-window line must NOT carry metrics.r: {l}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: a strategy that parses but has no walk-forward form yet (e.g.
|
||||
/// `stage1-breakout`) is rejected with exit 2 and a stderr message that names the
|
||||
/// OFFENDING strategy by its CLI token — not silently run as SMA, not crashed.
|
||||
/// The dispatch's catch-all arm routes through `Strategy::cli_token()`, so the
|
||||
/// diagnostic must echo the token the user typed (`stage1-breakout`), proving the
|
||||
/// inverse map is wired. Distinct from a bad `--strategy bogus` (a usage parse
|
||||
/// error): here the token is a valid strategy with no walk-forward arm.
|
||||
#[test]
|
||||
fn walkforward_unsupported_strategy_exits_2_naming_the_token() {
|
||||
let cwd = temp_cwd("wf-unsupported");
|
||||
let out = Command::new(BIN)
|
||||
.args(["walkforward", "--strategy", "stage1-breakout"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward --strategy stage1-breakout");
|
||||
assert_eq!(out.status.code(), Some(2), "unsupported walkforward strategy must exit 2");
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||||
assert!(
|
||||
stderr.contains("stage1-breakout"),
|
||||
"stderr must name the offending strategy token: {stderr:?}"
|
||||
);
|
||||
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
@@ -62,8 +62,9 @@ pub use harness::{
|
||||
VecSource,
|
||||
};
|
||||
pub use report::{
|
||||
derive_position_events, f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace,
|
||||
JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
|
||||
derive_position_events, f64_field, join_on_ts, r_metrics_from_rs, summarize, summarize_r,
|
||||
ColumnarTrace, JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics,
|
||||
RunReport,
|
||||
};
|
||||
pub use sweep::{
|
||||
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
|
||||
|
||||
@@ -41,7 +41,7 @@ pub struct RunMetrics {
|
||||
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
|
||||
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries
|
||||
/// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RMetrics {
|
||||
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
|
||||
pub n_trades: u64,
|
||||
@@ -59,6 +59,30 @@ pub struct RMetrics {
|
||||
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]
|
||||
/// Realised R per closed trade, in trade order — an in-memory conduit for the
|
||||
/// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire
|
||||
/// shape is unchanged, and from `PartialEq` (below) so every existing
|
||||
/// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
|
||||
#[serde(skip)]
|
||||
pub trade_rs: Vec<f64>,
|
||||
}
|
||||
|
||||
impl PartialEq for RMetrics {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.expectancy_r == o.expectancy_r
|
||||
&& self.n_trades == o.n_trades
|
||||
&& self.win_rate == o.win_rate
|
||||
&& self.avg_win_r == o.avg_win_r
|
||||
&& self.avg_loss_r == o.avg_loss_r
|
||||
&& self.profit_factor == o.profit_factor
|
||||
&& self.max_r_drawdown == o.max_r_drawdown
|
||||
&& self.n_open_at_end == o.n_open_at_end
|
||||
&& self.sqn == o.sqn
|
||||
&& self.sqn_normalized == o.sqn_normalized
|
||||
&& self.net_expectancy_r == o.net_expectancy_r
|
||||
&& self.conviction_terciles_r == o.conviction_terciles_r
|
||||
// trade_rs deliberately excluded
|
||||
}
|
||||
}
|
||||
|
||||
// Dense `PositionManagement` record column indices — the lockstep contract with
|
||||
@@ -132,6 +156,7 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
sqn_normalized: 0.0,
|
||||
net_expectancy_r: 0.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
trade_rs: Vec::new(),
|
||||
};
|
||||
}
|
||||
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
|
||||
@@ -216,6 +241,73 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
sqn_normalized,
|
||||
net_expectancy_r,
|
||||
conviction_terciles_r,
|
||||
trade_rs: rs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a
|
||||
/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit
|
||||
/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte —
|
||||
/// deliberately copied, not factored, so neither pinned-float expression shifts under
|
||||
/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in
|
||||
/// lockstep; the only guard that they agree is the cross-reducer equality test
|
||||
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
|
||||
/// without the other and that test is the sole tripwire. The
|
||||
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
|
||||
/// `net_expectancy_r = expectancy_r` (exact under the Stage-1 cost = 0 invariant),
|
||||
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
|
||||
/// input -> a well-defined all-zero `RMetrics`.
|
||||
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
||||
let n = rs.len() as u64;
|
||||
if n == 0 {
|
||||
return RMetrics {
|
||||
expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
|
||||
avg_loss_r: 0.0, profit_factor: 0.0, 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], trade_rs: Vec::new(),
|
||||
};
|
||||
}
|
||||
let sum: f64 = rs.iter().sum();
|
||||
let mean = sum / n as f64;
|
||||
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
|
||||
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
|
||||
let sum_win: f64 = wins.iter().sum();
|
||||
let sum_loss: f64 = losses.iter().sum();
|
||||
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
|
||||
let mut peak = f64::NEG_INFINITY;
|
||||
let mut cum = 0.0;
|
||||
let mut max_dd = 0.0_f64;
|
||||
for &r in rs {
|
||||
cum += r;
|
||||
if cum > peak { peak = cum; }
|
||||
let dd = peak - cum;
|
||||
if dd > max_dd { max_dd = dd; }
|
||||
}
|
||||
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, (n.min(SQN_CAP) as f64).sqrt() * mean / sd)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
};
|
||||
RMetrics {
|
||||
expectancy_r: mean,
|
||||
n_trades: n,
|
||||
win_rate: wins.len() as f64 / n as f64,
|
||||
avg_win_r: avg(&wins),
|
||||
avg_loss_r: avg(&losses),
|
||||
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
|
||||
max_r_drawdown: max_dd,
|
||||
n_open_at_end: 0,
|
||||
sqn,
|
||||
sqn_normalized,
|
||||
net_expectancy_r: mean, // cost = 0 -> net == gross (Stage-1 frictionless)
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
trade_rs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,6 +1237,7 @@ mod tests {
|
||||
sqn_normalized: 1.0,
|
||||
net_expectancy_r: 0.4,
|
||||
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
||||
trade_rs: Vec::new(),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
@@ -1326,4 +1419,127 @@ mod tests {
|
||||
let rows = vec![(Timestamp(1), vec![Scalar::f64(1.0)])];
|
||||
let _ = ColumnarTrace::from_rows("narrow", &[ScalarKind::F64, ScalarKind::F64], &rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_populates_trade_rs_in_trade_order() {
|
||||
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
|
||||
// record; trade_rs must carry [2.0, -1.0] in trade order.
|
||||
let rec = pm_record_two_closed_trades(); // helper below
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert_eq!(m.trade_rs, vec![2.0, -1.0]);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_empty_record_has_empty_trade_rs() {
|
||||
let m = summarize_r(&[], 0.0);
|
||||
assert!(m.trade_rs.is_empty());
|
||||
assert_eq!(m.n_trades, 0);
|
||||
}
|
||||
|
||||
/// Property: a position still open on the last row is folded into the trade
|
||||
/// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
|
||||
/// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
|
||||
/// This is the one case where the two reducers' inputs differ in meaning — it
|
||||
/// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
|
||||
/// so the two must agree on the R-distribution arithmetic for an
|
||||
/// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5].
|
||||
#[test]
|
||||
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
|
||||
let rec = pm_record_closed_then_open_at_end();
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert_eq!(m.trade_rs, vec![2.0, 0.5]);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
assert_eq!(m.n_open_at_end, 1);
|
||||
// Feed the open-at-end pooled series through the flat reducer: the
|
||||
// R-distribution fields (the verbatim-copied arithmetic) must agree.
|
||||
let pooled = r_metrics_from_rs(&m.trade_rs);
|
||||
assert_eq!(pooled.n_trades, m.n_trades);
|
||||
assert_eq!(pooled.expectancy_r, m.expectancy_r);
|
||||
assert_eq!(pooled.win_rate, m.win_rate);
|
||||
assert_eq!(pooled.avg_win_r, m.avg_win_r);
|
||||
assert_eq!(pooled.avg_loss_r, m.avg_loss_r);
|
||||
assert_eq!(pooled.profit_factor, m.profit_factor);
|
||||
assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown);
|
||||
assert_eq!(pooled.sqn, m.sqn);
|
||||
assert_eq!(pooled.sqn_normalized, m.sqn_normalized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rmetrics_partial_eq_ignores_trade_rs() {
|
||||
// two RMetrics equal in every metric but differing in trade_rs compare EQUAL
|
||||
// (trade_rs is an in-memory conduit, excluded from equality) — this is what
|
||||
// keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped,
|
||||
// so it deserializes empty).
|
||||
let a = summarize_r(&pm_record_two_closed_trades(), 0.0);
|
||||
let mut b = a.clone();
|
||||
b.trade_rs = Vec::new();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
|
||||
/// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6,
|
||||
/// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13.
|
||||
fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let row = |closed: bool, r: f64, open: bool| {
|
||||
let mut c = vec![Scalar::f64(0.0); 13];
|
||||
c[0] = Scalar::bool(closed);
|
||||
c[1] = Scalar::f64(r);
|
||||
c[6] = Scalar::f64(1.0); // entry
|
||||
c[7] = Scalar::f64(0.5); // stop -> latched 0.5
|
||||
c[9] = Scalar::f64(0.3); // conviction
|
||||
c[11] = Scalar::bool(open);
|
||||
c
|
||||
};
|
||||
vec![
|
||||
(Timestamp(1), row(true, 2.0, false)),
|
||||
(Timestamp(2), row(true, -1.0, false)),
|
||||
]
|
||||
}
|
||||
|
||||
/// A dense PositionManagement record whose last row is still open: one closed
|
||||
/// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R
|
||||
/// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade).
|
||||
/// Same column map / width as `pm_record_two_closed_trades`.
|
||||
fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| {
|
||||
let mut c = vec![Scalar::f64(0.0); 13];
|
||||
c[0] = Scalar::bool(closed);
|
||||
c[1] = Scalar::f64(realized_r);
|
||||
c[6] = Scalar::f64(1.0); // entry
|
||||
c[7] = Scalar::f64(0.5); // stop -> latched 0.5
|
||||
c[9] = Scalar::f64(0.3); // conviction
|
||||
c[11] = Scalar::bool(open);
|
||||
c[12] = Scalar::f64(unrealized_r);
|
||||
c
|
||||
};
|
||||
vec![
|
||||
(Timestamp(1), row(true, 2.0, false, 0.0)),
|
||||
(Timestamp(2), row(false, 0.0, true, 0.5)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r_metrics_from_rs_folds_a_flat_series() {
|
||||
// pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3,
|
||||
// profit_factor = (2+1)/1 = 3. At cost 0 (frictionless Stage-1) net == gross.
|
||||
// conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled
|
||||
// concept -> 0.
|
||||
let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]);
|
||||
assert_eq!(m.n_trades, 3);
|
||||
assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12);
|
||||
assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12);
|
||||
assert!((m.profit_factor - 3.0).abs() < 1e-12);
|
||||
assert_eq!(m.net_expectancy_r, m.expectancy_r);
|
||||
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
|
||||
assert_eq!(m.n_open_at_end, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r_metrics_from_rs_empty_is_all_zero() {
|
||||
let m = r_metrics_from_rs(&[]);
|
||||
assert_eq!(m.n_trades, 0);
|
||||
assert_eq!(m.expectancy_r, 0.0);
|
||||
assert_eq!(m.sqn, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ mod tests {
|
||||
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],
|
||||
trade_rs: Vec::new(),
|
||||
});
|
||||
rep
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user