diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index aa61a8b..acc74f4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -25,7 +25,7 @@ use aura_registry::{ group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError, }; -use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; +use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; use std::collections::HashSet; @@ -540,6 +540,82 @@ fn sweep_family(trace: Option<&str>) -> SweepFamily { .expect("the built-in named grid matches the sample param-space") } +/// The EMA-distance momentum demo strategy with its two recording sinks reachable. +/// The signal is how far price sits above/below its own EMA (`price - ema`), sized +/// by `Exposure`, then passed through the long-only `LongOnly` gate. Three swept +/// knobs of three kinds — `ema.length` (i64), `exposure.scale` (f64), +/// `longonly.enabled` (bool) — with nothing in common with the SMA-cross demo: the +/// generic-sweep proof. The exposure sink taps the FINAL (gated) exposure so the +/// bool's effect is visible in the trace. +#[allow(clippy::type_complexity)] +fn momentum_blueprint_with_sinks() -> ( + Composite, + Receiver<(Timestamp, Vec)>, + Receiver<(Timestamp, Vec)>, +) { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let mut g = GraphBuilder::new("momentum"); + // Name the param-bearing nodes explicitly so the swept paths are guaranteed + // ema.length / exposure.scale / longonly.enabled regardless of default-name + // derivation (the member-key examples + the param-space test depend on these). + let ema = g.add(Ema::builder().named("ema")); // ema.length + let dist = g.add(Sub::builder()); // momentum = price - ema + let expo = g.add(Exposure::builder().named("exposure")); // exposure.scale + let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param) + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); + let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); + let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [ema.input("series"), dist.input("lhs"), broker.input("price")]); + g.connect(ema.output("value"), dist.input("rhs")); // momentum = price - ema + g.connect(dist.output("value"), expo.input("signal")); + g.connect(expo.output("exposure"), gate.input("exposure")); + g.connect(gate.output("exposure"), broker.input("exposure")); // gated exposure -> broker + g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink + g.connect(gate.output("exposure"), ex.input("col[0]")); // gated exposure -> sink + let bp = g.build().expect("momentum blueprint wiring resolves"); + (bp, rx_eq, rx_ex) +} + +/// Run the momentum strategy over its built-in grid — `ema.length ∈ {5,10}` × +/// `exposure.scale ∈ {0.5,1.0}` × `longonly.enabled ∈ {true,false}` = 8 points, +/// all three axes varying. Mirrors `sweep_family`: capture the binder's varying +/// axes, key each member via the generic portable `member_key`. With `--trace`, +/// persist each member under `runs/traces///`. +fn momentum_sweep_family(trace: Option<&str>) -> SweepFamily { + let bp = momentum_blueprint_with_sinks().0; + let space = bp.param_space(); + let binder = bp + .axis("ema.length", [5, 10]) + .axis("exposure.scale", [0.5, 1.0]) + .axis("longonly.enabled", [true, false]); + let varying: HashSet = binder.varying_axes().into_iter().collect(); + binder + .sweep(|point| { + let (bp, rx_eq, rx_ex) = momentum_blueprint_with_sinks(); + let mut h = bp + .bootstrap_with_cells(point) + .expect("grid points are kind-checked against param_space"); + let sources: Vec> = + vec![Box::new(VecSource::new(showcase_prices()))]; + let window = window_of(&sources).expect("non-empty showcase stream"); + h.run(sources); + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + let named = zip_params(&space, point); + let key = member_key(&named, &varying); + let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE); + if let Some(name) = trace { + persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows); + } + let equity = f64_field(&eq_rows, 0); + let exposure = f64_field(&ex_rows, 0); + RunReport { manifest, metrics: summarize(&equity, &exposure) } + }) + .expect("the momentum named grid matches the momentum param-space") +} + /// Render a sweep family as one `RunReport` JSON line per point. Test helper: /// production (`run_sweep`) renders *and* persists per point. #[cfg(test)] @@ -559,13 +635,55 @@ fn default_registry() -> Registry { Registry::open("runs/runs.jsonl") } -/// `aura sweep [--name |--trace ]`: run the built-in sweep, persist it as a -/// *family* (related records sharing one `family_id`, C18/C21) via `append_family`, -/// and print each point's record line carrying the assigned id. With `--trace`, -/// also persist each member's streams under `runs/traces///` (opt-in). -fn run_sweep(name: &str, persist: bool) { +/// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the +/// SMA-cross sample; `momentum` is the bool-param demo. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Strategy { + SmaCross, + Momentum, +} + +/// Parse the `sweep` tail: `[--strategy ] [--name | --trace ]`. +/// Defaults: SMA-cross, name "sweep", no persist (today's bare `aura sweep`). +/// `--name` and `--trace` are mutually exclusive. Pure (no I/O / exit) so the +/// grammar is unit-testable; `main` does the side effects. An unknown token, a +/// flag without its value, an unknown strategy, or both name flags rejects. +fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> { + let usage = || "sweep [--strategy ] [--name | --trace ]".to_string(); + let mut strategy = Strategy::SmaCross; + let mut name: Option<(String, bool)> = None; // (name, persist) + let mut tail = rest; + while let Some((flag, t)) = tail.split_first() { + let (value, t) = t.split_first().ok_or_else(usage)?; + match *flag { + "--strategy" => { + strategy = match *value { + "sma" => Strategy::SmaCross, + "momentum" => Strategy::Momentum, + _ => return Err(usage()), + }; + } + "--name" if name.is_none() => name = Some(((*value).to_string(), false)), + "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), + _ => return Err(usage()), + } + tail = t; + } + let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false)); + Ok((strategy, name, persist)) +} + +/// `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`, also persist each member's streams +/// under `runs/traces///` (opt-in). +fn run_sweep(strategy: Strategy, name: &str, persist: bool) { let reg = default_registry(); - let family = sweep_family(persist.then_some(name)); + let family = match strategy { + Strategy::SmaCross => sweep_family(persist.then_some(name)), + Strategy::Momentum => momentum_sweep_family(persist.then_some(name)), + }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, Err(e) => { @@ -1005,7 +1123,7 @@ fn run_macd(trace: Option<&str>) -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--strategy ] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--name |--trace ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -1029,9 +1147,13 @@ fn main() { ["chart", name] => emit_chart(name, ChartMode::Overlay), ["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels), ["graph"] => print!("{}", render::render_html(&sample_blueprint())), - ["sweep"] => run_sweep("sweep", false), - ["sweep", "--name", n] => run_sweep(n, false), - ["sweep", "--trace", n] => run_sweep(n, true), + ["sweep", rest @ ..] => match parse_sweep_args(rest) { + Ok((strategy, name, persist)) => run_sweep(strategy, &name, persist), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + }, ["walkforward"] => run_walkforward("walkforward", false), ["walkforward", "--name", n] => run_walkforward(n, false), ["walkforward", "--trace", n] => run_walkforward(n, true), @@ -1529,4 +1651,42 @@ mod tests { let unique: std::collections::HashSet<&String> = keys.iter().collect(); assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}"); } + + #[test] + fn momentum_param_space_is_ema_exposure_longonly() { + // pins the default node-name path segments (ema / exposure / longonly) and + // the param order/kinds the member key + sweep depend on. + let names: Vec = + momentum_blueprint_with_sinks().0.param_space().into_iter().map(|p| p.name).collect(); + assert_eq!( + names, + vec![ + "ema.length".to_string(), + "exposure.scale".to_string(), + "longonly.enabled".to_string(), + ], + ); + } + + #[test] + fn momentum_sweep_is_deterministic_and_has_eight_points() { + let a = momentum_sweep_family(None); + let b = momentum_sweep_family(None); + assert_eq!(a.points.len(), 8, "2x2x2 grid = 8 points"); + assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); + } + + #[test] + fn parse_sweep_args_defaults_selects_and_rejects() { + assert_eq!(parse_sweep_args(&[]), Ok((Strategy::SmaCross, "sweep".to_string(), false))); + assert_eq!(parse_sweep_args(&["--trace", "swp"]), Ok((Strategy::SmaCross, "swp".to_string(), true))); + assert_eq!(parse_sweep_args(&["--name", "s"]), Ok((Strategy::SmaCross, "s".to_string(), false))); + assert_eq!( + parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]), + Ok((Strategy::Momentum, "mom".to_string(), true)) + ); + assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err()); + assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive + assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value + } } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 0e65f2b..54c4824 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -838,3 +838,172 @@ fn sweep_trace_is_byte_deterministic_across_runs() { // here too — this pins run-to-run determinism, C1). assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)"); } + +/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy +/// momentum --trace ` sweeps the momentum strategy — a totally different +/// param surface incl. a bool — and persists one portable member dir per grid +/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the +/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member +/// is chartable. +#[test] +fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() { + let cwd = temp_cwd("momentum-trace"); + + let traced = Command::new(BIN) + .args(["sweep", "--strategy", "momentum", "--trace", "mom"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy momentum --trace"); + assert!( + traced.status.success(), + "momentum sweep exit: {:?}; stderr: {}", + traced.status, + String::from_utf8_lossy(&traced.stderr) + ); + + let base = cwd.join("runs/traces/mom"); + let members: Vec = std::fs::read_dir(&base) + .expect("read mom trace dir") + .map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 member dirs; got {members:?}"); + for m in &members { + assert!( + m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')), + "non-portable member dir name: {m}" + ); + } + assert!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}"); + assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}"); + + // one member is chartable (the bool sits inside the dir name passed through). + let one = &members[0]; + let chart = Command::new(BIN) + .args(["chart", &format!("mom/{one}")]) + .current_dir(&cwd) + .output() + .expect("spawn aura chart mom/"); + assert!(chart.status.success(), "chart exit: {:?}", chart.status); + assert!( + String::from_utf8_lossy(&chart.stdout).contains("uPlot"), + "expected a self-contained uPlot page" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property (#105, the `--strategy` selector at the stdout boundary): `aura sweep +/// --strategy momentum` SWITCHES the swept surface — it prints exactly the +/// 8-point momentum family (vs the default SMA arm's 4 points), and every line is +/// a `RunReport` over the momentum param surface, carrying the bool axis as a +/// TYPED `{"Bool":...}` param (both `true` and `false` appear across the grid). +/// The disk-trace test pins the member-dir names; this pins the binary's actual +/// stdout. A selector miswiring (`momentum`→`SmaCross`) would leave that test +/// green — the dirs would still differ — while silently printing SMA reports +/// here; nothing else catches it. Asserts on structure + the typed bool token, +/// never the volatile commit value or the metric floats, so it stays +/// deterministic. Run in a fresh temp cwd so the family-store ordinal is stable. +#[test] +fn sweep_strategy_momentum_prints_eight_typed_bool_member_lines() { + let cwd = temp_cwd("sweep-strategy-momentum"); + let out = Command::new(BIN) + .args(["sweep", "--strategy", "momentum"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy momentum"); + assert!( + out.status.success(), + "momentum sweep exit: {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let lines: Vec<&str> = stdout.lines().collect(); + // 2x2x2 momentum grid = 8 member lines (vs the default SMA arm's 4): the + // selector switched the swept surface, observable at stdout. + assert_eq!(lines.len(), 8, "momentum sweep must print 8 family lines: {stdout:?}"); + + // Every line is a family member: assigned id + a nested RunReport over the + // MOMENTUM param surface (the SMA arm carries `signals.trend.*`, never + // `ema.length`/`longonly.enabled`). + for line in &lines { + assert!(line.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {line}"); + assert!(line.contains("[\"ema.length\","), "momentum surface missing ema.length: {line}"); + assert!(!line.contains("signals.trend.fast.length"), "must not be the SMA surface: {line}"); + } + + // The bool axis is carried as a TYPED `{"Bool":...}` param, both values + // present across the 8-point grid — the bool-param proof at the stdout edge. + assert!( + stdout.contains("[\"longonly.enabled\",{\"Bool\":true}]"), + "missing typed Bool=true param: {stdout}" + ); + assert!( + stdout.contains("[\"longonly.enabled\",{\"Bool\":false}]"), + "missing typed Bool=false param: {stdout}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property (#105, the bool actually gates — observable on disk): the +/// `longonly.enabled` param is genuinely WIRED into the momentum harness, not a +/// no-op knob. For one grid point's two bool members (same `ema.length` + +/// `exposure.scale`, differing only in the bool), the `enabled=true` member +/// records a long-only exposure stream — every recorded value `>= 0` — while the +/// otherwise-identical `enabled=false` member records at least one NEGATIVE +/// exposure over the same deterministic showcase stream. This pins the gate's +/// effect on the persisted C7 exposure tap end-to-end: the LongOnly unit test +/// proves `max(x,0)` in isolation, the dir-name test proves the bool reaches the +/// key, but only this proves the bool changes the OBSERVED run output. A +/// regression that dropped the gate from the wiring (passing exposure straight to +/// the broker) would make the two members' exposure taps identical — this fails +/// loudly. Asserts on the sign (>=0 / <0), never exact floats, so it is +/// deterministic across builds. +#[test] +fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() { + let cwd = temp_cwd("momentum-longonly-gate"); + let traced = Command::new(BIN) + .args(["sweep", "--strategy", "momentum", "--trace", "gate"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy momentum --trace"); + assert!( + traced.status.success(), + "momentum --trace exit: {:?}; stderr: {}", + traced.status, + String::from_utf8_lossy(&traced.stderr) + ); + + // One grid point, its two bool members (identical but for the bool). + let base = cwd.join("runs/traces/gate"); + let enabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-true"); + let disabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-false"); + + let exposures = |dir: &std::path::Path| -> Vec { + let body = std::fs::read_to_string(dir.join("exposure.json")).expect("read exposure.json"); + // the single f64 column's comma-separated values (substring parse, no serde). + json_array_body(&body, "\"columns\":[[") + .split(',') + .map(|t| t.trim().parse::().expect("exposure value is an f64")) + .collect() + }; + + let enabled_vals = exposures(&enabled); + let disabled_vals = exposures(&disabled); + + // enabled=true is a long-only gate: NO recorded exposure is negative. + assert!( + enabled_vals.iter().all(|&x| x >= 0.0), + "long-only (enabled=true) must clamp short exposure to >= 0; got: {enabled_vals:?}" + ); + // enabled=false passes through: at least one negative survives over the same + // showcase stream — proof the bool genuinely changes the run, not a no-op. + assert!( + disabled_vals.iter().any(|&x| x < 0.0), + "pass-through (enabled=false) must keep a short exposure over this stream; got: {disabled_vals:?}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 5949257..dfc9c24 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1030,7 +1030,10 @@ to family runs: `aura sweep|mc|walkforward --trace ` persists *each member a nested standalone run-dir `runs/traces///`, reusing `persist_traces`/`TraceStore` verbatim (engine untouched — persistence is a side-effect inside each per-member closure). The `member_key` is content-derived and -deterministic — sweep `f{fast}s{slow}`, MC `seed{N}`, walk-forward `oos{ns}` — never +deterministic — sweep: the *varying* axes rendered as a filesystem-portable +directory component (`-` tokens joined by `_`, charset +`[A-Za-z0-9._-]`, case-less values, length-capped with an FNV fallback), MC +`seed{N}`, walk-forward `oos{ns}` — never a runtime ordinal, because members run in parallel under the engine's `Fn + Sync` HOFs, where a counter would be schedule-dependent (C1); concurrent `TraceStore::write` targets disjoint member dirs, so it is lock-free. Opt-in: without `--trace`,