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:
+171
-11
@@ -25,7 +25,7 @@ use aura_registry::{
|
|||||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||||
walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError,
|
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::sync::mpsc::{self, Receiver};
|
||||||
use std::collections::HashSet;
|
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")
|
.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:
|
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||||
/// production (`run_sweep`) renders *and* persists per point.
|
/// production (`run_sweep`) renders *and* persists per point.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -559,13 +635,55 @@ fn default_registry() -> Registry {
|
|||||||
Registry::open("runs/runs.jsonl")
|
Registry::open("runs/runs.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura sweep [--name <n>|--trace <n>]`: run the built-in sweep, persist it as a
|
/// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the
|
||||||
/// *family* (related records sharing one `family_id`, C18/C21) via `append_family`,
|
/// SMA-cross sample; `momentum` is the bool-param demo.
|
||||||
/// and print each point's record line carrying the assigned id. With `--trace`,
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
/// also persist each member's streams under `runs/traces/<n>/<member_key>/` (opt-in).
|
enum Strategy {
|
||||||
fn run_sweep(name: &str, persist: bool) {
|
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 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)) {
|
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1005,7 +1123,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USAGE: &str =
|
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() {
|
fn main() {
|
||||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
// 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] => emit_chart(name, ChartMode::Overlay),
|
||||||
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
||||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
["sweep"] => run_sweep("sweep", false),
|
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
||||||
["sweep", "--name", n] => run_sweep(n, false),
|
Ok((strategy, name, persist)) => run_sweep(strategy, &name, persist),
|
||||||
["sweep", "--trace", n] => run_sweep(n, true),
|
Err(msg) => {
|
||||||
|
eprintln!("aura: {msg}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
["walkforward"] => run_walkforward("walkforward", false),
|
["walkforward"] => run_walkforward("walkforward", false),
|
||||||
["walkforward", "--name", n] => run_walkforward(n, false),
|
["walkforward", "--name", n] => run_walkforward(n, false),
|
||||||
["walkforward", "--trace", n] => run_walkforward(n, true),
|
["walkforward", "--trace", n] => run_walkforward(n, true),
|
||||||
@@ -1529,4 +1651,42 @@ mod tests {
|
|||||||
let unique: std::collections::HashSet<&String> = keys.iter().collect();
|
let unique: std::collections::HashSet<&String> = keys.iter().collect();
|
||||||
assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}");
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -838,3 +838,172 @@ fn sweep_trace_is_byte_deterministic_across_runs() {
|
|||||||
// here too — this pins run-to-run determinism, C1).
|
// here too — this pins run-to-run determinism, C1).
|
||||||
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
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 <name>` 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<String> = 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/<member>");
|
||||||
|
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<f64> {
|
||||||
|
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::<f64>().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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1030,7 +1030,10 @@ to family runs: `aura sweep|mc|walkforward --trace <name>` persists *each member
|
|||||||
a nested standalone run-dir `runs/traces/<name>/<member_key>/`, reusing
|
a nested standalone run-dir `runs/traces/<name>/<member_key>/`, reusing
|
||||||
`persist_traces`/`TraceStore` verbatim (engine untouched — persistence is a
|
`persist_traces`/`TraceStore` verbatim (engine untouched — persistence is a
|
||||||
side-effect inside each per-member closure). The `member_key` is content-derived and
|
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 (`<axis-name>-<value>` 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`
|
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`
|
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`,
|
targets disjoint member dirs, so it is lock-free. Opt-in: without `--trace`,
|
||||||
|
|||||||
Reference in New Issue
Block a user