feat(momentum): bool-param demo strategy + aura sweep --strategy selector

Proves the #105 generic portable member key end-to-end: a demo strategy whose
params have nothing in common with the SMA-cross demo, swept by the same
machinery, with a bool axis surfacing — conformant — in the member dir names.

- momentum strategy: price -> Ema(length:i64) -> Sub(price-ema) ->
  Exposure(scale:f64) -> LongOnly(enabled:bool) -> SimBroker. Three swept params
  of three kinds incl. a bool; the exposure sink taps the post-gate exposure so
  the bool's effect is in the trace.
- aura sweep gains --strategy <sma|momentum> via a pure, unit-tested
  parse_sweep_args (mirroring parse_real_args); default = SMA-cross, so the
  existing bare / --name / --trace forms are unchanged.
- `aura sweep --strategy momentum --trace mom` persists 8 member dirs named e.g.
  ema.length-5_exposure.scale-0.5_longonly.enabled-true — the bool in the dir
  name, every name matching [A-Za-z0-9._-], each chartable.
- ledger (docs/design/INDEX.md): the C22/#101 member-key note's sweep clause
  amended to the generalised portable form (MC seed{N} / walk-forward oos{ns}
  unchanged).

Self-verified: cargo build --workspace, cargo test --workspace (all green incl.
momentum_param_space_is_ema_exposure_longonly, the 8-point determinism test,
the parse_sweep_args grammar test, and the momentum_sweep_trace integration test
asserting 8 portable bool-bearing dirs + a chartable member), cargo clippy
--workspace --all-targets -D warnings.

refs #105
This commit is contained in:
2026-06-21 11:32:46 +02:00
parent 27f850dc52
commit 0b73a75d4b
3 changed files with 344 additions and 12 deletions
+171 -11
View File
@@ -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<Scalar>)>,
Receiver<(Timestamp, Vec<Scalar>)>,
) {
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/<name>/<member_key>/`.
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<String> = 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<Box<dyn aura_engine::Source>> =
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::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
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 <n>|--trace <n>]`: 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/<n>/<member_key>/` (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 <sma|momentum>] [--name <n> | --trace <n>]`.
/// 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 <sma|momentum>] [--name <n> | --trace <n>]".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 <sma|momentum>] [--name <n>|--trace <n>]`: 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/<n>/<member_key>/` (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 <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
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<String> =
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
}
}