diff --git a/crates/aura-cli/examples/r_breakout.json b/crates/aura-cli/examples/r_breakout.json new file mode 100644 index 0000000..e8556b2 --- /dev/null +++ b/crates/aura-cli/examples/r_breakout.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/examples/r_breakout_open.json b/crates/aura-cli/examples/r_breakout_open.json new file mode 100644 index 0000000..b760645 --- /dev/null +++ b/crates/aura-cli/examples/r_breakout_open.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0fa9c2f..5df4f5c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -37,7 +37,7 @@ use aura_registry::{ DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, }; use aura_std::{ - Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, + Add, Bias, CarryCost, ConstantCost, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost, PM_FIELD_NAMES, PM_RECORD_KINDS, }; @@ -46,6 +46,10 @@ use aura_std::{ // the import is test-only. #[cfg(test)] use aura_std::std_vocabulary; +// `Delay` is now only used by the test-only `r_breakout_signal` carve (#159 cut 2's +// fused-builder retirement dropped the production caller); the import is test-only. +#[cfg(test)] +use aura_std::Delay; use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; use std::collections::HashSet; @@ -1097,7 +1101,6 @@ struct RGrid { slow: Vec, stop_length: Vec, stop_k: Vec, - channel: Vec, window: Vec, band_k: Vec, } @@ -1109,86 +1112,12 @@ impl Default for RGrid { slow: vec![6, 12], stop_length: vec![R_SMA_STOP_LENGTH], stop_k: vec![R_SMA_STOP_K], - channel: vec![1920], window: vec![1920], band_k: vec![2.0], } } } -/// `aura sweep --strategy r-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 -/// point (compile_with_params(&[]) + Harness::bootstrap) rather than -/// the open .axis/bootstrap_with_cells path. Each member folds the dense R-record via -/// summarize_r, so the family is rankable by sqn / expectancy_r / ... (parity with -/// r-sma). With --trace, raw recorders persist the per-cycle streams. -fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { - let pip = data.pip_size(); - let window = data.full_window(env); - let mut varying: HashSet = HashSet::new(); - if grid.channel.len() > 1 { - varying.insert("channel".to_string()); - } - if grid.stop_length.len() > 1 { - varying.insert("stop_length".to_string()); - } - if grid.stop_k.len() > 1 { - varying.insert("stop_k".to_string()); - } - let mut points = Vec::new(); - for &c in &grid.channel { - for &sl in &grid.stop_length { - for &sk in &grid.stop_k { - 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 reduce = trace.is_none(); - let flat = r_breakout_graph(tx_eq, tx_ex, tx_r, tx_req, Some(c), sl, sk, reduce) - .compile_with_params(&[]) - .expect("valid r-breakout blueprint"); - let mut h = Harness::bootstrap(flat).expect("valid r-breakout harness"); - h.run(data.run_sources(env)); - let named: Vec<(String, Scalar)> = vec![ - ("channel".to_string(), Scalar::i64(c)), - ("stop_length".to_string(), Scalar::i64(sl)), - ("stop_k".to_string(), Scalar::f64(sk)), - ]; - let key = member_key(&named, &varying); - let mut manifest = sim_optimal_manifest(named, window, 0, pip); - manifest.broker = r_sma_broker_label(pip); - let metrics = if reduce { - let r_rows: Vec<(Timestamp, Vec)> = 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, &[])); - m - } else { - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); - if let Some(name) = trace { - persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); - } - let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - m.r = Some(summarize_r(&r_rows, &[])); - m - }; - points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } }); - } - } - } - SweepFamily { space: vec![], points } -} - fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); let window = data.full_window(env); @@ -1280,7 +1209,6 @@ fn sweep_report() -> String { enum Strategy { SmaCross, Momentum, - RBreakout, RMeanRev, } @@ -1312,7 +1240,6 @@ impl Strategy { match self { Strategy::SmaCross => "sma", Strategy::Momentum => "momentum", - Strategy::RBreakout => "r-breakout", Strategy::RMeanRev => "r-meanrev", } } @@ -1359,7 +1286,7 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { ) } -/// `aura sweep [--strategy ] [--name |--trace ]`: run the +/// `aura sweep [--strategy ] [--name |--trace ]`: 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`, every strategy @@ -1380,7 +1307,6 @@ fn run_sweep( let family = match strategy { Strategy::SmaCross => sweep_family(persist.then_some(name), &data, env), Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data, env), - Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid, env), Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid, env), }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { @@ -2942,25 +2868,24 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: println!("{}", mc_aggregate_json(&family.aggregate)); } -/// The r-sma harness with its signal leg swapped for a Donchian channel breakout: -/// `close -> Delay(1) -> {RollingMax,RollingMin}(channel) -> {Gt,Gt} -> {Latch,Latch} -/// -> Sub = bias in {-1,0,+1}`. The one Delay(1) on close feeds both rolling nodes, so -/// each channel covers `close[t-N..t-1]` (the C2 guard: the current bar is excluded). -/// `channel = None` leaves the lengths open (unused today; the family binds them); the -/// stop is bound (defines R), identical downstream to `wrap_r`'s scaffolding. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -fn r_breakout_graph( - tx_eq: mpsc::Sender<(Timestamp, Vec)>, - tx_ex: mpsc::Sender<(Timestamp, Vec)>, - tx_r: mpsc::Sender<(Timestamp, Vec)>, - tx_req: mpsc::Sender<(Timestamp, Vec)>, - channel: Option, - stop_length: i64, - stop_k: f64, - reduce: bool, -) -> Composite { - let mut g = GraphBuilder::new("r_breakout"); - // Donchian breakout signal leg. +/// The Donchian channel length for the canonical closed r_breakout example. Single +/// source for the emitter and the three proof tests that must all agree with the +/// baked `examples/r_breakout.json` — kept honest by one constant instead of a +/// hand-synced `Some(3)` literal recurring across those call sites. +#[cfg(test)] +const R_BREAKOUT_CHANNEL: i64 = 3; + +/// The Donchian breakout signal leg, a pure `price→bias` Composite so it serialises +/// as blueprint data (#159 cut 2). The signal computation matches the retired fused +/// builder's leg (same nodes, same wiring); the pip/R harness is the generic +/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling +/// nodes (closed); `None` leaves them open (two sweep axes — per-node exposure; +/// ganging one knob into both is the separate #61). This carve has no production +/// (non-test) caller — it is exercised only by the regeneration + proof tests below; +/// production code loads the shipped JSON, not this builder — hence `#[cfg(test)]`. +#[cfg(test)] +fn r_breakout_signal(channel: Option) -> Composite { + let mut g = GraphBuilder::new("r_breakout_signal"); let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); let mut mx_b = RollingMax::builder().named("channel_hi"); let mut mn_b = RollingMin::builder().named("channel_lo"); @@ -2975,39 +2900,8 @@ fn r_breakout_graph( let up_latch = g.add(Latch::builder()); let down_latch = g.add(Latch::builder()); let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} - // pip branch (verbatim from wrap_r's scaffolding). - let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); - let gate_col = PM_FIELD_NAMES - .iter() - .position(|&n| n == "closed_this_cycle") - .expect("PM record has a closed_this_cycle column"); - let eq = if reduce { - g.add(SeriesReducer::builder(Firing::Any, tx_eq)) - } else { - g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)) - }; - let ex = if reduce { - g.add(SeriesReducer::builder(Firing::Any, tx_ex)) - } else { - g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) - }; - let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0)); - let rrec = if reduce { - g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) - } else { - g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) - }; let price = g.source_role("price", ScalarKind::F64); - g.feed( - price, - [ - delay.input("series"), - gt_up.input("a"), - gt_down.input("b"), - broker.input("price"), - exec.input("price"), - ], - ); + g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]); g.connect(delay.output("value"), mx.input("series")); g.connect(delay.output("value"), mn.input("series")); g.connect(mx.output("value"), gt_up.input("b")); @@ -3018,36 +2912,19 @@ fn r_breakout_graph( g.connect(gt_up.output("value"), down_latch.input("reset")); g.connect(up_latch.output("value"), exposure.input("lhs")); g.connect(down_latch.output("value"), exposure.input("rhs")); - g.connect(exposure.output("value"), broker.input("exposure")); - g.connect(exposure.output("value"), ex.input("col[0]")); - g.connect(exposure.output("value"), exec.input("bias")); - g.connect(broker.output("equity"), eq.input("col[0]")); - for (i, field) in PM_FIELD_NAMES.iter().enumerate() { - g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str())); - } - if !reduce { - let r_equity = g.add( - LinComb::builder(2) - .bind("weights[0]", Scalar::f64(1.0)) - .bind("weights[1]", Scalar::f64(1.0)), - ); - let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); - g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); - g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); - g.connect(r_equity.output("value"), req.input("col[0]")); - } - g.build().expect("r_breakout wiring resolves") + g.expose(exposure.output("value"), "bias"); + g.build().expect("r_breakout signal wiring resolves") } -/// The EWMA Bollinger-band mean-reversion candidate, mirroring -/// `r_breakout_graph` but swapping the signal leg: fade deviation from a +/// The EWMA Bollinger-band mean-reversion candidate, mirroring the breakout +/// candidate's structure, swapping the signal leg: fade deviation from a /// rolling mean (price above `mean + k*sigma` -> short; below `mean - k*sigma` -> /// long), latched +-1. sigma = `Sqrt(Ema((price-mean)^2))` (deviation squared /// then smoothed, the vol_stop shape — no catastrophic cancellation, no NaN). /// No Delay: the current bar legitimately belongs to its own band (causal, C2). /// `window` gangs the mean Ema and the variance Ema (one Bollinger window); /// `band_k` is the band half-width in sigma. Everything below the signal leg is -/// byte-identical to `r_breakout_graph`. +/// the standard pip/R branch. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn r_meanrev_graph( tx_eq: mpsc::Sender<(Timestamp, Vec)>, @@ -3081,7 +2958,7 @@ fn r_meanrev_graph( let short_latch = g.add(Latch::builder()); let long_latch = g.add(Latch::builder()); let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias in {-1,0,+1} - // pip branch (VERBATIM from r_breakout_graph). + // pip branch (the standard SimBroker + RiskExecutor + recorders branch). let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); let gate_col = PM_FIELD_NAMES .iter() @@ -3451,7 +3328,7 @@ struct RunCmd { struct SweepCmd { /// A loaded blueprint (.json); omit for the built-in --strategy grammar. blueprint: Option, - /// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma). + /// Built-in strategy: sma | momentum | r-sma | r-meanrev (default sma). #[arg(long)] strategy: Option, /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. @@ -3481,9 +3358,6 @@ struct SweepCmd { /// Stop-k grid axis (comma-separated values). #[arg(long)] stop_k: Option, - /// Breakout-channel grid axis (comma-separated values; r-breakout). - #[arg(long)] - channel: Option, /// Mean-reversion window grid axis (comma-separated values; r-meanrev). #[arg(long)] window: Option, @@ -3502,7 +3376,7 @@ struct SweepCmd { struct WalkforwardCmd { /// A loaded blueprint (.json); omit for the built-in --strategy grammar. blueprint: Option, - /// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma). + /// Built-in strategy: sma | momentum | r-sma | r-meanrev (default sma). #[arg(long)] strategy: Option, /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. @@ -3805,7 +3679,6 @@ fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result Strategy::SmaCross, Some("momentum") => Strategy::Momentum, - Some("r-breakout") => Strategy::RBreakout, Some("r-meanrev") => Strategy::RMeanRev, Some(_) => return Err(usage()), }) @@ -4063,7 +3936,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { || a.slow.is_some() || a.stop_length.is_some() || a.stop_k.is_some() - || a.channel.is_some() || a.window.is_some() || a.band_k.is_some() { @@ -4194,7 +4066,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { } } None => { - let usage = || "Usage: aura sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]".to_string(); + let usage = || "Usage: aura sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--window ] [--band-k ]".to_string(); if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -4212,7 +4084,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } - if let Some(v) = a.channel.as_deref() { grid.channel = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } if let Some(v) = a.window.as_deref() { grid.window = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } if let Some(v) = a.band_k.as_deref() { grid.band_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) @@ -4272,7 +4143,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); } None => { - let usage = || "Usage: aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); + let usage = || "Usage: aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); if a.blueprint.is_some() || !a.axis.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -4528,6 +4399,56 @@ fn main() { mod tests { use super::*; + /// Regenerates the shipped r_breakout examples from the carved signal. Run by hand: + /// `cargo test --bin aura emit_r_breakout_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_breakout{,_open}.json; run by hand"] + fn emit_r_breakout_examples() { + std::fs::create_dir_all("examples").expect("examples dir"); + std::fs::write( + "examples/r_breakout.json", + blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed r_breakout"), + ) + .expect("write examples/r_breakout.json"); + std::fs::write( + "examples/r_breakout_open.json", + blueprint_to_json(&r_breakout_signal(None)).expect("serialize open r_breakout"), + ) + .expect("write examples/r_breakout_open.json"); + } + + /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 2): + /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the + /// emitter; a drift between builder and file breaks it. Survives the fused builder's + /// retirement (it references `r_breakout_signal`, the carve, not the retired builder). + #[test] + fn shipped_r_breakout_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed"), + include_str!("../examples/r_breakout.json"), + ); + assert_eq!( + blueprint_to_json(&r_breakout_signal(None)).expect("serialize open"), + include_str!("../examples/r_breakout_open.json"), + ); + } + + /// The shipped closed example, reloaded through the data plane and run, grades + /// bit-identically to running the carved signal directly (#159 cut 2). This is + /// r_breakout's durable equivalence anchor after the fused builder retires: it pins + /// that `examples/r_breakout.json` still produces the carve's grade, with no + /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. + #[test] + fn r_breakout_example_loaded_runs_identically_to_the_carved_signal() { + let env = project::Env::std(); + let loaded = blueprint_from_json(include_str!("../examples/r_breakout.json"), &|t| std_vocabulary(t)) + .expect("shipped r_breakout example loads"); + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); + let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env); + assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); + } + /// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through the /// public `blueprint_from_json` path — the single call site so a fixture /// rename or vocabulary change is one edit, not fourteen. @@ -5414,7 +5335,7 @@ mod tests { /// the hand-rebuilt subgraph in `r_meanrev_e2e.rs`) FADES against the /// move — its exposure tap reads short (-1) above the band and long (+1) /// below, the sign-inverted-vs-breakout polarity that defines mean-reversion. - /// A latch-polarity copy-paste from `r_breakout_graph` (swapping the + /// A latch-polarity copy-paste from the breakout candidate (swapping the /// `set`/`reset` legs) would leave the fold-vs-raw and window-grid CLI tests /// green yet silently invert the signal; only an observable read of this /// function's bias catches it. `k = 0` collapses the band to the lagging EWMA diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index c583d0d..d276962 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -2107,101 +2107,6 @@ fn metrics_object(line: &str) -> &str { panic!("unbalanced metrics object: {line}") } -/// The breakout strategy reaches the CLI seam: `aura sweep --strategy r-breakout` -/// emits an R-bearing member, and the folded no-trace path equals the raw --trace path -/// byte-for-byte (parity with the r-sma fold-vs-raw guard). channel 3 warms up on -/// the synthetic stream; one channel value × the single default stop = one member. -#[test] -fn sweep_strategy_r_breakout_folded_no_trace_metrics_equal_raw_trace_metrics() { - let cwd = temp_cwd("sweep-r-breakout-fold-vs-raw"); - - let folded = Command::new(BIN) - .args(["sweep", "--strategy", "r-breakout", "--channel", "3"]) - .current_dir(&cwd) - .output() - .expect("spawn folded (no-trace) r-breakout sweep"); - assert!( - folded.status.success(), - "folded no-trace breakout sweep exit: {:?}; stderr: {}", - folded.status, - String::from_utf8_lossy(&folded.stderr) - ); - let folded_out = String::from_utf8(folded.stdout).expect("utf-8"); - - let raw = Command::new(BIN) - .args(["sweep", "--strategy", "r-breakout", "--channel", "3", "--trace", "b1"]) - .current_dir(&cwd) - .output() - .expect("spawn raw (--trace) r-breakout sweep"); - assert!( - raw.status.success(), - "raw --trace breakout sweep exit: {:?}; stderr: {}", - raw.status, - String::from_utf8_lossy(&raw.stderr) - ); - let raw_out = String::from_utf8(raw.stdout).expect("utf-8"); - - let folded_lines: Vec<&str> = folded_out.lines().collect(); - let raw_lines: Vec<&str> = raw_out.lines().collect(); - assert_eq!(folded_lines.len(), 1, "folded breakout sweep must print 1 member: {folded_out:?}"); - assert_eq!( - raw_lines.len(), - folded_lines.len(), - "member count must match: folded {} vs raw {}", - folded_lines.len(), - raw_lines.len() - ); - - for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() { - let fm = metrics_object(f); - let rm = metrics_object(r); - assert!(fm.contains("\"r\":{"), "folded breakout member {i} must carry an r block: {fm}"); - assert_eq!( - fm, rm, - "folded vs raw breakout metrics diverge at member {i}\n folded: {fm}\n raw: {rm}" - ); - } - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the breakout family is the cartesian product of its grid axes — a -/// multi-value `--channel` list yields exactly one member per channel value, each -/// member carrying its own bound channel length in the manifest params. Guards the -/// manual cartesian-product loop in `r_breakout_sweep_family`: a regression that -/// collapsed the loop (sweeping only the last value, or ganging the two channels) -/// would silently drop members here. Channels 2 and 3 both warm up on the 18-bar -/// synthetic stream, so two members must appear, in grid order, distinctly. -#[test] -fn sweep_strategy_r_breakout_grids_one_member_per_channel() { - let cwd = temp_cwd("sweep-r-breakout-channel-grid"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "r-breakout", "--channel", "2,3"]) - .current_dir(&cwd) - .output() - .expect("spawn 2-channel r-breakout sweep"); - assert!( - out.status.success(), - "channel-grid breakout 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(), 2, "2-channel grid must print 2 members: {stdout:?}"); - // each member carries its own bound channel length (grid order: 2 then 3). - assert!( - lines[0].contains("[\"channel\",{\"I64\":2}]"), - "first member must bind channel=2: {}", - lines[0] - ); - assert!( - lines[1].contains("[\"channel\",{\"I64\":3}]"), - "second member must bind channel=3: {}", - lines[1] - ); - let _ = std::fs::remove_dir_all(&cwd); -} - /// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy /// r-meanrev` emits an R-bearing member, and the folded no-trace path equals /// the raw --trace path byte-for-byte (parity with the r-sma / breakout @@ -2328,31 +2233,87 @@ fn walkforward_bare_sma_summary_has_no_oos_r() { let _ = std::fs::remove_dir_all(&cwd); } -/// Property: a strategy that parses but has no walk-forward form yet (e.g. -/// `r-breakout`) is rejected with exit 2 and a stderr message that names the +/// Property: a strategy that parses but has no walk-forward form (e.g. +/// `momentum`) 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 (`r-breakout`), proving the +/// diagnostic must echo the token the user typed (`momentum`), 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", "r-breakout"]) + .args(["walkforward", "--strategy", "momentum"]) .current_dir(&cwd) .output() - .expect("spawn aura walkforward --strategy r-breakout"); + .expect("spawn aura walkforward --strategy momentum"); 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("r-breakout"), + stderr.contains("momentum"), "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); } +/// Property (#159 cut 2): `--strategy r-breakout` is a fully retired CLI token, on +/// BOTH `sweep` and `walkforward` — indistinguishable from an unrecognized token +/// (`bogus`), i.e. it falls through `strategy_from`'s catch-all into the plain +/// usage error, not into a "valid-but-unsupported" branch (contrast +/// `walkforward_unsupported_strategy_exits_2_naming_the_token`, which pins that +/// shape for `momentum`). A regression that left `Strategy::RBreakout` parseable +/// while only deleting its family builder would make this test's stderr start +/// naming "r-breakout" specially instead of reading the generic `Usage: aura +/// …` line — this is the negative that catches a half-finished retirement. +#[test] +fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() { + for (args, verb) in [ + (&["sweep", "--strategy", "r-breakout"][..], "sweep"), + (&["walkforward", "--strategy", "r-breakout"][..], "walkforward"), + ] { + let cwd = temp_cwd("retired-r-breakout-token"); + let out = Command::new(BIN) + .args(args) + .current_dir(&cwd) + .output() + .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); + assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr); + let want = format!("aura: Usage: aura {verb} "); + assert!( + stderr.starts_with(&want), + "`aura {args:?}` must fall into the generic usage error, not a special-cased \ + message: {stderr:?}" + ); + assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout); + let _ = std::fs::remove_dir_all(&cwd); + } +} + +/// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar, +/// not merely unused — clap rejects it as a structurally unknown argument (exit 2) +/// rather than silently accepting and dropping it. A regression that left the flag +/// on `SweepCmd` while deleting only its plumbing would parse successfully here and +/// this test would fail on the exit code. +#[test] +fn sweep_channel_flag_is_removed_from_the_grammar() { + let cwd = temp_cwd("sweep-channel-flag-retired"); + let out = Command::new(BIN) + .args(["sweep", "--strategy", "r-meanrev", "--channel", "3"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --channel"); + assert_eq!(out.status.code(), Some(2), "--channel must be an unrecognized clap argument: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--channel") || stderr.to_lowercase().contains("unexpected argument"), + "clap must name the unknown flag: {stderr:?}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + /// Property: `aura generalize` grades a single r-sma candidate across two /// instruments — its stdout carries the per-instrument breakdown + the worst-case /// floor + the sign-agreement, then the persisted CrossInstrument family handle. diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index df6789a..f47ef5c 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -731,3 +731,28 @@ fn shipped_r_sma_open_example_lists_its_axis_namespace() { "the raw open params, in order, from the public gallery copy" ); } + +/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is +/// genuinely closed — `graph introspect --params` reports zero unbound params. +#[test] +fn shipped_r_breakout_example_is_genuinely_closed() { + let dir = temp_cwd("r-breakout-example-closed-params"); + let (stdout, stderr, code) = + run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + assert_eq!(stdout, "", "the closed example must leave zero params unbound"); +} + +/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`) +/// lists its two channel axes, in lowering order (per-node exposure; ganging is #61). +#[test] +fn shipped_r_breakout_open_example_lists_its_axis_namespace() { + let dir = temp_cwd("r-breakout-example-open-params"); + let (stdout, stderr, code) = + run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + assert_eq!( + stdout, "channel_hi.length:I64\nchannel_lo.length:I64\n", + "the raw open params, in order, from the public gallery copy" + ); +}