5bded0ca83
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
641 lines
27 KiB
Rust
641 lines
27 KiB
Rust
//! `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);
|
||
}
|
||
}
|