feat(0075): Monte-Carlo R-bootstrap for OOS validation (iter 2)

Add `aura mc --strategy stage1-r [--real <SYM>]`: run the stage1-r
walk-forward, pool its OOS per-trade R series, and report a moving-block
bootstrap E[R] distribution / CI (one `mc_r_bootstrap` line with an `e_r`
quantile block + P(E[R]<=0)). Completes spec 0075's Monte-Carlo half on top
of iter 1's walk-forward R-reporting.

Engine: `r_bootstrap(rs, n_resamples, block_len, seed) -> RBootstrap` — a
deterministic moving-block bootstrap (non-circular, final block truncated;
block_len=1 = i.i.d. trade-shuffle; clamped to [1,n]; empty -> all-zero),
reusing the existing MetricStats/quantile and SplitMix64.

CLI: parse_mc_args/McArgs split — bare/`--name`/`--trace` route to the
unchanged synthetic seed-resweep `run_mc` (byte-for-byte preserved); the R
path accepts ONLY `--strategy stage1-r`, so a bare `--real` stays a usage
error (mc_rejects_real_flag_with_usage_exit_2 preserved, doc-comment
refreshed). run_mc_r_bootstrap reuses walkforward_family + RMetrics.trade_rs.

Corrective fixes over the plan snippets, both verified: the mc usage string
carries a `usage:` token (the new `["mc", rest @ ..]` arm now handles the
`--real` rejection, and the preserved golden asserts `stderr.contains
("usage")`); `#[allow(clippy::large_enum_variant)]` on McArgs keeps the
unboxed Stage1RGrid field layout.

Scope: single `--real <SYM>` per invocation; cross-symbol pooling is a
deferred follow-on (needs a multi-symbol grammar). Verified: cargo build
clean, full `cargo test --workspace` green (609 passed), clippy -D warnings
clean; bare `aura mc`, SMA walkforward, stage1-r single-run, and C18
round-trip goldens all preserved.

