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:
@@ -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 <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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user