From 257ab0b9f26010d63244446ec326cd19544ab7cb Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 7 Jul 2026 22:02:50 +0200 Subject: [PATCH] feat(std,cli): add the Scale node, retire the r-meanrev demo to data (#159 cut 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut 3 of the hard-wired demo retirement. Unlike r-sma/r-breakout, r-meanrev could not round-trip as data: its band computes band_k*sigma (a constant times a stream) and the closed 22-node std_vocabulary had no way to express it (EqConst is i64->bool, Bias is clamp(signal/scale,±1), Mul is binary, LinComb is excluded as a construction-arg node; no Div/Const/constant-emitter). Standard operators are missing by chance, not by design, so this adds the one r-meanrev needs. Scale node (aura-std): out = input * factor — one f64 input, one f64 `factor` param, stateless, warm-up-filtered (the EqConst/Bias shape). A zero-input `Const` emitter was rejected: the per-cycle eval loop gates every node through an input-firing test (harness.rs `fires()`), so a node with no input never fires without an engine-core change; `Scale` fits the existing model and, by IEEE-754 commutativity, reproduces the retiring LinComb's band byte-for-byte. Rostered (`"Scale" => Scale`); the two vocabulary count-pins bump 22 -> 23. r-meanrev migration: r_meanrev_signal(window, band_k) is carved out of the fused r_meanrev_graph as a #[cfg(test)] price->bias Composite whose band is `Scale` in place of `LinComb(1)`; production loads the shipped JSON (examples/r_meanrev{,_open}.json), never a builder. Before the fused builder was deleted, an equivalence test proved the carved Scale-band signal reproduces its grade byte-for-byte on the synthetic stream (window=3, band_k=2). Durable survivors: the byte-identity of the examples to the carve, the loaded-grades-identically-to-carve proof, the closed/open introspect anchors (mean_window.length, var_window.length, band.factor — band_k is now a first-class sweepable param), and — re-pointed onto the carve rather than dropped — the fades-short-above/long-below behavioural test, which becomes the durable carve-correctness gate the deleted equivalence test used to be. Deletions + the dead-code cascade the retirement opened: r_meanrev_graph, r_meanrev_sweep_family, Strategy::RMeanRev (+ all arms). r-meanrev was the last reader of run_sweep's grid, so the whole vestigial built-in-sweep grid apparatus retires with it: run_sweep's `grid` param, the sweep call-site grid build, and SweepCmd's --fast/--slow/--stop-length/--stop-k/--window/--band-k (sma/momentum sweeps build their own blueprints and ignored these; the fast/slow/stop-* had been vestigial since r-sma's cut 1b). RGrid the struct SURVIVES — the dissolved `generalize` verb resolves its candidate from its fast/slow/stop_length/stop_k via generalize_args_from — so only its window/band_k fields go; its stale doc-comment (r-sma-sweep / persist_traces_r / CLI flags, all now gone) is rewritten to its true generalize-only role. Also retired as transitively dead: persist_traces_r and the metrics_object test helper (their only callers were the deleted families/tests); Add/Gt/Latch/Mul/Sqrt imports are now #[cfg(test)] (their last production caller was r_meanrev_graph). Test surface: the two built-in --strategy r-meanrev sweep tests drop; a new negative pins that --strategy r-meanrev on both sweep and walkforward now falls into the generic usage error; the grid-flag stray-positional negative is re-pointed to a surviving flag. Verification (own, not the workflow's report): full `cargo test --workspace` green (0 failed across 62 result lines); `cargo clippy --workspace --all-targets -- -D warnings` clean, no dead-code residue; the r-sma + r-breakout anchors and the dissolved-verb real goldens (generalize still reads RGrid) stay green. Bundled as one commit (the Scale node's count-pin and the r-meanrev anchors share graph_construct.rs, so a clean two-commit split was not path-separable); the implement-loop ran all three plan tasks in one pass and the tree was verified by hand. refs #159 --- crates/aura-cli/examples/r_meanrev.json | 1 + crates/aura-cli/examples/r_meanrev_open.json | 1 + crates/aura-cli/src/main.rs | 483 +++++++------------ crates/aura-cli/tests/cli_run.rs | 178 +++---- crates/aura-cli/tests/graph_construct.rs | 29 +- crates/aura-std/src/lib.rs | 2 + crates/aura-std/src/scale.rs | 75 +++ crates/aura-std/src/vocabulary.rs | 7 +- 8 files changed, 343 insertions(+), 433 deletions(-) create mode 100644 crates/aura-cli/examples/r_meanrev.json create mode 100644 crates/aura-cli/examples/r_meanrev_open.json create mode 100644 crates/aura-std/src/scale.rs diff --git a/crates/aura-cli/examples/r_meanrev.json b/crates/aura-cli/examples/r_meanrev.json new file mode 100644 index 0000000..df795a6 --- /dev/null +++ b/crates/aura-cli/examples/r_meanrev.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/examples/r_meanrev_open.json b/crates/aura-cli/examples/r_meanrev_open.json new file mode 100644 index 0000000..7631b15 --- /dev/null +++ b/crates/aura-cli/examples/r_meanrev_open.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"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 5df4f5c..68c8f0f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -37,9 +37,9 @@ use aura_registry::{ DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, }; use aura_std::{ - 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, + Bias, CarryCost, ConstantCost, Ema, GatedRecorder, LinComb, LongOnly, Recorder, RollingMax, + RollingMin, SeriesReducer, SimBroker, Sma, Sub, VolSlippageCost, PM_FIELD_NAMES, + PM_RECORD_KINDS, }; // `std_vocabulary` is now only reached through `project::Env::resolve` in production // code; the test module still builds reference blueprints against it directly, so @@ -50,6 +50,17 @@ use aura_std::std_vocabulary; // fused-builder retirement dropped the production caller); the import is test-only. #[cfg(test)] use aura_std::Delay; +// `Scale` is only used by the test-only `r_meanrev_signal` carve (#159 cut 3; the +// band half-width uses `Scale` in place of `LinComb(1)`) — no production caller, so +// the import is test-only, mirroring `Delay` above. +#[cfg(test)] +use aura_std::Scale; +// `Add`/`Gt`/`Latch`/`Mul`/`Sqrt` are now only reached from the test-only +// `r_breakout_signal`/`r_meanrev_signal` carves — #159 cut 3's retirement of the +// last fused mean-reversion builder dropped the last production caller, mirroring +// `Delay`/`Scale` above. +#[cfg(test)] +use aura_std::{Add, Gt, Latch, Mul, Sqrt}; use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; use std::collections::HashSet; @@ -1080,29 +1091,18 @@ fn r_sma_broker_label(pip_size: f64) -> String { format!("sim-optimal+risk-executor(pip_size={pip_size})") } -/// `aura sweep --strategy r-sma`: sweep the r-sma 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 / sqn_normalized / expectancy_r / net_expectancy_r. With `--trace`, each -/// member's equity / exposure / r_equity streams are persisted under -/// `runs/traces///` via `persist_traces_r` (mirroring -/// `momentum_sweep_family`), so a swept member is chartable. -/// The four griddable knobs of the r-sma sweep as value lists (#137): the SMA fast -/// / slow lengths and the vol-stop length / `k`-multiplier. The family is the cartesian -/// product of the four lists; absent flags fall back to the historical defaults (fast -/// `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k `{2.0}`) so a no-flags sweep is -/// byte-identical to the pre-#137 family. +/// The four-knob r-sma candidate grid the dissolved `generalize` verb resolves its +/// candidate from (`generalize_args_from`): the SMA fast / slow lengths and the +/// vol-stop length / `k`-multiplier, each a value list. Absent knobs fall back to the +/// historical defaults (fast `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k +/// `{2.0}`). The built-in `--strategy` sweep and its CLI grid flags retired with the +/// demo harnesses (#159 cuts 1b/2/3); this struct now serves `generalize` alone. #[derive(Clone, Debug, PartialEq)] struct RGrid { fast: Vec, slow: Vec, stop_length: Vec, stop_k: Vec, - window: Vec, - band_k: Vec, } impl Default for RGrid { @@ -1112,85 +1112,10 @@ impl Default for RGrid { slow: vec![6, 12], stop_length: vec![R_SMA_STOP_LENGTH], stop_k: vec![R_SMA_STOP_K], - window: vec![1920], - band_k: vec![2.0], } } } -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); - let mut varying: HashSet = HashSet::new(); - if grid.window.len() > 1 { - varying.insert("window".to_string()); - } - if grid.band_k.len() > 1 { - varying.insert("band_k".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 &n in &grid.window { - for &bk in &grid.band_k { - 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_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce) - .compile_with_params(&[]) - .expect("valid r-meanrev blueprint"); - let mut h = Harness::bootstrap(flat).expect("valid r-meanrev harness"); - h.run(data.run_sources(env)); - let named: Vec<(String, Scalar)> = vec![ - ("window".to_string(), Scalar::i64(n)), - ("band_k".to_string(), Scalar::f64(bk)), - ("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 } -} - /// Render a sweep family as one `RunReport` JSON line per point. Test helper: /// production (`run_sweep`) renders *and* persists per point. #[cfg(test)] @@ -1209,7 +1134,6 @@ fn sweep_report() -> String { enum Strategy { SmaCross, Momentum, - RMeanRev, } /// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is @@ -1240,7 +1164,6 @@ impl Strategy { match self { Strategy::SmaCross => "sma", Strategy::Momentum => "momentum", - Strategy::RMeanRev => "r-meanrev", } } } @@ -1286,17 +1209,13 @@ 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 -/// (`sma`/`momentum`/`r-sma`) persists each member's streams under -/// `runs/traces///` (opt-in); the `r-sma` member also carries -/// the `r_equity` tap (via `persist_traces_r`). -fn run_sweep( - strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid, - env: &project::Env, -) { +/// (`sma`/`momentum`) persists each member's streams under +/// `runs/traces///` (opt-in). +fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, env: &project::Env) { if persist && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) { @@ -1307,7 +1226,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::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)) { Ok(id) => id, @@ -2916,29 +2834,26 @@ fn r_breakout_signal(channel: Option) -> Composite { g.build().expect("r_breakout signal wiring resolves") } -/// 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 -/// the standard pip/R branch. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -fn r_meanrev_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)>, - window: Option, - band_k: f64, - stop_length: i64, - stop_k: f64, - reduce: bool, -) -> Composite { - let mut g = GraphBuilder::new("r_meanrev"); - // EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout). +/// The EWMA window for the canonical closed r_meanrev example (ganged mean/var Ema +/// length). Single source for the emitter and the proof tests that must all agree +/// with the baked `examples/r_meanrev.json`. +#[cfg(test)] +const R_MEANREV_WINDOW: i64 = 3; + +/// The Bollinger band half-width (in sigma) for the canonical closed r_meanrev +/// example. Single source alongside [`R_MEANREV_WINDOW`]. +#[cfg(test)] +const R_MEANREV_BAND_K: f64 = 2.0; + +/// The EWMA Bollinger-band mean-reversion signal leg, carved out of the retired fused +/// builder as a pure `price→bias` Composite so it serialises as blueprint data (#159 +/// cut 3). Verbatim signal computation vs that retired fused builder, except the band +/// half-width `band_k*sigma` uses `Scale` (a rosterable multiply) in place of +/// `LinComb(1)`. `#[cfg(test)]`: its only role is regenerating + pinning the examples; +/// production loads the shipped JSON. +#[cfg(test)] +fn r_meanrev_signal(window: Option, band_k: Option) -> Composite { + let mut g = GraphBuilder::new("r_meanrev_signal"); let (mut mean_b, mut var_b) = (Ema::builder().named("mean_window"), Ema::builder().named("var_window")); if let Some(n) = window { @@ -2949,59 +2864,31 @@ fn r_meanrev_graph( let dev = g.add(Sub::builder()); // price - mean let sq = g.add(Mul::builder()); // dev * dev let var = g.add(var_b); // EWMA variance - let sigma = g.add(Sqrt::builder()); // sigma (price units) - let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k*sigma - let upper = g.add(Add::builder()); // mean + k*sigma - let lower = g.add(Sub::builder()); // mean - k*sigma - let gt_hi = g.add(Gt::builder()); // price > upper -> overextended up -> fade short - let gt_lo = g.add(Gt::builder()); // lower > price -> overextended down -> fade long + let sigma = g.add(Sqrt::builder()); + let mut band_b = Scale::builder().named("band"); + if let Some(k) = band_k { + band_b = band_b.bind("factor", Scalar::f64(k)); + } + let band = g.add(band_b); + let upper = g.add(Add::builder()); + let lower = g.add(Sub::builder()); + let gt_hi = g.add(Gt::builder()); + let gt_lo = g.add(Gt::builder()); 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 (the standard SimBroker + RiskExecutor + recorders branch). - 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 exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias let price = g.source_role("price", ScalarKind::F64); - g.feed( - price, - [ - mean.input("series"), - dev.input("lhs"), - gt_hi.input("a"), - gt_lo.input("b"), - broker.input("price"), - exec.input("price"), - ], - ); + g.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]); g.connect(mean.output("value"), dev.input("rhs")); g.connect(dev.output("value"), sq.input("lhs")); - g.connect(dev.output("value"), sq.input("rhs")); // square: feed dev to both legs + g.connect(dev.output("value"), sq.input("rhs")); g.connect(sq.output("value"), var.input("series")); g.connect(var.output("value"), sigma.input("value")); - g.connect(sigma.output("value"), band.input("term[0]")); + g.connect(sigma.output("value"), band.input("signal")); g.connect(mean.output("value"), upper.input("lhs")); - g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma + g.connect(band.output("value"), upper.input("rhs")); g.connect(mean.output("value"), lower.input("lhs")); - g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma + g.connect(band.output("value"), lower.input("rhs")); g.connect(upper.output("value"), gt_hi.input("b")); g.connect(lower.output("value"), gt_lo.input("a")); g.connect(gt_hi.output("value"), short_latch.input("set")); @@ -3010,54 +2897,8 @@ fn r_meanrev_graph( g.connect(gt_hi.output("value"), long_latch.input("reset")); g.connect(long_latch.output("value"), exposure.input("lhs")); g.connect(short_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_meanrev wiring resolves") -} - -/// Persist a r-sma run's taps: equity (off the SimBroker), exposure (off the Bias), -/// r_equity = cum_realized_r + unrealized_r (off the RiskExecutor), and — only on a cost -/// run — net_r_equity (gross r_equity minus the cost-in-R taps). Separate from the two-tap -/// `persist_traces` so the pip handlers stay byte-unchanged on disk; the `net_r_equity` tap -/// is emitted only when `net_rows` is non-empty, so a no-cost run's on-disk trace set is -/// byte-unchanged too. -fn persist_traces_r( - name: &str, - manifest: &RunManifest, - eq_rows: &[(Timestamp, Vec)], - ex_rows: &[(Timestamp, Vec)], - req_rows: &[(Timestamp, Vec)], - net_rows: &[(Timestamp, Vec)], - env: &project::Env, -) { - let mut taps = vec![ - ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows), - ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows), - ColumnarTrace::from_rows("r_equity", &[ScalarKind::F64], req_rows), - ]; - if !net_rows.is_empty() { - taps.push(ColumnarTrace::from_rows("net_r_equity", &[ScalarKind::F64], net_rows)); - } - if let Err(e) = env.trace_store().write(name, manifest, &taps) { - eprintln!("aura: trace persist failed: {e}"); - std::process::exit(1); - } + g.expose(exposure.output("value"), "bias"); + g.build().expect("r_meanrev signal wiring resolves") } /// Which built-in harness `aura run` drives. A fixed compile-time enumeration over @@ -3328,7 +3169,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-meanrev (default sma). + /// Built-in strategy: sma | momentum | r-sma (default sma). #[arg(long)] strategy: Option, /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. @@ -3346,24 +3187,6 @@ struct SweepCmd { /// Family name that also persists each member's taps (mutually exclusive with --name). #[arg(long)] trace: Option, - /// Fast-MA grid axis (comma-separated values). - #[arg(long)] - fast: Option, - /// Slow-MA grid axis (comma-separated values). - #[arg(long)] - slow: Option, - /// Stop-length grid axis (comma-separated values). - #[arg(long)] - stop_length: Option, - /// Stop-k grid axis (comma-separated values). - #[arg(long)] - stop_k: Option, - /// Mean-reversion window grid axis (comma-separated values; r-meanrev). - #[arg(long)] - window: Option, - /// Mean-reversion band-k grid axis (comma-separated values; r-meanrev). - #[arg(long)] - band_k: Option, /// Blueprint sweep axis `=` (repeatable; .json mode). #[arg(long)] axis: Vec, @@ -3376,7 +3199,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-meanrev (default sma). + /// Built-in strategy: sma | momentum | r-sma (default sma). #[arg(long)] strategy: Option, /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. @@ -3648,7 +3471,6 @@ fn generalize_args_from( slow: vec![a.slow.ok_or_else(knobs)?], stop_length: vec![a.stop_length.ok_or_else(knobs)?], stop_k: vec![a.stop_k.ok_or_else(knobs)?], - ..RGrid::default() }; let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string()); let name = a.name.clone().unwrap_or_else(|| "generalize".to_string()); @@ -3674,12 +3496,11 @@ fn data_choice_from( } /// Map a built-in `--strategy` token to a `Strategy` (default sma), reusing the old -/// four-way parse arms; an unknown token is a usage error. +/// parse arms; an unknown token is a usage error. fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result { Ok(match s { None | Some("sma") => Strategy::SmaCross, Some("momentum") => Strategy::Momentum, - Some("r-meanrev") => Strategy::RMeanRev, Some(_) => return Err(usage()), }) } @@ -3931,14 +3752,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { std::process::exit(2); } // A built-in-only flag with a blueprint file is not in this grammar. - if a.strategy.is_some() - || a.fast.is_some() - || a.slow.is_some() - || a.stop_length.is_some() - || a.stop_k.is_some() - || a.window.is_some() - || a.band_k.is_some() - { + if a.strategy.is_some() { eprintln!("aura: {}", usage()); std::process::exit(2); } @@ -4066,7 +3880,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 ] [--window ] [--band-k ]".to_string(); + let usage = || "Usage: aura sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ]".to_string(); if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -4075,17 +3889,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - let mut grid = RGrid::default(); - let bad = |m: String| -> ! { - eprintln!("aura: {m}"); - std::process::exit(2) - }; - if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } - 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.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) .unwrap_or_else(|m| { eprintln!("aura: {m}"); @@ -4095,7 +3898,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), &grid, env); + run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), env); } } } @@ -4143,7 +3946,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); @@ -4449,6 +4252,114 @@ mod tests { assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); } + /// Regenerates the shipped r_meanrev examples from the carved signal. Run by hand: + /// `cargo test --bin aura emit_r_meanrev_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_meanrev{,_open}.json; run by hand"] + fn emit_r_meanrev_examples() { + std::fs::create_dir_all("examples").expect("examples dir"); + std::fs::write( + "examples/r_meanrev.json", + blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))) + .expect("serialize closed r_meanrev"), + ) + .expect("write examples/r_meanrev.json"); + std::fs::write( + "examples/r_meanrev_open.json", + blueprint_to_json(&r_meanrev_signal(None, None)).expect("serialize open r_meanrev"), + ) + .expect("write examples/r_meanrev_open.json"); + } + + /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3): + /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the + /// emitter; a drift between builder and file breaks it. + #[test] + fn shipped_r_meanrev_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))).expect("closed"), + include_str!("../examples/r_meanrev.json"), + ); + assert_eq!( + blueprint_to_json(&r_meanrev_signal(None, None)).expect("open"), + include_str!("../examples/r_meanrev_open.json"), + ); + } + + /// The shipped closed example, reloaded through the data plane and run, grades + /// bit-identically to running the carved signal directly (#159 cut 3). This is + /// r_meanrev's durable equivalence anchor after the fused builder retires: it pins + /// that `examples/r_meanrev.json` still produces the carve's grade, with no + /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. + #[test] + fn r_meanrev_example_loaded_runs_identically_to_the_carved_signal() { + let env = project::Env::std(); + let loaded = blueprint_from_json(include_str!("../examples/r_meanrev.json"), &|t| std_vocabulary(t)) + .expect("shipped r_meanrev example loads"); + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); + let via_carve = run_signal_r( + r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)), + &[], + RunData::Synthetic, + 0, + &env, + ); + assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); + } + + /// Independently pins the shipped `r_meanrev_signal` carve's FADE direction — + /// short (-1) above the band, long (+1) below — using the Scale-based band it + /// actually ships with. The equivalence anchor above only proves the loaded + /// example agrees with the carve; both sides run the same `r_meanrev_signal`, so + /// it is tautological on carve correctness (a mis-wired or sign-inverted carve + /// would fail identically on both sides and still pass). The retired + /// `r_meanrev_graph` builder (LinComb-based) that once graded against this exact + /// polarity is deleted (#159 cut 3); `aura-engine`'s `r_meanrev_e2e` survives but + /// still hand-builds the band with `LinComb(1)`, not `Scale`, so it does not + /// exercise the node this carve actually ships. `k = 0` collapses the band to the + /// lagging EWMA mean, isolating direction + latch from the sigma threshold (same + /// idiom as `r_meanrev_e2e`'s own k=0 case); window 3 (alpha = 0.5) lags the level + /// clearly. `wrap_r`'s `ex` tap reads `sig.output("bias")` directly (before any + /// broker/exec/cost machinery), so `rx_ex` carries the raw signal bias. + #[test] + fn r_meanrev_signal_fades_short_above_the_band_and_long_below() { + 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 flat = wrap_r( + r_meanrev_signal(Some(3), Some(0.0)), + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: 3, k: 2.0 }, + false, + None, + ) + .compile_with_params(&[]) + .expect("r-meanrev signal wraps to a valid harness"); + let mut h = Harness::bootstrap(flat).expect("r-meanrev harness bootstraps"); + // calm (price == lagging mean -> no fade) | sustained UP (price > mean -> + // fade SHORT) | sustained DOWN (price < mean -> fade LONG). + let closes = [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, + ]; + let prices: Vec<(Timestamp, Scalar)> = + closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); + let src: Vec> = vec![Box::new(VecSource::new(prices))]; + h.run(src); + let bias: Vec = + rx_ex.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect(); + assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up"); + assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}"); + let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); + let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); + assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}"); + assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}"); + } + /// 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. @@ -5331,46 +5242,6 @@ mod tests { assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); } - /// Property: the *shipped* `r_meanrev_graph` (the CLI compile unit, not - /// 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 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 - /// mean, isolating direction + latch from the sigma threshold; window 3 - /// (alpha = 0.5) lags the level clearly. The exposure Recorder (`tx_ex`, - /// `reduce = false`) carries the bias in col[0]. - #[test] - fn r_meanrev_graph_fades_short_above_the_band_and_long_below() { - 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 flat = r_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false) - .compile_with_params(&[]) - .expect("r-meanrev blueprint compiles"); - let mut h = Harness::bootstrap(flat).expect("r-meanrev harness bootstraps"); - // calm (price == lagging mean -> no fade) | sustained UP (price > mean -> - // fade SHORT) | sustained DOWN (price < mean -> fade LONG). - let closes = [ - 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, - ]; - let prices: Vec<(Timestamp, Scalar)> = - closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); - let src: Vec> = vec![Box::new(VecSource::new(prices))]; - h.run(src); - let bias: Vec = - rx_ex.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect(); - assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up"); - assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}"); - let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); - let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); - assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}"); - assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}"); - } - /// Property: a `blueprint_sweep_family` member built from a serialized signal is /// the SAME trading result as the cycle-1 single run of that signal at the same /// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index d276962..ba07fe8 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -2080,126 +2080,6 @@ fn run_unknown_harness_exits_two() { assert_eq!(out.status.code(), Some(2)); } -/// Extract the balanced `"metrics":{...}` object substring from one stdout member -/// line. The `family_id` prefix legitimately differs between a no-trace sweep -/// (`sweep-0`) and a `--trace n` sweep (`n-0`), so an equivalence check must -/// compare only the `metrics` block (the reduced/raw output under test), not the -/// whole line. Brace-balanced scan, not a JSON parser (no serde dep in this test -/// crate); the member lines are flat enough for this to be exact. -fn metrics_object(line: &str) -> &str { - let key = "\"metrics\":"; - let start = line.find(key).expect("member line has a metrics block") + key.len(); - let bytes = line.as_bytes(); - assert_eq!(bytes[start], b'{', "metrics value must be an object: {line}"); - let mut depth = 0usize; - for (i, &b) in bytes[start..].iter().enumerate() { - match b { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - return &line[start..start + i + 1]; - } - } - _ => {} - } - } - panic!("unbalanced metrics object: {line}") -} - -/// 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 -/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window × -/// one band_k × the single default stop = one member. -#[test] -fn sweep_strategy_r_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() { - let cwd = temp_cwd("sweep-r-meanrev-fold-vs-raw"); - - let folded = Command::new(BIN) - .args(["sweep", "--strategy", "r-meanrev", "--window", "3"]) - .current_dir(&cwd) - .output() - .expect("spawn folded (no-trace) r-meanrev sweep"); - assert!( - folded.status.success(), - "folded no-trace meanrev 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-meanrev", "--window", "3", "--trace", "m1"]) - .current_dir(&cwd) - .output() - .expect("spawn raw (--trace) r-meanrev sweep"); - assert!( - raw.status.success(), - "raw --trace meanrev 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 meanrev 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 meanrev member {i} must carry an r block: {fm}"); - assert_eq!( - fm, rm, - "folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}" - ); - } - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the meanrev family is the cartesian product of its grid axes — a -/// multi-value `--window` list yields exactly one member per window value, each -/// carrying its own bound window length in the manifest params. Guards the manual -/// cartesian loop in `r_meanrev_sweep_family`. windows 3 and 4 both warm up -/// on the 18-bar synthetic stream, so two members must appear, in grid order. -#[test] -fn sweep_strategy_r_meanrev_grids_one_member_per_window() { - let cwd = temp_cwd("sweep-r-meanrev-window-grid"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "r-meanrev", "--window", "3,4"]) - .current_dir(&cwd) - .output() - .expect("spawn 2-window r-meanrev sweep"); - assert!( - out.status.success(), - "window-grid meanrev 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-window grid must print 2 members: {stdout:?}"); - assert!( - lines[0].contains("[\"window\",{\"I64\":3}]"), - "first member must bind window=3: {}", - lines[0] - ); - assert!( - lines[1].contains("[\"window\",{\"I64\":4}]"), - "second member must bind window=4: {}", - lines[1] - ); - let _ = std::fs::remove_dir_all(&cwd); -} - /// 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 r-sma-only @@ -2292,6 +2172,26 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() { } } +/// Property (#159 cut 3): `--strategy r-meanrev` is a fully retired token on both +/// sweep and walkforward — the generic usage error, no special-casing. +#[test] +fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() { + for (args, verb) in [ + (&["sweep", "--strategy", "r-meanrev"][..], "sweep"), + (&["walkforward", "--strategy", "r-meanrev"][..], "walkforward"), + ] { + let cwd = temp_cwd("retired-r-meanrev-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), "generic usage, not special-cased: {stderr:?}"); + assert!(out.stdout.is_empty(), "no 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 @@ -2301,7 +2201,7 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() { 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"]) + .args(["sweep", "--strategy", "sma", "--channel", "3"]) .current_dir(&cwd) .output() .expect("spawn aura sweep --channel"); @@ -3925,7 +3825,7 @@ fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() { // is_file() discriminator sends every case down the built-in branch. for argv in [ &["run", "bogus"][..], - &["sweep", "bogus", "--fast", "3"][..], + &["sweep", "bogus", "--name", "x"][..], &["walkforward", "bogus", "--strategy", "sma"][..], &["mc", "bogus"][..], ] { @@ -4254,3 +4154,37 @@ fn shipped_r_sma_example_reproduces_the_builtin_grade() { assert!(stdout.contains("\"total_pips\":0.34185000000002036"), "{stdout}"); assert!(stdout.contains("\"n_trades\":3"), "{stdout}"); } + +/// `aura run` on the shipped closed r-meanrev example (#159 cut 3) actually loads +/// and grades through the real, separately-linked `aura` binary — not merely +/// in-process, as `r_meanrev_example_loaded_runs_identically_to_the_carved_signal` +/// (main.rs) and this test's own grade-equivalence check both do by calling +/// `run_signal_r`/`blueprint_from_json` inside the *test* binary. This is the +/// CLI-seam companion `shipped_r_sma_example_reproduces_the_builtin_grade` has for +/// r-sma: it pins that the `Scale`-based band, freshly added to the closed +/// vocabulary this iteration, resolves and runs cleanly through the production +/// vocabulary lookup + arg-parsing path a real invocation takes, not just through +/// the test harness's direct function calls. The synthetic stream never crosses +/// the band at this window/k, so the grade is genuinely all-zero (no trade) — +/// still a deterministic, meaningful pin: a regression that broke `Scale`'s CLI +/// wiring (e.g. a roster/registration mismatch) would surface as a non-zero exit +/// or a load error here, not as a metrics drift. +#[test] +fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "examples/r_meanrev.json"]) + .output() + .expect("spawn aura run example"); + assert_eq!( + out.status.code(), + Some(0), + "exit: {:?} stderr={}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}"); + assert!(stdout.contains("\"expectancy_r\":0.0"), "{stdout}"); + assert!(stdout.contains("\"n_trades\":0"), "{stdout}"); + assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}"); +} diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index f47ef5c..1a6025f 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() { let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( lines.len(), - 22, - "the std-only (no project) vocabulary has exactly the roster's 22 entries: {stdout}" + 23, + "the std-only (no project) vocabulary has exactly the roster's 23 entries: {stdout}" ); } @@ -756,3 +756,28 @@ fn shipped_r_breakout_open_example_lists_its_axis_namespace() { "the raw open params, in order, from the public gallery copy" ); } + +/// Property (#159 cut 3): the shipped closed example (`examples/r_meanrev.json`) is +/// genuinely closed — `graph introspect --params` reports zero unbound params. +#[test] +fn shipped_r_meanrev_example_is_genuinely_closed() { + let dir = temp_cwd("r-meanrev-example-closed-params"); + let (stdout, stderr, code) = + run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + assert_eq!(stdout, "", "the closed example must leave zero params unbound"); +} + +/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`) +/// lists its raw axis namespace, in lowering (node-add) order. +#[test] +fn shipped_r_meanrev_open_example_lists_its_axis_namespace() { + let dir = temp_cwd("r-meanrev-example-open-params"); + let (stdout, stderr, code) = + run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + assert_eq!( + stdout, "mean_window.length:I64\nvar_window.length:I64\nband.factor:F64\n", + "the raw open params, in lowering order" + ); +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 7cb6b3e..b3c8825 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -38,6 +38,7 @@ mod recorder; mod resample; mod rolling_max; mod rolling_min; +mod scale; mod series_reducer; mod session; mod sim_broker; @@ -75,6 +76,7 @@ pub use recorder::Recorder; pub use resample::Resample; pub use rolling_max::RollingMax; pub use rolling_min::RollingMin; +pub use scale::Scale; pub use series_reducer::SeriesReducer; pub use session::Session; pub use sim_broker::SimBroker; diff --git a/crates/aura-std/src/scale.rs b/crates/aura-std/src/scale.rs new file mode 100644 index 0000000..a1e4d66 --- /dev/null +++ b/crates/aura-std/src/scale.rs @@ -0,0 +1,75 @@ +//! `Scale` — a stateless `f64 -> f64` multiply-by-constant: `out = input * factor`. +//! The standard scalar-gain operator (the `*` is fixed by the type; `factor` is the +//! only knob). One f64 input, one f64 output, allocation-free on the hot path. +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// Stateless scalar gain: emits `input * factor` each cycle. Emits `None` until its +/// input is present (warm-up filter, C8). +pub struct Scale { + factor: f64, + out: [Cell; 1], +} + +impl Scale { + /// Build a gain node that multiplies its input by `factor`. + pub fn new(factor: f64) -> Self { + Self { factor, out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe for a blueprint primitive: declares `factor` and + /// builds through `Scale::new` (the slice is kind-checked before `build` runs). + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Scale", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }], + }, + |p| Box::new(Scale::new(p[0].f64())), + ) + } +} + +impl Node for Scale { + fn lookbacks(&self) -> Vec { + vec![1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; // not yet warmed up (C8 filter) + } + self.out[0] = Cell::from_f64(w[0] * self.factor); + Some(&self.out) + } + + fn label(&self) -> String { + format!("Scale({})", self.factor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn scale_multiplies_input_by_factor() { + let mut node = Scale::new(2.0); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, node.lookbacks()[0])]; + inputs[0].push(Scalar::f64(3.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(6.0)].as_slice())); + } + + #[test] + fn scale_is_none_until_input_present() { + let mut node = Scale::new(2.0); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + } +} diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs index 6ea06ec..c7aefe4 100644 --- a/crates/aura-std/src/vocabulary.rs +++ b/crates/aura-std/src/vocabulary.rs @@ -19,7 +19,7 @@ use crate::{ Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, - Mul, PositionManagement, Resample, RollingMax, RollingMin, Sizer, Sma, Sqrt, Sub, + Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, Sizer, Sma, Sqrt, Sub, VolSlippageCost, }; use aura_core::PrimitiveBuilder; @@ -75,6 +75,7 @@ std_vocabulary_roster! { "Resample" => Resample, "RollingMax" => RollingMax, "RollingMin" => RollingMin, + "Scale" => Scale, "Sizer" => Sizer, "SMA" => Sma, "Sqrt" => Sqrt, @@ -123,7 +124,7 @@ mod tests { assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); - // count guard: pins the roster at exactly 22 entries - assert_eq!(std_vocabulary_types().len(), 22); + // count guard: pins the roster at exactly 23 entries + assert_eq!(std_vocabulary_types().len(), 23); } }