refs #139
This commit is contained in:
2026-06-26 12:12:06 +02:00
parent b3505c7609
commit 2e77ab1dda
4 changed files with 471 additions and 21 deletions
+108 -7
View File
@@ -1294,13 +1294,12 @@ fn sweep_real_is_byte_deterministic_across_runs() {
assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)");
}
/// Property (spec-0060 MC exclusion): `aura mc` admits NO `--real` flag. The
/// spec carves MC out of the real-data path — its seed varies a *synthetic*
/// price-walk realization, undefined over real bars (one realization -> identical
/// members). The observable contract is that `aura mc --real EURUSD` is REJECTED
/// (usage on stderr, exit 2) at the binary boundary, never silently accepted as a
/// real run. This pins the exclusion: if an `["mc", rest @ ..]` arm ever wired
/// `--real` into MC, this test fails loudly. NOT gated — the refusal precedes any
/// The synthetic seed-resweep `mc` is undefined over real bars (one realization ->
/// identical members), so a bare `aura mc --real EURUSD`with no R candidate — is
/// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is
/// reachable ONLY via `--strategy stage1-r` (the R-bootstrap over the pooled OOS R
/// series, `mc_strategy_stage1_r_prints_a_bootstrap_line_deterministically`); a
/// `--real` without it stays a usage error. NOT gated — the refusal precedes any
/// data access, so it is CI-safe on every machine.
#[test]
fn mc_rejects_real_flag_with_usage_exit_2() {
@@ -2407,3 +2406,105 @@ fn walkforward_unsupported_strategy_exits_2_naming_the_token() {
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: `aura mc --strategy stage1-r` runs the stage1-r walk-forward on
/// synthetic data, bootstraps the pooled OOS per-trade R series, and prints exactly
/// one canonical `mc_r_bootstrap` line carrying an `e_r` distribution block and
/// `prob_le_zero`. Deterministic: a second run with the same (default) seed is
/// byte-identical (C1). Frictionless Stage-1 — no costs, no `runs/` family write.
#[test]
fn mc_strategy_stage1_r_prints_a_bootstrap_line_deterministically() {
let dir = temp_cwd("mc_stage1r_boot");
let run = || {
Command::new(BIN)
.args(["mc", "--strategy", "stage1-r", "--resamples", "200"])
.current_dir(&dir)
.output()
.expect("spawn aura mc --strategy stage1-r")
};
let out = run();
assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert_eq!(stdout.lines().count(), 1, "exactly one mc_r_bootstrap line: {stdout:?}");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON");
assert!(v["mc_r_bootstrap"]["e_r"]["mean"].is_number(), "e_r block present: {stdout}");
assert!(v["mc_r_bootstrap"]["prob_le_zero"].is_number(), "prob_le_zero present: {stdout}");
assert_eq!(v["mc_r_bootstrap"]["n_resamples"], 200);
let out2 = run();
assert_eq!(stdout.as_bytes(), out2.stdout.as_slice(), "same seed -> byte-identical line (C1)");
let _ = std::fs::remove_dir_all(&dir);
}
/// Property: `--block-len` is the moving-block knob, and it is observable AND
/// load-bearing at the binary boundary — it round-trips into the emitted
/// `block_len` field, and a different `block-len` (over the same seed + pooled R
/// series) yields a genuinely different E[R] distribution. This is what separates
/// the moving-block bootstrap from a plain i.i.d. trade shuffle: contiguous runs
/// preserve serial correlation a `block-len 1` shuffle erases. Without this test a
/// regression that dropped `--block-len` on the floor (or wired it to `block_len 1`
/// always) would still pass the happy-path E2E. Same-seed determinism (C1) pins
/// that the difference is the block length, not RNG noise. Not gated — synthetic.
#[test]
fn mc_stage1_r_block_len_is_observable_and_changes_the_distribution() {
let dir = temp_cwd("mc_stage1r_block");
let run = |block_len: &str| {
let out = Command::new(BIN)
.args(["mc", "--strategy", "stage1-r", "--resamples", "256", "--seed", "1", "--block-len", block_len])
.current_dir(&dir)
.output()
.expect("spawn aura mc --strategy stage1-r --block-len");
assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).expect("utf-8 stdout")
};
let one = run("1");
let three = run("3");
let v1: serde_json::Value = serde_json::from_str(one.trim()).expect("canonical JSON (block-len 1)");
let v3: serde_json::Value = serde_json::from_str(three.trim()).expect("canonical JSON (block-len 3)");
// the flag round-trips verbatim into the emitted line.
assert_eq!(v1["mc_r_bootstrap"]["block_len"], 1, "block_len 1 must echo: {one}");
assert_eq!(v3["mc_r_bootstrap"]["block_len"], 3, "block_len 3 must echo: {three}");
// same seed, same pooled series, different block length -> different E[R] spread.
// (a no-op `--block-len` would make these byte-identical.)
assert_ne!(one, three, "block-len must change the resampled distribution, not be ignored");
// C1 at the CLI edge: the block-len-1 line is byte-identical on re-run.
assert_eq!(one, run("1"), "same seed+block-len -> byte-identical line (C1)");
let _ = std::fs::remove_dir_all(&dir);
}
/// Property: a `--block-len` at or above the pooled-series length is CLAMPED to the
/// series length at the binary boundary (no panic, no out-of-bounds), and the
/// degenerate single-block case collapses the bootstrap — when every resample IS
/// the full series, the E[R] distribution becomes a single point (all quantiles
/// equal). The emitted `block_len` reports the clamped value, not the raw flag.
/// This pins the clamp the engine primitive guards in-crate, now at the observable
/// CLI edge where an off-by-one in the index arithmetic would otherwise surface as
/// a process crash. Not gated — synthetic stage1-r pools a fixed, non-empty series.
#[test]
fn mc_stage1_r_oversized_block_len_clamps_and_collapses() {
let dir = temp_cwd("mc_stage1r_clamp");
let out = Command::new(BIN)
.args(["mc", "--strategy", "stage1-r", "--resamples", "200", "--seed", "1", "--block-len", "9999"])
.current_dir(&dir)
.output()
.expect("spawn aura mc --strategy stage1-r --block-len 9999");
assert!(out.status.success(), "must not crash on oversized block-len: {:?} stderr: {}",
out.status, String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON");
let obj = &v["mc_r_bootstrap"];
let n_trades = obj["n_trades"].as_u64().expect("n_trades is an integer");
assert!(n_trades >= 1, "synthetic stage1-r must pool a non-empty R series: {stdout}");
// block_len is reported clamped to the pooled-series length, never the raw 9999.
assert_eq!(obj["block_len"], serde_json::json!(n_trades),
"oversized block-len must clamp to n_trades: {stdout}");
// single-block degenerate: every resample is the full series, so the E[R]
// distribution is a single point — p5 == p95 (zero spread).
let p5 = obj["e_r"]["p5"].as_f64().expect("p5 is a number");
let p95 = obj["e_r"]["p95"].as_f64().expect("p95 is a number");
assert!((p5 - p95).abs() < 1e-12, "single-block bootstrap must have zero spread: p5={p5} p95={p95}");
let _ = std::fs::remove_dir_all(&dir);
}