Files
Aura/crates/aura-cli/src/main.rs
T
Brummel 5bded0ca83 feat(aura-cli): aura runs registry — persist on sweep, list + rank (cycle D / iter 3)
Final iteration of the run-registry cycle (#33): the read surface that makes a
sweep family — and runs across separate invocations — comparable over time
(C18's "compare experiments over time", which has no home in git or Gitea).

- `aura sweep` now persists each point's RunReport to runs/runs.jsonl (append-
  only, one serde_json line per run) in addition to printing it.
- `aura runs list` prints every stored record, in store order.
- `aura runs rank <metric>` prints the stored runs best-first by the metric
  (total_pips desc; max_drawdown / exposure_sign_flips asc); an unknown metric
  is a usage error (exit 2), like every other aura arg error.

sweep_report splits into a production sweep_family() (the pure run) and a
#[cfg(test)] renderer kept for the in-bin goldens; the dispatch gains run_sweep
/ runs_list / runs_rank over default_registry() (runs/runs.jsonl under cwd).
Process goldens run the binary in a temp cwd so persistence never dirties the
repo; /runs/ is git-ignored as a backstop.

Verified end-to-end: two `aura sweep` invocations accumulate 8 records;
`aura runs list` shows all 8; `aura runs rank total_pips` orders them best-first;
`aura runs rank bogus` exits 2 with the known-metrics hint. cargo test
--workspace green (cli_run 11, registry 5, engine 93, …); clippy
--workspace --all-targets -D warnings clean; no stray runs/ in the work tree.

refs #33
2026-06-10 20:37:51 +02:00

641 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and
//! automation drive: author a node, run a sim/sweep, emit structured metrics).
//!
//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample
//! signal-quality harness (synthetic source → SMA-cross → Exposure → SimBroker →
//! recording sinks), runs it deterministically (C1), and prints the run's
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
mod render;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
use aura_registry::{rank_by, Registry};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
/// demo trace carries one exposure sign flip and a real drawdown (C22 populated
/// trace). Deterministic and fixed (C1).
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The
/// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the
/// broker's slot 0 (slot order is load-bearing — both are f64).
// The harness-plus-two-drained-sink-receivers tuple has exactly one call site
// (`run_sample`); a named type would be speculative abstraction this cycle.
#[allow(clippy::type_complexity)]
fn sample_harness() -> (
Harness,
Receiver<(Timestamp, Vec<Scalar>)>,
Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let f64_recorder_sig = || aura_engine::NodeSchema {
inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0 fast SMA
Box::new(Sma::new(4)), // 1 slow SMA
Box::new(Sub::new()), // 2 spread
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(SimBroker::new(0.0001)), // 4 sim-optimal broker
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker's price slot
],
}],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure into broker slot 0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
})
.expect("valid sample signal-quality DAG");
(h, rx_eq, rx_ex)
}
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
/// (C1): the same build yields the same report.
fn run_sample() -> RunReport {
let (mut h, rx_eq, rx_ex) = sample_harness();
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
/// CLI-local sample builder; the engine ships no sample (the duplication with
/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA
/// lengths are injected at compile, not baked here.
fn sma_cross(name: &str) -> Composite {
Composite::new(
name,
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length
],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
)
}
/// The sample signal-quality blueprint (value-empty) **with its two recording
/// sinks reachable**: returns the equity + exposure receivers a per-point sweep
/// run drains (the SMA lengths + exposure scale are injected at compile via the
/// point vector). The single source of the sample topology — `build_sample`
/// (the `aura graph` entry) is expressed on top of it.
#[allow(clippy::type_complexity)]
fn sample_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 bp = Composite::new(
"sample",
vec![
BlueprintNode::Composite(sma_cross("sma_cross")),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in sinks, no re-export
);
(bp, rx_eq, rx_ex)
}
/// The sample blueprint without its sink receivers — the `aura graph` render
/// entry, which never runs the graph (so the receivers are dropped).
fn build_sample() -> Composite {
sample_blueprint_with_sinks().0
}
/// The built-in sample rendered by `aura graph`.
fn sample_blueprint() -> Composite {
build_sample()
}
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
fn scalar_as_param_f64(s: &Scalar) -> f64 {
match s {
Scalar::I64(n) => *n as f64,
Scalar::F64(f) => *f,
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
/// Run the built-in sample over a small built-in grid (fast ∈ {2,3},
/// slow ∈ {4,5}, scale ∈ {0.5} — 4 points) and render one JSON line per point in
/// enumeration (odometer) order. Pure + deterministic (C1): the same build yields
/// the same report. Each point builds a fresh blueprint (fresh sink channels),
/// bootstraps it under the point vector, runs it, and folds the drained sinks to
/// metrics — the per-point closure the engine `sweep` drives disjointly.
fn sweep_family() -> SweepFamily {
let space = sample_blueprint_with_sinks().0.param_space();
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3}
vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5}
vec![Scalar::F64(0.5)], // scale ∈ {0.5}
],
)
.expect("the built-in grid matches the sample param-space");
sweep(&grid, |point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
})
}
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
/// production (`run_sweep`) renders *and* persists per point.
#[cfg(test)]
fn sweep_report() -> String {
let mut out = String::new();
for pt in &sweep_family().points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
}
/// The default run registry: an append-only JSONL store under the current
/// working directory. (A project-configured runs-dir via `Aura.toml` is a later
/// refinement.)
fn default_registry() -> Registry {
Registry::open("runs/runs.jsonl")
}
/// `aura sweep`: run the built-in sweep, persist each point's `RunReport` to the
/// registry (the run record, queryable over time — C18), and print each as one
/// JSON line.
fn run_sweep() {
let reg = default_registry();
for pt in &sweep_family().points {
if let Err(e) = reg.append(&pt.report) {
eprintln!("aura: {e}");
std::process::exit(2);
}
println!("{}", pt.report.to_json());
}
}
/// `aura runs list`: print every stored run record, in store (over-time) order.
fn runs_list() {
for report in &load_runs_or_exit() {
println!("{}", report.to_json());
}
}
/// `aura runs rank <metric>`: print the stored runs best-first by `metric`.
fn runs_rank(metric: &str) {
match rank_by(load_runs_or_exit(), metric) {
Ok(ranked) => {
for report in &ranked {
println!("{}", report.to_json());
}
}
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
}
}
/// Load the registry or exit 2 with the error. A missing registry loads empty.
fn load_runs_or_exit() -> Vec<RunReport> {
default_registry().load().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
})
}
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
/// (their spread) → a signal `Ema` of that line → the histogram (line signal).
/// The composite exposes all **three MACD lines** as a named output record
/// (`macd`, `signal`, `histogram`); the strategy trades the histogram by reading
/// `from_field: 2`. A richer fixture than `sma_cross`: a nested EMA-of-EMA chain
/// with interior fan-out (the MACD line feeds *both* the signal EMA and the
/// histogram). Three `length` knobs (fast, slow, signal) are injected at compile
/// in node order; value-empty here.
fn macd(name: &str) -> Composite {
Composite::new(
name,
vec![
Ema::builder().into(), // 0 fast EMA
Ema::builder().into(), // 1 slow EMA
Sub::builder().into(), // 2 MACD line = fast slow
Ema::builder().into(), // 3 signal EMA of the MACD line
Sub::builder().into(), // 4 histogram = MACD line signal
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0]
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow → line[1]
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // line → signal EMA
Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // line → histogram[0]
Edge { from: 3, to: 4, slot: 1, from_field: 0 }, // signal → histogram[1]
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
],
source: None,
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast EMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow EMA length
ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length
],
vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram
],
)
}
/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Exposure` →
/// `SimBroker` → recording sinks. Channels are threaded so a run can drain the
/// sinks; `macd_blueprint` drops the receivers for the structural render.
fn macd_strategy_blueprint(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Composite {
Composite::new(
"macd_strategy",
vec![
BlueprintNode::Composite(macd("macd")),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 2 }, // histogram → Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure → broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity → sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure → sink
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price → macd role 0
Target { node: 2, slot: 1 }, // price → SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior macd carries the aliases
vec![], // output: the root ends in sinks, no re-export
)
}
/// The MACD strategy blueprint as a param-space fixture (receivers dropped, since
/// `param_space()` reads structure only, never runs the graph).
#[cfg(test)]
fn macd_blueprint() -> Composite {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
macd_strategy_blueprint(tx_eq, tx_ex)
}
/// The point vector for the MACD strategy, in `param_space()` slot order:
/// `[fast EMA length, slow EMA length, signal EMA length, exposure scale]`. Short
/// windows so the 7-tick synthetic stream still produces a non-trivial trace
/// (conventional MACD is 12/26/9, meaningless on 7 points).
fn macd_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::I64(3), Scalar::F64(0.5)]
}
/// A longer synthetic stream than the SMA sample's 7 ticks: MACD's EMAs each warm
/// up over their `length`, so the stream rises, falls, then rises again to give the
/// histogram room to flip sign more than once *after* warm-up. Deterministic (C1).
fn macd_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.collect()
}
/// Run the MACD strategy: compile the nested composite blueprint to a flat harness
/// (the same bootstrap path the SMA sample's compiled view uses), drive it on the
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
/// deterministic (C1).
fn run_macd() -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
.compile_with_params(&macd_point())
.expect("valid macd blueprint");
let mut h = Harness::bootstrap(flat).expect("valid macd harness");
let prices = macd_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params: vec![
("ema_fast".to_string(), 2.0),
("ema_slow".to_string(), 4.0),
("ema_signal".to_string(), 3.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
const USAGE: &str =
"usage: aura run [--macd] | aura graph | aura sweep | aura runs list | aura runs rank <metric>";
fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive,
// so an unexpected trailing token falls through to the usage-error path rather
// than masquerading as a successful run (#16 strict reading).
let args: Vec<String> = std::env::args().skip(1).collect();
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep"] => run_sweep(),
["runs", "list"] => runs_list(),
["runs", "rank", metric] => runs_rank(metric),
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_blueprint_with_sinks_bootstraps_runs_and_drains() {
// the factory returns the two Recorder receivers (build_sample drops them),
// so a caller can bootstrap one point, run it, and drain both sinks.
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("sample blueprint compiles under a valid point");
h.run(vec![synthetic_prices()]);
assert!(!rx_eq.try_iter().collect::<Vec<_>>().is_empty(), "equity sink drained empty");
assert!(!rx_ex.try_iter().collect::<Vec<_>>().is_empty(), "exposure sink drained empty");
}
#[test]
fn sweep_report_renders_four_points_in_odometer_order() {
let out = sweep_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
// each line is a full RunReport; the commit is the real git HEAD
// (volatile), so pin the per-point manifest params (odometer order, last
// axis fastest) + the metric keys, not the commit value.
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]);
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
assert!(line.contains(r#""exposure_sign_flips":"#), "missing flips: {line}");
assert!(line.ends_with('}'), "line not closed: {line}");
}
}
#[test]
fn sweep_report_is_deterministic() {
// C1 at the CLI edge: the same build yields a bit-identical report.
assert_eq!(sweep_report(), sweep_report());
}
#[test]
fn run_macd_compiles_from_nested_composite_and_is_deterministic() {
// the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a
// flat runnable harness (the call not panicking proves the compile+bootstrap
// path), and runs it. C1 determinism: two runs are bit-identical.
let r1 = run_macd();
let r2 = run_macd();
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
// the synthetic stream is carried end-to-end and the trace is well-formed.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 18));
assert!(r1.metrics.total_pips.is_finite(), "macd pips must be finite: {:?}", r1.metrics);
assert!(r1.metrics.max_drawdown >= 0.0, "drawdown is non-negative: {:?}", r1.metrics);
// after warm-up the EMA-of-EMA histogram crosses zero, so the strategy
// reverses exposure at least once — a genuinely non-trivial trace.
assert!(
r1.metrics.exposure_sign_flips >= 1,
"macd trace should flip exposure: {:?}",
r1.metrics
);
}
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
/// blueprint's swept param surface relabels the three otherwise-indistinguishable
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named
/// composite boundary visible end-to-end through `param_space()`, with the slot
/// count and order unchanged (C23 — pure naming overlay, not curation: every
/// interior slot stays sweepable, the `scale` knob is unaffected).
#[test]
fn macd_param_space_surfaces_the_three_named_aliases() {
let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three aliased composite slots, in declared (fast, slow, signal) order,
// then the strategy-level Exposure `scale` (outside the composite, unaliased).
assert_eq!(
names,
vec![
"macd.fast".to_string(),
"macd.slow".to_string(),
"macd.signal".to_string(),
"scale".to_string(),
],
"MACD param surface must expose the three named EMA lengths + scale",
);
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();
let r2 = run_sample();
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
let m = &r1.metrics;
// exactly one exposure sign flip in the demo trace (rises then reverses).
assert_eq!(m.exposure_sign_flips, 1);
// a non-trivial, populated trace: a real drawdown.
assert!(m.max_drawdown > 0.0);
// hand-computed magnitudes for the chosen stream (float tolerance; the
// computation's dust is ~1e-15).
assert!(
(m.max_drawdown - 0.17).abs() < 1e-9,
"max_drawdown = {}",
m.max_drawdown
);
assert!(
(m.total_pips - (-0.13)).abs() < 1e-9,
"total_pips = {}",
m.total_pips
);
// manifest carries the sample's known configuration.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 7));
// commit is the build's git identity (or the no-git "unknown" fallback);
// either way it is non-empty and fixed at compile time, so it is stable
// across runs of the same build (C1 determinism, already asserted above
// via `to_json()`).
assert!(!r1.manifest.commit.is_empty());
assert_eq!(r1.manifest.commit, r2.manifest.commit);
}
}