Files
Aura/crates/aura-cli/src/main.rs
T
Brummel 3fe4684190 feat(0081): cost-model graph cycle 1 — ConstantCost node + net-R seam
Lands the first cycle of the "Cost-model graph (in R)" milestone: cost is
now a composable in-graph node, not a post-run scalar (C10).

- ConstantCost (aura-std): a flat round-trip cost per trade, emitted in R
  as a 3-field record {cost_in_r, cum_cost_in_r, open_cost_in_r} isomorphic
  to PositionManagement's R-triple. R-pure: cost_in_r = cost_per_trade /
  |entry-stop|; notional cancels, no equity held.
- summarize_r folds the cost stream into net_expectancy_r, replacing its
  scalar round_trip_cost (one home for cost, no double-count). Positional
  co-temporal join; an empty stream is the gross-R baseline.
- net_r_equity tap (stage1_r_graph): LinComb(4) over [cum_realized_r,
  unrealized_r, -cum_cost_in_r, -open_cost_in_r] -> Recorder, a sibling of
  r_equity; --cost-per-trade on the run path wires the node + tap + fold.
- A no-cost run is byte-identical: no net_r_equity tap, net == gross, the
  C18 golden unchanged.

Tests: exact-subsumption (the node-stream net == mean(r_i - cost_i) over
ALL trades incl. the window-end open one), the in-graph net_r_equity tap +
net < gross E2E, the no-cost golden held. Full suite green; clippy clean.

Implement-time decisions ratified (recorded on #148): the cost stream is
recorded 3-wide (the node's full output; summarize_r reads col 0/2, col 1
feeds the in-graph net curve) — resolving a 2-wide/3-wide inconsistency in
the plan's Step 3; Trade drops its `latched` field (cost now arrives in R),
so r_col ENTRY_PRICE/STOP_PRICE become #[allow(dead_code)] layout contract.

Scoped to the run path (non-reduce); sweep/walkforward/mc pass None — cost
on the reduce-mode sweep path and OOS-pooling cost are a deferred follow-up.

Known pre-existing concern (not this cycle): aura-std lib.rs module doc
still names "realistic broker profiles", retired by the C10 rework.

refs #148
2026-06-28 14:41:45 +02:00

4528 lines
218 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 → Bias → SimBroker →
//! recording sinks), runs it deterministically (C1), and prints the run's
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
//!
//! `aura run --real <SYMBOL> [--from <ms>] [--to <ms>]` feeds that same harness
//! real M1 close bars, streamed lazily from the local data-server archive through
//! the #71 Source seam (`M1FieldSource`) instead of the synthetic stream — the
//! first real-data backtest from the CLI.
mod render;
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_composites::{risk_executor, risk_executor_vol_open, StopRule};
use aura_engine::{
f64_field, join_on_ts, monte_carlo, param_stability, r_bootstrap, r_metrics_from_rs, summarize,
summarize_r, walk_forward, window_of, ColumnarTrace, Composite, Edge, FamilySelection, FlatGraph,
GraphBuilder, Harness, JoinedRow, McAggregate, McFamily, RBootstrap, RollMode, RunManifest,
RunMetrics, RunReport, SelectionMode, SourceSpec, SweepFamily, SweepPoint, SyntheticSpec, Target,
VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
};
use aura_registry::{
check_r_metric, generalization, group_families, mc_member_reports, optimize_deflated,
optimize_plateau, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind,
FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, TraceStore, WriteKind,
};
use aura_std::{
Add, Bias, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder,
RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, PM_FIELD_NAMES,
PM_RECORD_KINDS,
};
use std::sync::mpsc::{self, Receiver};
use std::sync::LazyLock;
use std::collections::HashSet;
/// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major
/// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no
/// recorded geometry sidecar to thread; a single named source keeps the broker's
/// divisor (`sample_harness` / `SimBroker::builder`) and the recorded broker label
/// (`sim_optimal_manifest`) in lockstep, so they cannot silently drift apart.
/// The real path threads the sidecar's looked-up `pip_size` instead.
const SYNTHETIC_PIP_SIZE: f64 = 0.0001;
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
/// stream's epoch-unit; for real M1 that is nanoseconds. A classic 3-month
/// in-sample / 1-month out-of-sample / 1-month step (contiguous OOS tiling).
const WF_DAY_NS: i64 = 86_400_000_000_000;
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
/// Trials-deflation resampling budget for walk-forward winner selection. Recorded
/// on each winner's manifest (so `overfit_probability` is reproducible by re-run);
/// CLI flags for these are a deferred refinement.
const DEFLATION_N_RESAMPLES: usize = 1000;
const DEFLATION_BLOCK_LEN: usize = 5;
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// 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()
}
/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks
/// rising, falling, then rising again so the trend SMA spread and the MACD
/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up
/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite,
/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter
/// `synthetic_prices`. Deterministic and fixed (C1).
fn showcase_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()
}
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
/// tapped on the SimBroker, exposure tapped on the Bias 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(pip_size: f64) -> (
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(Bias::new(0.5)), // 3 bias
Box::new(SimBroker::new(pip_size)), // 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(),
Bias::builder().schema().clone(),
SimBroker::builder(pip_size).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)
}
/// Build the sim-optimal `RunManifest`: the engine-external descriptor fields
/// (commit, seed-free synthetic run, broker label) are constant across the CLI's
/// built-in harnesses — only `params` and `window` vary. Centralizing the broker
/// label keeps it a single source (and a single edit point for per-asset pip
/// work): the `pip_size` it renders is the one its caller ran the broker at.
fn sim_optimal_manifest(
params: Vec<(String, Scalar)>,
window: (Timestamp, Timestamp),
seed: u64,
pip_size: f64,
) -> RunManifest {
// Typed params pass straight through: the manifest carries self-describing
// Scalars, so a length stays `i64` and a scale `f64` in the record.
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed,
broker: format!("sim-optimal(pip_size={pip_size})"),
selection: None,
instrument: None,
}
}
/// Persist a run's drained taps to the on-disk trace store under `runs/traces/<name>/`
/// (beside the run registry's `runs/`). Shared by every run form — all drain the same
/// two f64 taps (equity off the broker, exposure off the strategy) and carry a
/// `RunManifest`. Pure wiring over `ColumnarTrace` + `TraceStore`; the engine is
/// untouched. An I/O failure is a usage-level error (stderr + exit 2), per the spec.
fn persist_traces(
name: &str,
manifest: &RunManifest,
eq_rows: &[(Timestamp, Vec<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)],
) {
let taps = vec![
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
];
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(2);
}
}
/// The maximum length (bytes) of an on-disk member-key path component. Comfortably
/// under the 255-byte POSIX `NAME_MAX` and inside Windows' 260-char `MAX_PATH`
/// once the `runs/traces/<name>/` prefix and `/<tap>.json` suffix are added.
const MAX_KEY: usize = 200;
/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` to
/// `_`. The single source of filesystem-portability for an on-disk path component
/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe).
fn sanitize_component(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
.collect()
}
/// Render a scalar value case-lessly: integers/timestamps as decimal digits, bool
/// as `true`/`false`, f64 via Rust's `Display` (decimal, shortest round-trip, NO
/// scientific notation). Case-less rendering is what keeps two members of one
/// family from ever differing only by letter case (case-insensitive-FS safety).
fn render_value(v: &Scalar) -> String {
match v {
Scalar::I64(n) => n.to_string(),
Scalar::F64(x) => x.to_string(),
Scalar::Bool(b) => b.to_string(),
Scalar::Timestamp(t) => t.0.to_string(),
}
}
/// FNV-1a-64 over `bytes` — a fixed, version-stable, non-cryptographic hash used
/// only as the over-cap member-key disambiguator (`std`'s `DefaultHasher` is
/// explicitly unstable across releases, so it cannot name an on-disk artefact).
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// The portable, collision-free member key for a swept grid point: one `name-value`
/// token per *varying* axis (in `named`'s param-space slot order), joined by `_`,
/// every token sanitised to the portable charset. Pinned (singleton) axes carry no
/// information and are omitted. A 1-point grid (no varying axis) keys as `"m"`. A
/// key over `MAX_KEY` degrades to a conformant `h-<16-hex>` FNV fallback so the
/// key is one valid path component for ANY grid (the #105 generalisation).
fn member_key(named: &[(String, Scalar)], varying: &HashSet<String>) -> String {
let key: String = named
.iter()
.filter(|(n, _)| varying.contains(n))
.map(|(n, v)| format!("{}-{}", sanitize_component(n), sanitize_component(&render_value(v))))
.collect::<Vec<_>>()
.join("_");
let key = if key.is_empty() { "m".to_string() } else { key };
if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) }
}
/// Default decimation budget: target horizontal buckets. ~2000 buckets ⇒ ≤ ~4000
/// spine slots (min+max per bucket) — a few-thousand-point page regardless of the
/// underlying multi-year M1 point count.
const CHART_DECIMATE_BUCKETS: usize = 2000;
/// Per-tap decimation kind (#111): the bounded exposure stream (C10, f64 ∈ [-1,+1])
/// reduces by per-bucket mean, so its net/duty-cycle level survives decimation
/// instead of collapsing to a -1..+1 band (every bucket of a multi-year exposure
/// straddles many sign flips, so min/max would be ±1 everywhere). An unbounded
/// cumulative curve (equity) keeps the min/max envelope so drawdowns survive. Keyed
/// on the tap name — `exposure` is the only bounded level tap today.
fn reduce_for_tap(tap: &str) -> ReduceKind {
if tap == "exposure" {
ReduceKind::Mean
} else {
ReduceKind::MinMax
}
}
/// Serve-time decimation on the aligned `ChartData` (#108). Partition the shared
/// `xs` into at most `buckets` contiguous index ranges; per non-empty bucket emit the
/// bucket's first (and, if it spans >1 index, last) timestamp as shared spine slots,
/// and reduce each series per its [`ReduceKind`] (#111): a `MinMax` series emits min
/// then max (the envelope — equity drawdowns survive), a `Mean` series emits the
/// per-bucket mean in both slots (the net level — a bounded exposure shows its
/// duty-cycle instead of a -1..+1 band). An all-null bucket emits null. `meta` passes
/// through unchanged. Deterministic (C1). Full data stays on disk; only the served
/// page is thinned. No-op when `xs.len() <= 2 * buckets`.
fn decimate(data: ChartData, buckets: usize) -> ChartData {
let buckets = buckets.max(1);
let n = data.xs.len();
if n <= 2 * buckets {
return data;
}
let ChartData { xs, series, meta } = data;
// Bucket index bounds (lo, hi_exclusive, two_slots) + the decimated shared spine.
// xs is sorted+deduped (strictly increasing) -> boundary timestamps are strictly
// increasing across and within buckets, so the spine stays monotonic for uPlot.
let mut bounds: Vec<(usize, usize, bool)> = Vec::with_capacity(buckets);
let mut out_xs: Vec<i64> = Vec::with_capacity(2 * buckets);
for b in 0..buckets {
let lo = b * n / buckets;
let hi = (b + 1) * n / buckets;
if lo >= hi {
continue;
}
let two = hi - lo > 1;
out_xs.push(xs[lo]);
if two {
out_xs.push(xs[hi - 1]);
}
bounds.push((lo, hi, two));
}
let out_series: Vec<Series> = series
.into_iter()
.map(|s| {
let mut points: Vec<Option<f64>> = Vec::with_capacity(out_xs.len());
for &(lo, hi, two) in &bounds {
let (first, second) = match s.reduce {
ReduceKind::MinMax => {
// envelope: min at the first slot, max at the second.
let mut mn = f64::INFINITY;
let mut mx = f64::NEG_INFINITY;
let mut any = false;
for v in s.points[lo..hi].iter().flatten() {
any = true;
if *v < mn {
mn = *v;
}
if *v > mx {
mx = *v;
}
}
if any { (Some(mn), Some(mx)) } else { (None, None) }
}
ReduceKind::Mean => {
// net level: the per-bucket mean written to both slots (a flat
// step), so a bounded high-flip series shows its duty-cycle
// instead of a -1..+1 band (#111).
let mut sum = 0.0;
let mut cnt = 0u32;
for v in s.points[lo..hi].iter().flatten() {
sum += *v;
cnt += 1;
}
let m = if cnt > 0 { Some(sum / cnt as f64) } else { None };
(m, m)
}
};
points.push(first);
if two {
points.push(second);
}
}
Series { name: s.name, y_scale_id: s.y_scale_id, points, reduce: s.reduce }
})
.collect();
ChartData { xs: out_xs, series: out_series, meta }
}
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
/// 3-step union-spine alignment — no tap privileged, no point dropped:
/// (1) xs = the sorted, deduped union of every tap's timestamps;
/// (2) synthesize an empty-payload spine over xs and pass ALL taps (via
/// `ColumnarTrace::to_rows`, which yields uniformly-f64 rows) as symmetric sides
/// of `join_on_ts`, so no side row is dropped and none occupies the privileged
/// `JoinedRow.spine`;
/// (3) flatten each (tap, column) to a `Series` of `Option<f64>` over xs.
fn build_chart_data(name: &str, traces: RunTraces) -> ChartData {
let mut xs: Vec<i64> = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect();
xs.sort_unstable();
xs.dedup();
let spine: Vec<(Timestamp, Vec<Scalar>)> = xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
let tap_rows: Vec<Vec<(Timestamp, Vec<Scalar>)>> = traces.taps.iter().map(|t| t.to_rows()).collect();
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> = tap_rows.iter().map(|r| r.as_slice()).collect();
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
let mut series: Vec<Series> = Vec::new();
for (i, tap) in traces.taps.iter().enumerate() {
for c in 0..tap.columns.len() {
let name = if tap.columns.len() == 1 { tap.tap.clone() } else { format!("{}[{c}]", tap.tap) };
let y_scale_id = format!("y_{}", series.len());
let points: Vec<Option<f64>> =
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[c].as_f64())).collect();
series.push(Series { name, y_scale_id, points, reduce: reduce_for_tap(&tap.tap) });
}
}
let m = &traces.manifest;
let meta = ChartMeta {
kind: "run".to_string(),
name: name.to_string(),
commit: m.commit.clone(),
window: (m.window.0.0, m.window.1.0),
broker: m.broker.clone(),
seed: m.seed,
taps: traces.taps.iter().map(|t| t.tap.clone()).collect(),
members: None,
params: m.params.iter().map(|(k, v)| (k.clone(), render_value(v))).collect(),
};
ChartData { xs, series, meta }
}
/// One member's contribution to the comparison build: its key (the future series
/// name) paired with the chosen tap's drained `(ts, row)` pairs.
type MemberRows = (String, Vec<(Timestamp, Vec<Scalar>)>);
/// Build the comparison `ChartData` for a family: one `Series` per member (the
/// chosen `tap`'s column), labelled by `member.key`, ALL sharing ONE `y_scale_id`
/// (the members measure one identical quantity, so a shared scale is what makes
/// them comparable — unlike the single-run overlay, whose series are different
/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data
/// uses. `Err` if NO member carries `tap` (refuse-don't-guess).
fn build_comparison_chart_data(
name: &str,
members: &[FamilyMember],
tap: &str,
) -> Result<ChartData, String> {
let mut member_rows: Vec<MemberRows> = Vec::new();
for m in members {
if let Some(t) = m.traces.taps.iter().find(|t| t.tap == tap) {
member_rows.push((m.key.clone(), t.to_rows()));
}
}
if member_rows.is_empty() {
return Err(format!("no family member has a tap named '{tap}'"));
}
let mut xs: Vec<i64> =
member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect();
xs.sort_unstable();
xs.dedup();
let spine: Vec<(Timestamp, Vec<Scalar>)> =
xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> =
member_rows.iter().map(|(_, r)| r.as_slice()).collect();
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
// One shared y-scale across all member series (same quantity).
let y_scale_id = format!("y_cmp_{tap}");
let mut series: Vec<Series> = Vec::new();
for (i, (key, _)) in member_rows.iter().enumerate() {
// Project column 0 — the doc's "chosen tap's column" (singular). The
// comparison taps in scope (equity / exposure) are single-column `f64`.
// A future multi-column tap selection would need a column index here.
let points: Vec<Option<f64>> =
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect();
series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points, reduce: reduce_for_tap(tap) });
}
// member_rows is non-empty here (checked above) => members is non-empty, so
// members[0] is safe. commit/broker ARE shared across a family (one frozen
// artifact, one broker profile), but the window is NOT: a walk-forward family's
// members are disjoint OOS windows (commit 4c64feb), so the family window is the
// SPAN across all members — (min from, max to). For sweep/MC, whose members
// share one window, the span collapses to that shared window, so this is the one
// correct reading for all three kinds.
let m = &members[0].traces.manifest;
let window = (
members.iter().map(|fm| fm.traces.manifest.window.0.0).min().unwrap(),
members.iter().map(|fm| fm.traces.manifest.window.1.0).max().unwrap(),
);
let meta = ChartMeta {
kind: "family".to_string(),
name: name.to_string(),
commit: m.commit.clone(),
window,
broker: m.broker.clone(),
seed: m.seed,
taps: vec![tap.to_string()],
members: Some(members.len()),
params: Vec::new(),
};
Ok(ChartData { xs, series, meta })
}
/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the
/// run has no such tap (refuse-don't-guess). Used by the `--tap` flag on the
/// single-run chart path; without `--tap` the single-run page is unchanged.
fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
if !data.series.iter().any(|s| s.name == tap) {
// List the valid taps so the user can correct a typo (#131) — refuse, but help.
let available: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect();
return Err(format!("run has no tap named '{tap}' (available: {})", available.join(", ")));
}
let series: Vec<Series> = data.series.into_iter().filter(|s| s.name == tap).collect();
let mut meta = data.meta;
meta.taps = vec![tap.to_string()];
Ok(ChartData { xs: data.xs, series, meta })
}
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
/// single run charts all its taps (or the one `--tap` selects); a family overlays
/// one tap (default `equity`) across its members; an unknown name is a usage error
/// (stderr + exit 2), never a panic.
fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
let store = TraceStore::open("runs");
match store.name_kind(name) {
NameKind::Run => {
let traces = match store.read(name) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let mut data = build_chart_data(name, traces);
if let Some(t) = tap {
data = match filter_to_tap(data, t) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
}
let data = decimate(data, CHART_DECIMATE_BUCKETS);
print!("{}", render::render_chart_html(&data, mode));
}
NameKind::Family => {
let members = match store.read_family(name) {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let data = decimate(data, CHART_DECIMATE_BUCKETS);
print!("{}", render::render_chart_html(&data, mode));
}
NameKind::NotFound => {
eprintln!(
"aura: no recorded run or family '{name}' under runs/traces \
(run `aura run --trace {name}` or `aura sweep --trace {name}` first)"
);
std::process::exit(2);
}
}
}
/// 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(trace: Option<&str>) -> RunReport {
if let Some(n) = trace
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(synthetic_prices()))];
let window = window_of(&sources).expect("non-empty synthetic stream");
h.run(sources);
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 manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
/// `aura run --real <SYMBOL>`: run the built-in sample harness over real M1 close
/// bars streamed lazily from the local data-server archive through the #71 Source
/// seam (`M1FieldSource`, a `Box<dyn Source>`), not the synthetic `VecSource`. Same
/// fold as `run_sample`. The manifest window is read from a *separate* probe source
/// (a Source is single-pass), so the run source streams the window untouched.
/// A no-local-data condition (unknown symbol, or a window overlapping no file / no
/// bars) is a usage error: stderr + exit(2), not a panic.
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
if let Some(n) = trace
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms);
let (mut h, rx_eq, rx_ex) = sample_harness(pip_size);
h.run(vec![source]);
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 manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
pip_size,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
/// Parse `aura chart` args: `<name> [--tap <t>] [--panels]` in any order.
fn parse_chart_args(rest: &[&str]) -> Result<(String, Option<String>, ChartMode), String> {
let mut name: Option<String> = None;
let mut tap: Option<String> = None;
let mut mode = ChartMode::Overlay;
let mut i = 0;
while i < rest.len() {
match rest[i] {
"--panels" => {
mode = ChartMode::Panels;
i += 1;
}
"--tap" => {
let t = rest.get(i + 1).ok_or("--tap needs a value")?;
tap = Some((*t).to_string());
i += 2;
}
other if !other.starts_with("--") && name.is_none() => {
name = Some(other.to_string());
i += 1;
}
other => return Err(format!("unexpected chart argument '{other}'")),
}
}
let name = name.ok_or("chart needs a <name>")?;
Ok((name, tap, mode))
}
/// The shared `--real <SYMBOL> [--from <ms>] [--to <ms>]` accumulator for the
/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the
/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol
/// rejection, and the "window flags require `--real`" rule — so the two siblings
/// agree with each other (the `run` parser inlines the same strictness rather than
/// sharing this accumulator). Each parser owns its other
/// flags (strategy / name / trace); on a `--real`/`--from`/`--to` token it calls
/// `accept`, and at the end calls `finish` to validate and build the `DataChoice`.
#[derive(Default)]
struct RealWindowGrammar {
symbol: Option<String>,
from_ms: Option<i64>,
to_ms: Option<i64>,
}
impl RealWindowGrammar {
/// Consume one `--real`/`--from`/`--to` flag and its already-split value.
/// Returns `Ok(true)` if the flag was a real/window flag (consumed),
/// `Ok(false)` if it is not ours (the caller handles it), `Err` on a malformed
/// real/window flag: a repeated flag (the `if from.is_none()` strictness) or
/// an empty `--real` symbol.
fn accept(&mut self, flag: &str, value: &str, usage: &impl Fn() -> String) -> Result<bool, String> {
match flag {
"--real" if self.symbol.is_none() && !value.is_empty() => {
self.symbol = Some(value.to_string());
}
"--from" if self.from_ms.is_none() => {
self.from_ms = Some(value.parse().map_err(|_| usage())?);
}
"--to" if self.to_ms.is_none() => {
self.to_ms = Some(value.parse().map_err(|_| usage())?);
}
"--real" | "--from" | "--to" => return Err(usage()), // repeated / empty symbol
_ => return Ok(false),
}
Ok(true)
}
/// Validate (window flags require `--real`) and assemble the `DataChoice`.
fn finish(self, usage: &impl Fn() -> String) -> Result<DataChoice, String> {
match self.symbol {
Some(symbol) => Ok(DataChoice::Real { symbol, from_ms: self.from_ms, to_ms: self.to_ms }),
None if self.from_ms.is_some() || self.to_ms.is_some() => Err(usage()),
None => Ok(DataChoice::Synthetic),
}
}
}
/// What `--real` parsing yields: the synthetic default, or a real symbol + an
/// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable.
#[derive(Debug, Clone, PartialEq)]
enum DataChoice {
Synthetic,
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
}
/// The source provider threaded into the family builders: synthetic built-in
/// streams, or real M1 close bars from the data-server archive. Replaces the
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
/// from one place (Fork B/D/F).
///
/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
/// series: the full-window consumers (`full_window` / `run_sources`, used by
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two
/// faces never reach one consumer (a family is either full-window or windowed), so
/// the split is invisible per call site but real across the type — read both
/// family builders to see it whole.
enum DataSource {
Synthetic,
Real {
server: std::sync::Arc<data_server::DataServer>,
symbol: String,
from_ms: Option<i64>,
to_ms: Option<i64>,
pip: f64,
},
}
/// No-local-data refusal — stderr + exit(2), mirroring `run_sample_real`.
fn no_real_data(symbol: &str) -> ! {
eprintln!("aura: no local data for symbol '{symbol}' at {}", data_server::DEFAULT_DATA_PATH);
std::process::exit(2)
}
/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse
/// (stderr + exit 2) when the symbol has no recorded geometry — the single home of
/// the guessed-pip refusal, shared by `open_real_source` and `from_choice`. Reads
/// the symbol's geometry metadata (not bar data) before the run source opens, so an
/// instrument with no recorded geometry refuses without a guessed pip; the pip is
/// the provider's recorded value, honest by construction.
fn pip_or_refuse(server: &std::sync::Arc<data_server::DataServer>, symbol: &str) -> f64 {
match aura_ingest::instrument_geometry(server, symbol) {
Some(geo) => geo.pip_size,
None => {
eprintln!(
"aura: no recorded geometry for symbol '{symbol}' at {}\
refusing to run a real instrument with a guessed pip",
data_server::DEFAULT_DATA_PATH
);
std::process::exit(2);
}
}
}
/// Probe the full data window: open a single-pass probe `M1FieldSource`, drain it
/// for the first/last timestamp, and return `(first, last)`. Refuses (via
/// `no_real_data`) when the symbol/window yields no source or no bars. Shared by
/// `run_sample_real` (which needs the manifest window from a probe separate from
/// the run source) and `DataSource::full_window`.
fn probe_window(
server: &std::sync::Arc<data_server::DataServer>,
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
) -> (Timestamp, Timestamp) {
let mut probe = aura_ingest::M1FieldSource::open(server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol));
let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol));
let mut last = first;
while let Some((t, _)) = aura_engine::Source::next(&mut probe) {
last = t;
}
(first, last)
}
/// Open a real M1-close source for a recorded symbol over an optional window, returning
/// the run source paired with its manifest `window` and per-instrument `pip_size`.
/// Single home of the real-source construction the single-run handlers share — the
/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and
/// the run-source `open` (each refusal an stderr + exit 2). Pre-data refusals keep the
/// pip honest by construction. Shared by `run_sample_real` and `run_stage1_r`.
fn open_real_source(
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
) -> (Box<dyn aura_engine::Source>, (Timestamp, Timestamp), f64) {
// Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access
// so an instrument with no geometry refuses without touching the archive.
let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
let pip = pip_or_refuse(&server, symbol);
if !server.has_symbol(symbol) {
no_real_data(symbol);
}
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
let window = probe_window(&server, symbol, from_ms, to_ms);
let source: Box<dyn aura_engine::Source> =
match aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) {
Some(s) => Box::new(s),
None => no_real_data(symbol),
};
(source, window, pip)
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 2) on a symbol
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G),
/// via the same `pip_or_refuse` / `no_real_data` helpers as
/// `run_sample_real`.
fn from_choice(choice: DataChoice) -> DataSource {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Real { symbol, from_ms, to_ms } => {
let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
let pip = pip_or_refuse(&server, &symbol);
if !server.has_symbol(&symbol) {
no_real_data(&symbol);
}
DataSource::Real { server, symbol, from_ms, to_ms, pip }
}
}
}
fn pip_size(&self) -> f64 {
match self {
DataSource::Synthetic => SYNTHETIC_PIP_SIZE,
DataSource::Real { pip, .. } => *pip,
}
}
/// The full run window, probed once. Synthetic: the showcase span. Real:
/// `probe_window` drains a separate single-pass probe source for first/last ts
/// (the same helper `run_sample_real` uses for its manifest window).
fn full_window(&self) -> (Timestamp, Timestamp) {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
window_of(&s).expect("non-empty showcase stream")
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms)
}
}
}
/// The full walk-forward span. Synthetic draws the 60-bar `walkforward_prices`
/// span — NOT `showcase_prices` (which `full_window` uses): walk-forward is a
/// *windowed* consumer whose roller `(24,12,12)` needs 36 bars, so it uses the
/// longer built-in stream (byte-unchanged from the pre-`DataSource`
/// `walkforward_family`, which derived its span the same way). Real: the same
/// probed `--from..--to` window as `full_window`.
fn wf_full_span(&self) -> (Timestamp, Timestamp) {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))];
window_of(&s).expect("non-empty walkforward stream")
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms)
}
}
}
/// A fresh full-window source per member (single-pass). Synthetic: showcase.
fn run_sources(&self) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
DataSource::Real { server, symbol, from_ms, to_ms, .. } => vec![Box::new(
aura_ingest::M1FieldSource::open(server, symbol, *from_ms, *to_ms, aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol)),
)],
}
}
/// A fresh windowed source for an IS/OOS sub-window (walk-forward). Synthetic:
/// `walkforward_window_source`. Real: `open_window` (ns-native `Timestamp`).
fn windowed_sources(&self, from: Timestamp, to: Timestamp) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
DataSource::Real { server, symbol, .. } => vec![Box::new(
aura_ingest::M1FieldSource::open_window(server, symbol, Some(from), Some(to), aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol)),
)],
}
}
/// WindowRoller sizes per data kind (Fork F): bar-index for synthetic (24/12/12
/// over the 60-bar span), calendar-ns for real.
fn wf_window_sizes(&self) -> (i64, i64, i64) {
match self {
DataSource::Synthetic => (24, 12, 12),
DataSource::Real { .. } => (WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS),
}
}
/// The built-in strategy's length grid, **per data kind**. Synthetic keeps the
/// short lengths that fit the 18/60-bar demo streams (a 200-bar MA would never
/// warm on a 60-bar stream); real uses realistic M1 lengths so the SMA-cross +
/// MACD signal is a real trend cross over tens of thousands of bars, not noise.
/// Returns `(trend_fast grid, trend_slow grid, (macd_fast, macd_slow, macd_signal))`;
/// only the two trend axes vary (a 2×2 sweep), the MACD lengths are pinned.
fn strategy_lengths(&self) -> ([i64; 2], [i64; 2], (i64, i64, i64)) {
match self {
DataSource::Synthetic => ([2, 3], [4, 5], (2, 4, 3)),
// 50/200-style intraday crosses on M1 (minutes), standard 12/26/9 MACD.
DataSource::Real { .. } => ([50, 100], [200, 400], (12, 26, 9)),
}
}
}
/// 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 {
let mut g = GraphBuilder::new(name);
let fast = g.add(Sma::builder().named("fast")); // fast SMA leg
let slow = g.add(Sma::builder().named("slow")); // slow SMA leg
let sub = g.add(Sub::builder());
let price = g.input_role("price");
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), sub.input("lhs"));
g.connect(slow.output("value"), sub.input("rhs"));
g.expose(sub.output("value"), "cross");
g.build().expect("sample sma_cross wiring resolves")
}
/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD), combined
/// by a weighted sum. A multiply-nested composite (root → signals → {trend,
/// momentum}); the blend is a multi-param node living inside it, with one weight
/// bound as a structural constant (so it drops out of the sweepable surface).
fn signals(name: &str) -> Composite {
let mut g = GraphBuilder::new(name);
let trend = g.add(sma_cross("trend")); // trend leg (one f64 "cross")
let momentum = g.add(macd("momentum")); // momentum leg (3 outputs)
// blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
// weights[2] is bound (a fixed signal-line weight) → removed from param_space;
// weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.*
// (and renders the `blend:` prefix).
let blend = g.add(
LinComb::builder(3)
.named("blend")
.bind("weights[2]", Scalar::f64(0.5)),
);
let price = g.input_role("price");
g.feed(price, [trend.input("price"), momentum.input("price")]);
g.connect(trend.output("cross"), blend.input("term[0]")); // trend.cross → blend.term[0]
g.connect(momentum.output("histogram"), blend.input("term[1]")); // momentum.histogram → blend.term[1]
g.connect(momentum.output("signal"), blend.input("term[2]")); // momentum.signal → blend.term[2]
g.expose(blend.output("value"), "signal");
g.build().expect("sample signals wiring resolves")
}
/// 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 eight free params — the trend SMA lengths, the momentum EMA
/// lengths, the two open blend weights, and the exposure scale — are injected at
/// compile via the point vector). The root harness of the sample topology, whose
/// signal is built by the nested `signals` composite; `build_sample` (the `aura
/// graph` entry) is expressed on top of it.
#[allow(clippy::type_complexity)]
fn sample_blueprint_with_sinks(pip_size: f64) -> (
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("sample");
let sig = g.add(signals("signals"));
let exposure = g.add(Bias::builder().named("bias"));
let broker = g.add(SimBroker::builder(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, [sig.input("price"), broker.input("price")]);
g.connect(sig.output("signal"), exposure.input("signal")); // blended signal -> Bias
g.connect(exposure.output("bias"), broker.input("exposure")); // bias -> broker slot 0
g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink
g.connect(exposure.output("bias"), ex.input("col[0]")); // bias -> sink
let bp = g.build().expect("sample blueprint wiring resolves");
(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(SYNTHETIC_PIP_SIZE).0
}
/// The built-in sample rendered by `aura graph`.
fn sample_blueprint() -> Composite {
build_sample()
}
/// 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(trace: Option<&str>, data: &DataSource) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
let bp = sample_blueprint_with_sinks(pip).0;
let space = bp.param_space();
let (tf, ts, (mf, ms, msig)) = data.strategy_lengths();
let binder = bp
.axis("signals.trend.fast.length", tf)
.axis("signals.trend.slow.length", ts)
.axis("signals.momentum.fast.length", [mf])
.axis("signals.momentum.slow.length", [ms])
.axis("signals.momentum.signal.length", [msig])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("bias.scale", [0.5]);
let varying: HashSet<String> = binder.varying_axes().into_iter().collect();
binder
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip);
let mut h = bp
.bootstrap_with_cells(point)
.expect("grid points are kind-checked against param_space");
let sources = data.run_sources();
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, pip);
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 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`), bounded
/// into a directional (unsized) bias by `Bias`, then passed through the long-only
/// `LongOnly` gate. Three swept
/// knobs of three kinds — `ema.length` (i64), `bias.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(pip_size: f64) -> (
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 / bias.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(Bias::builder().named("bias")); // bias.scale
let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param)
let broker = g.add(SimBroker::builder(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("bias"), 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}` ×
/// `bias.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>, data: &DataSource) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
let bp = momentum_blueprint_with_sinks(pip).0;
let space = bp.param_space();
let binder = bp
.axis("ema.length", [5, 10])
.axis("bias.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(pip);
let mut h = bp
.bootstrap_with_cells(point)
.expect("grid points are kind-checked against param_space");
let sources = data.run_sources();
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, pip);
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")
}
/// The honest broker label for the dual-tap stage1-r harness: it runs a RiskExecutor
/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report
/// it (#132). Shared by the single run and the sweep so the two cannot drift.
fn stage1_r_broker_label(pip_size: f64) -> String {
format!("sim-optimal+risk-executor(pip_size={pip_size})")
}
/// `aura sweep --strategy stage1-r`: sweep the stage1-r harness over a fast×slow
/// SIGNAL grid (the stop + sizing stay fixed — see the cycle-0066 / #133 decision
/// log: risk_budget is R-invariant and bias.scale is sign-only under flat-1R, both
/// degenerate axes; the stop defines the R unit, so varying it would break
/// cross-member SQN comparability). Each member folds the dense R-record via
/// summarize_r, so its RunReport carries `r: Some(..)` and the family is rankable
/// by sqn / sqn_normalized / expectancy_r / net_expectancy_r. With `--trace`, each
/// member's equity / exposure / r_equity streams are persisted under
/// `runs/traces/<name>/<member_key>/` via `persist_traces_r` (mirroring
/// `momentum_sweep_family`), so a swept member is chartable.
/// The four griddable knobs of the stage1-r sweep as value lists (#137): the SMA fast
/// / slow lengths and the vol-stop length / `k`-multiplier. The family is the cartesian
/// product of the four lists; absent flags fall back to the historical defaults (fast
/// `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k `{2.0}`) so a no-flags sweep is
/// byte-identical to the pre-#137 family.
#[derive(Clone, Debug, PartialEq)]
struct Stage1RGrid {
fast: Vec<i64>,
slow: Vec<i64>,
stop_length: Vec<i64>,
stop_k: Vec<f64>,
channel: Vec<i64>,
window: Vec<i64>,
band_k: Vec<f64>,
}
impl Default for Stage1RGrid {
fn default() -> Self {
Self {
fast: vec![2, 3],
slow: vec![6, 12],
stop_length: vec![STAGE1_R_STOP_LENGTH],
stop_k: vec![STAGE1_R_STOP_K],
channel: vec![1920],
window: vec![1920],
band_k: vec![2.0],
}
}
}
/// The path-qualified `param_space()` suffix of the open vol-stop's EWMA length knob
/// (the `Ema` named `stop_length` in `risk_executor_vol_open`). The full slot name is
/// resolved at runtime from the live param-space by this suffix, so the composite path
/// prefix is never hand-synced.
const STOP_LENGTH_SUFFIX: &str = ".vol_stop.stop_length.length";
/// The path-qualified `param_space()` suffix of the open vol-stop's `k`-multiplier knob
/// (the `LinComb` named `stop_k`, whose single weight is `weights[0]`).
const STOP_K_SUFFIX: &str = ".vol_stop.stop_k.weights[0]";
/// The friendly manifest name for an open vol-stop slot, given its path-qualified
/// `param_space()` name: the two stop knobs render as `stop_length` / `stop_k` (their
/// pre-#137 manual manifest names), every other slot unchanged. Decoupling the manifest
/// name from the deep param-space path keeps the family record auditable (C18) while the
/// values flow through the real `.axis(..)` grid.
fn stage1_r_friendly_name(space_name: &str) -> String {
if space_name.ends_with(STOP_LENGTH_SUFFIX) {
"stop_length".to_string()
} else if space_name.ends_with(STOP_K_SUFFIX) {
"stop_k".to_string()
} else {
space_name.to_string()
}
}
fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
// a single throwaway floated build, only to resolve param_space (borrow) then
// seed the named axes (move) — its taps are never drained, the per-point run_one
// rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self,
// .axis() consumes self, so one build suffices. The vol-stop knobs are left OPEN
// (`stop_open = true`) so all four knobs — signal AND stop — grid through the one
// `.axis(..)` mechanism (#137).
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None);
let space = bp.param_space();
// resolve the open stop slots' exact path-qualified names from the live param-space
// (the composite prefix is never hand-synced — match by the stable suffix).
let stop_length_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(STOP_LENGTH_SUFFIX))
.expect("open stage1-r vol-stop exposes a stop_length axis");
let stop_k_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(STOP_K_SUFFIX))
.expect("open stage1-r vol-stop exposes a stop_k axis");
let binder = bp
.axis("fast.length", grid.fast.clone())
.axis("slow.length", grid.slow.clone())
.axis(&stop_length_axis, grid.stop_length.clone())
.axis(&stop_k_axis, grid.stop_k.clone());
// the varying axes drive the member key; translate the deep stop-axis names to the
// friendly `stop_length` / `stop_k` the manifest uses, so the key reads the same
// names whether a signal or a stop axis is what varies.
let varying: HashSet<String> = binder
.varying_axes()
.into_iter()
.map(|n| stage1_r_friendly_name(&n))
.collect();
binder
.sweep(|point| {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let reduce = trace.is_none();
let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, reduce, None)
.bootstrap_with_cells(point)
.expect("stage1-r grid points are kind-checked against param_space");
h.run(data.run_sources());
// record the swept knobs PLUS the fixed R-defining bias scale, matching the
// single run: a family member must be reproducible from its own manifest
// (C18). The stop knobs are now real swept axes (they appear in `point`), so
// they reach the manifest via `zip_params` — under their friendly
// `stop_length` / `stop_k` names so cross-member SQN comparability (C10) is
// auditable from the family record by the same names as before.
let mut named: Vec<(String, Scalar)> = zip_params(&space, point)
.into_iter()
.map(|(n, v)| (stage1_r_friendly_name(&n), v))
.collect();
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
let key = member_key(&named, &varying);
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
let metrics = if reduce {
// folded: GatedRecorder emits O(trades) R rows; each SeriesReducer
// emits one [last, max_drawdown, sign_flips] summary row.
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq
.try_iter()
.next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
.unwrap_or((0.0, 0.0));
let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &[]));
m
} else {
// trace path (--trace set): raw recorders, persist, summarize — today's code.
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
if let Some(name) = trace {
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]);
}
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
m.r = Some(summarize_r(&r_rows, &[]));
m
};
RunReport { manifest, metrics }
})
.expect("the stage1-r named grid matches the stage1-r param-space")
}
/// The param-space of the OPEN stage1-r blueprint (all four knobs free) — the kinds
/// the per-window `WindowRun::chosen_params` are read against (C7). Mirrors the
/// throwaway-build param_space resolution inside `stage1_r_sweep_family`.
fn stage1_r_space() -> Vec<ParamSpec> {
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None).param_space()
}
/// Windowed reduce-mode stage1-r sweep over `[from, to]` — the in-sample leg of the
/// stage1-r walk-forward. Identical grid/axis/fold logic to `stage1_r_sweep_family`,
/// but windowed (`windowed_sources`) and always folded (O(trades)/member): each
/// member's RunReport carries `metrics.r = Some(summarize_r(..))`, so the family is
/// rankable by an R metric.
fn stage1_r_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &Stage1RGrid) -> (SweepFamily, Option<Vec<usize>>) {
let pip = data.pip_size();
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None);
let space = bp.param_space();
let stop_length_axis = space.iter().map(|p| p.name.clone())
.find(|n| n.ends_with(STOP_LENGTH_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_length axis");
let stop_k_axis = space.iter().map(|p| p.name.clone())
.find(|n| n.ends_with(STOP_K_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_k axis");
bp
.axis("fast.length", grid.fast.clone())
.axis("slow.length", grid.slow.clone())
.axis(&stop_length_axis, grid.stop_length.clone())
.axis(&stop_k_axis, grid.stop_k.clone())
.sweep_with_lattice(|point| {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None)
.bootstrap_with_cells(point)
.expect("stage1-r grid points are kind-checked against param_space");
let sources = data.windowed_sources(from, to);
let window = window_of(&sources).expect("non-empty in-sample window");
h.run(sources);
let mut named: Vec<(String, Scalar)> = zip_params(&space, point).into_iter()
.map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect();
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq.try_iter().next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64())).unwrap_or((0.0, 0.0));
let bias_sign_flips = rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &[]));
RunReport { manifest, metrics: m }
})
.map(|(fam, lat)| (fam, Some(lat)))
.expect("the stage1-r named grid matches the stage1-r param-space")
}
/// Run the chosen stage1-r params over an OOS window; return the recorded pip-equity
/// segment (for stitching) and the OOS RunReport whose `metrics.r` carries both the
/// R metrics and the per-trade `trade_rs`. Non-reduce (raw recorders): one window's
/// curve is bounded, and `stitch` needs the full pip-equity series.
fn run_oos_r(
params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource,
) -> (Vec<(Timestamp, f64)>, RunReport) {
let pip = data.pip_size();
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, false, None);
let space = bp.param_space();
let mut h = bp.bootstrap_with_cells(params)
.expect("chosen params pre-validated by the in-sample GridSpace::new");
let sources = data.windowed_sources(from, to);
let window = window_of(&sources).expect("non-empty out-of-sample window");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
let mut named: Vec<(String, Scalar)> = zip_params(&space, params).into_iter()
.map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect();
named.push(("bias_scale".to_string(), Scalar::f64(0.5)));
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
if let Some(name) = trace {
persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows, &[]);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let mut metrics = summarize(&equity, &exposure);
metrics.r = Some(summarize_r(&r_rows, &[]));
(equity, RunReport { manifest, metrics })
}
/// `aura sweep --strategy stage1-breakout`: sweep the breakout harness over a channel ×
/// stop grid. One `channel` length drives BOTH rolling nodes (parameter-ganging, #61),
/// so the family iterates the cartesian product MANUALLY with a fully-bound graph per
/// point (compile_with_params(&[]) + Harness::bootstrap, like run_stage1_r) rather than
/// the open .axis/bootstrap_with_cells path. Each member folds the dense R-record via
/// summarize_r, so the family is rankable by sqn / expectancy_r / ... (parity with
/// stage1-r). With --trace, raw recorders persist the per-cycle streams.
fn stage1_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
let mut varying: HashSet<String> = HashSet::new();
if grid.channel.len() > 1 {
varying.insert("channel".to_string());
}
if grid.stop_length.len() > 1 {
varying.insert("stop_length".to_string());
}
if grid.stop_k.len() > 1 {
varying.insert("stop_k".to_string());
}
let mut points = Vec::new();
for &c in &grid.channel {
for &sl in &grid.stop_length {
for &sk in &grid.stop_k {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let reduce = trace.is_none();
let flat = stage1_breakout_graph(tx_eq, tx_ex, tx_r, tx_req, Some(c), sl, sk, reduce)
.compile_with_params(&[])
.expect("valid stage1-breakout blueprint");
let mut h = Harness::bootstrap(flat).expect("valid stage1-breakout harness");
h.run(data.run_sources());
let named: Vec<(String, Scalar)> = vec![
("channel".to_string(), Scalar::i64(c)),
("stop_length".to_string(), Scalar::i64(sl)),
("stop_k".to_string(), Scalar::f64(sk)),
];
let key = member_key(&named, &varying);
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
let metrics = if reduce {
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq
.try_iter()
.next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
.unwrap_or((0.0, 0.0));
let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &[]));
m
} else {
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
if let Some(name) = trace {
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]);
}
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
m.r = Some(summarize_r(&r_rows, &[]));
m
};
points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } });
}
}
}
SweepFamily { space: vec![], points }
}
fn stage1_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
let mut varying: HashSet<String> = HashSet::new();
if grid.window.len() > 1 {
varying.insert("window".to_string());
}
if grid.band_k.len() > 1 {
varying.insert("band_k".to_string());
}
if grid.stop_length.len() > 1 {
varying.insert("stop_length".to_string());
}
if grid.stop_k.len() > 1 {
varying.insert("stop_k".to_string());
}
let mut points = Vec::new();
for &n in &grid.window {
for &bk in &grid.band_k {
for &sl in &grid.stop_length {
for &sk in &grid.stop_k {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let reduce = trace.is_none();
let flat =
stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce)
.compile_with_params(&[])
.expect("valid stage1-meanrev blueprint");
let mut h = Harness::bootstrap(flat).expect("valid stage1-meanrev harness");
h.run(data.run_sources());
let named: Vec<(String, Scalar)> = vec![
("window".to_string(), Scalar::i64(n)),
("band_k".to_string(), Scalar::f64(bk)),
("stop_length".to_string(), Scalar::i64(sl)),
("stop_k".to_string(), Scalar::f64(sk)),
];
let key = member_key(&named, &varying);
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
let metrics = if reduce {
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq
.try_iter()
.next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
.unwrap_or((0.0, 0.0));
let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &[]));
m
} else {
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
if let Some(name) = trace {
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]);
}
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
m.r = Some(summarize_r(&r_rows, &[]));
m
};
points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } });
}
}
}
}
SweepFamily { space: vec![], points }
}
/// 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(None, &DataSource::Synthetic).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")
}
/// 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,
Stage1R,
Stage1Breakout,
Stage1MeanRev,
}
/// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is
/// the bare-best pick deflated for trials (#144, the default); `Plateau` argmaxes
/// the neighbourhood-smoothed surface instead (opt-in via `--select`).
#[derive(Clone, Copy)]
enum Selection {
Argmax,
Plateau(PlateauMode),
}
/// Parse a `--select` token: `argmax` | `plateau:mean` | `plateau:worst`. Unknown
/// tokens are a usage error (the caller maps `Err(())` to exit 2).
fn parse_select(s: &str) -> Result<Selection, ()> {
match s {
"argmax" => Ok(Selection::Argmax),
"plateau:mean" => Ok(Selection::Plateau(PlateauMode::Mean)),
"plateau:worst" => Ok(Selection::Plateau(PlateauMode::Worst)),
_ => Err(()),
}
}
impl Strategy {
/// The CLI `--strategy` token this variant parses from — the inverse of the
/// `parse_*_args` match arms. Used to echo the offending strategy in error
/// messages so they name the actual input.
fn cli_token(self) -> &'static str {
match self {
Strategy::SmaCross => "sma",
Strategy::Momentum => "momentum",
Strategy::Stage1R => "stage1-r",
Strategy::Stage1Breakout => "stage1-breakout",
Strategy::Stage1MeanRev => "stage1-meanrev",
}
}
}
/// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting
/// any item that fails to parse — the shared validator for the stage1-r grid flags
/// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed
/// `--fast 2,x` is the strict usage path, not a downstream panic. `str::split(',')`
/// always yields at least one item, and an empty item (`""`, or a trailing comma)
/// fails its per-item `parse`, so a non-empty result needs no separate empty-list
/// check — the empty/blank inputs are rejected by the per-item parse itself.
fn parse_csv_list<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
let items: Vec<&str> = s.split(',').collect();
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(item.parse::<T>().map_err(|_| ())?);
}
Ok(out)
}
/// Parse the `sweep` tail:
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]`.
/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura
/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms,
/// `i64`) require `--real` (there is no synthetic window knob). The four optional grid
/// flags (#137) carry comma-separated value lists that become the stage1-r sweep's
/// grid axes (sma fast/slow length, vol-stop length/`k`); an absent flag keeps the
/// historical default list. 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, both name flags, a window flag without `--real`, or a malformed
/// grid list rejects.
fn parse_sweep_args(
rest: &[&str],
) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> {
let usage = || "sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]".to_string();
let mut strategy = Strategy::SmaCross;
let mut name: Option<(String, bool)> = None; // (name, persist)
let mut real = RealWindowGrammar::default();
let mut grid = Stage1RGrid::default();
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
if real.accept(flag, value, &usage)? {
tail = t;
continue;
}
match *flag {
"--strategy" => {
strategy = match *value {
"sma" => Strategy::SmaCross,
"momentum" => Strategy::Momentum,
"stage1-r" => Strategy::Stage1R,
"stage1-breakout" => Strategy::Stage1Breakout,
"stage1-meanrev" => Strategy::Stage1MeanRev,
_ => 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)),
"--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?,
"--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?,
"--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?,
"--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?,
"--channel" => grid.channel = parse_csv_list(value).map_err(|()| usage())?,
"--window" => grid.window = parse_csv_list(value).map_err(|()| usage())?,
"--band-k" => grid.band_k = parse_csv_list(value).map_err(|()| usage())?,
_ => return Err(usage()),
}
tail = t;
}
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
Ok((strategy, name, persist, real.finish(&usage)?, grid))
}
/// Parse the `generalize` tail:
/// `[--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n>
/// --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>]`.
/// The candidate is a single cell: each grid knob takes exactly one value and all
/// four are required. `--real` is a comma list of >= 2 distinct non-empty symbols.
/// Pure (no I/O / exit) so the grammar is unit-testable.
#[allow(clippy::type_complexity)]
fn parse_generalize_args(
rest: &[&str],
) -> Result<(String, Vec<String>, Stage1RGrid, String, Option<i64>, Option<i64>), String> {
let usage = || "generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>]".to_string();
let mut symbols: Option<Vec<String>> = None;
let mut from_ms: Option<i64> = None;
let mut to_ms: Option<i64> = None;
let mut fast: Option<i64> = None;
let mut slow: Option<i64> = None;
let mut stop_length: Option<i64> = None;
let mut stop_k: Option<f64> = None;
let mut metric = "expectancy_r".to_string();
let mut name = "generalize".to_string();
// generalize validates ONE candidate cell, so each grid knob takes a single value;
// refuse a multi-value flag with a reason, not a bare usage dump (fieldtest friction).
let one = |value: &str| -> Result<(), String> {
let items: Vec<&str> = value.split(',').collect();
if items.len() != 1 || items[0].is_empty() {
return Err("generalize validates a single candidate: --fast / --slow / --stop-length / --stop-k each take exactly one value".to_string());
}
Ok(())
};
let knobs = || "generalize requires all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string();
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" => { if *value != "stage1-r" { return Err("generalize requires --strategy stage1-r (the candidate must produce R)".to_string()); } }
"--real" => {
let parts: Vec<String> = value.split(',').map(|s| s.to_string()).collect();
if parts.iter().any(|s| s.is_empty()) { return Err("generalize: --real takes a comma list of non-empty symbols (e.g. GER40,USDJPY)".to_string()); }
symbols = Some(parts);
}
"--from" => from_ms = Some(value.parse().map_err(|_| usage())?),
"--to" => to_ms = Some(value.parse().map_err(|_| usage())?),
"--fast" => { one(value)?; fast = Some(value.parse().map_err(|_| usage())?); }
"--slow" => { one(value)?; slow = Some(value.parse().map_err(|_| usage())?); }
"--stop-length" => { one(value)?; stop_length = Some(value.parse().map_err(|_| usage())?); }
"--stop-k" => { one(value)?; stop_k = Some(value.parse().map_err(|_| usage())?); }
"--metric" => metric = (*value).to_string(),
"--name" => name = (*value).to_string(),
_ => return Err(usage()),
}
tail = t;
}
let symbols = symbols
.ok_or_else(|| "generalize requires --real <SYM1,SYM2,...> — a comma list of two or more instruments".to_string())?;
if symbols.len() < 2 {
return Err(format!(
"generalize needs at least two instruments to compare across; got {} (--real takes a comma list of >=2 symbols)",
symbols.len()
));
}
// distinct: a duplicate would double-count one instrument in the floor / sign count
let mut seen = std::collections::HashSet::new();
if !symbols.iter().all(|s| seen.insert(s.clone())) {
return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string());
}
let grid = Stage1RGrid {
fast: vec![fast.ok_or_else(knobs)?],
slow: vec![slow.ok_or_else(knobs)?],
stop_length: vec![stop_length.ok_or_else(knobs)?],
stop_k: vec![stop_k.ok_or_else(knobs)?],
..Stage1RGrid::default()
};
Ok((name, symbols, grid, metric, from_ms, to_ms))
}
/// Parse the `walkforward` tail:
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>]`.
/// Defaults: SMA-cross, synthetic, name "walkforward", no persist. `--name`/`--trace`
/// mutually exclusive; `--from`/`--to` require `--real`. Pure (no I/O / exit).
fn parse_walkforward_args(
rest: &[&str],
) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid, Selection), String> {
let usage = || "walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>]".to_string();
let mut strategy = Strategy::SmaCross;
let mut name: Option<(String, bool)> = None;
let mut real = RealWindowGrammar::default();
let mut grid = Stage1RGrid::default();
let mut select = Selection::Argmax;
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
if real.accept(flag, value, &usage)? {
tail = t;
continue;
}
match *flag {
"--strategy" => {
strategy = match *value {
"sma" => Strategy::SmaCross,
"momentum" => Strategy::Momentum,
"stage1-r" => Strategy::Stage1R,
"stage1-breakout" => Strategy::Stage1Breakout,
"stage1-meanrev" => Strategy::Stage1MeanRev,
_ => 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)),
"--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?,
"--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?,
"--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?,
"--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?,
"--select" => select = parse_select(value).map_err(|()| usage())?,
_ => return Err(usage()),
}
tail = t;
}
let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false));
Ok((strategy, name, persist, real.finish(&usage)?, grid, select))
}
/// Render a family-member stdout line: the assigned `family_id` plus the embedded
/// `RunReport`. The report is emitted in its own declaration key order (manifest
/// leads with `commit`, C18) so the line is byte-identical to the stored
/// `families.jsonl`. `serde_json::json!` would route the report through a
/// `serde_json::Value` and re-alphabetize the manifest keys (broker-first),
/// diverging from the store — hence the report is spliced in pre-serialized (#99).
fn family_member_line(id: &str, report: &RunReport) -> String {
format!(
r#"{{"family_id":{},"report":{}}}"#,
serde_json::to_string(id).expect("a string id always serializes"),
report.to_json()
)
}
/// Monte-Carlo variant of [`family_member_line`]: the per-draw line also carries the
/// realization `seed` (between `family_id` and `report`), matching `run_mc`'s shape.
fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
format!(
r#"{{"family_id":{},"seed":{},"report":{}}}"#,
serde_json::to_string(id).expect("a string id always serializes"),
seed,
report.to_json()
)
}
/// `aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--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`, every strategy
/// (`sma`/`momentum`/`stage1-r`) persists each member's streams under
/// `runs/traces/<n>/<member_key>/` (opt-in); the `stage1-r` member also carries
/// the `r_equity` tap (via `persist_traces_r`).
fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &Stage1RGrid) {
if persist
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let reg = default_registry();
let family = match strategy {
Strategy::SmaCross => sweep_family(persist.then_some(name), &data),
Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data),
Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data, grid),
Strategy::Stage1Breakout => stage1_breakout_sweep_family(persist.then_some(name), &data, grid),
Strategy::Stage1MeanRev => stage1_meanrev_sweep_family(persist.then_some(name), &data, grid),
};
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
for pt in &family.points {
println!("{}", family_member_line(&id, &pt.report));
}
}
/// `aura generalize --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n>
/// --stop-k <f>`: grade one stage1-r candidate (a single-cell grid) across an
/// instrument list. Pre-checks the R-metric data-free (refuse a non-R / unknown
/// metric before any run), runs the candidate per instrument (stamping each report's
/// manifest `instrument`), reduces to the worst-case floor + sign-agreement +
/// per-instrument breakdown (`generalization`, C9/C10), prints the aggregate, and
/// persists the per-instrument members as a `CrossInstrument` family (C12/C18).
fn run_generalize(
name: &str,
symbols: &[String],
grid: &Stage1RGrid,
metric: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
) {
// data-free metric pre-check: refuse a non-R / unknown metric before any run.
if let Err(e) = check_r_metric(metric) {
eprintln!("aura: {e}");
std::process::exit(2);
}
let mut members: Vec<RunReport> = Vec::new();
for symbol in symbols {
let choice = DataChoice::Real { symbol: symbol.clone(), from_ms, to_ms };
let data = DataSource::from_choice(choice); // per-instrument pip_or_refuse + has_symbol
let family = stage1_r_sweep_family(None, &data, grid); // single-cell grid -> 1 member
let mut report = family.points[0].report.clone();
report.manifest.instrument = Some(symbol.clone());
members.push(report);
}
let pairs: Vec<(String, &RunReport)> = symbols.iter().cloned().zip(members.iter()).collect();
let agg = match generalization(&pairs, metric) {
Ok(a) => a,
Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); }
};
println!("{}", generalize_json(&agg));
let reg = default_registry();
match reg.append_family(name, FamilyKind::CrossInstrument, &members) {
Ok(id) => println!("{{\"family_id\":\"{id}\"}}"),
Err(e) => { eprintln!("aura: failed to persist family: {e}"); std::process::exit(1); }
}
}
/// `aura walkforward [--name <n>|--trace <n>]`: run a built-in rolling walk-forward
/// over the sample blueprint + a synthetic windowed source. Per window: sweep the
/// built-in grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3,
/// where aura-cli bridges engine + registry), run the chosen params out-of-sample.
/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`,
/// print each carrying the assigned id, then the stitched summary line. With
/// `--trace`, also persist each OOS member's streams under
/// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &Stage1RGrid, select: Selection) {
if persist
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let reg = default_registry();
let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select);
let id =
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
{
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
for w in &result.windows {
println!("{}", family_member_line(&id, &w.run.oos_report));
}
println!("{}", walkforward_summary_json(&result));
}
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
/// always known at the call sites, so a metric error is unreachable (`expect`); the
/// only fallible outcome is the plateau-without-lattice refusal, returned as
/// `Err(message)` for the caller to print and exit 2.
fn select_winner(
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
) -> Result<(SweepPoint, FamilySelection), String> {
match select {
Selection::Argmax => Ok(optimize_deflated(
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
).expect("walk-forward metrics are known")),
Selection::Plateau(mode) => match lattice {
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
.expect("walk-forward metrics are known")),
None => Err(
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
.to_string(),
),
},
}
}
/// The built-in rolling walk-forward: 24-bar in-sample, 12-bar out-of-sample,
/// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3
/// windows. Each window sweeps a grid in-sample, optimizes by a metric, and runs
/// the chosen params out-of-sample — both strategy-dispatched: the `SmaCross` arm
/// sweeps the SMA sample grid and optimizes by `total_pips` (axis 2); the
/// `Stage1R` arm sweeps the stage1-r grid and optimizes by `sqn_normalized`.
/// Other strategies have no walk-forward form yet (exit 2).
fn walkforward_family(
strategy: Strategy, trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid,
select: Selection,
) -> WalkForwardResult {
let span = data.wf_full_span();
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
std::process::exit(2);
}
};
match strategy {
Strategy::SmaCross => {
let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space();
walk_forward(roller, space, |w: WindowBounds| {
let (is_family, lattice) = sweep_over(w.is.0, w.is.1, data);
let (best, selection) = match select_winner(&is_family, "total_pips", select, lattice.as_deref()) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
let (oos_equity, mut oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data);
oos_report.manifest.selection = Some(selection);
WindowRun {
// The tag-free sweep winner is the chosen point; its kinds live on
// WalkForwardResult.space (computed once above from the same blueprint).
chosen_params: best.params,
oos_equity,
oos_report,
}
})
}
Strategy::Stage1R => {
let space = stage1_r_space();
walk_forward(roller, space, |w: WindowBounds| {
let (is_family, lattice) = stage1_r_sweep_over(w.is.0, w.is.1, data, grid);
let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, lattice.as_deref()) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
let (oos_equity, mut oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data);
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
})
}
other => {
eprintln!(
"aura: walkforward has no form for strategy '{}'",
other.cli_token()
);
std::process::exit(2);
}
}
}
/// Sweep the built-in named grid over an in-sample window, sourcing the in-memory
/// windowed stream. Mirrors `sweep_family`, but windowed by `[from, to]`.
fn sweep_over(from: Timestamp, to: Timestamp, data: &DataSource) -> (SweepFamily, Option<Vec<usize>>) {
let pip = data.pip_size();
let bp = sample_blueprint_with_sinks(pip).0;
let space = bp.param_space();
let (tf, ts, (mf, ms, msig)) = data.strategy_lengths();
bp.axis("signals.trend.fast.length", tf)
.axis("signals.trend.slow.length", ts)
.axis("signals.momentum.fast.length", [mf])
.axis("signals.momentum.slow.length", [ms])
.axis("signals.momentum.signal.length", [msig])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("bias.scale", [0.5])
.sweep_with_lattice(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip);
let mut h = bp
.bootstrap_with_cells(point)
.expect("grid points are kind-checked against param_space");
let sources = data.windowed_sources(from, to);
let window = window_of(&sources).expect("non-empty in-sample window");
h.run(sources);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, pip),
metrics: summarize(&equity, &exposure),
}
})
.map(|(fam, lat)| (fam, Some(lat)))
.expect("the built-in named grid matches the sample param-space")
}
/// Run the chosen params over an out-of-sample window; return the recorded
/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record).
fn run_oos(
params: &[Cell],
from: Timestamp,
to: Timestamp,
trace: Option<&str>,
data: &DataSource,
) -> (Vec<(Timestamp, f64)>, RunReport) {
let pip = data.pip_size();
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip);
let space = bp.param_space();
let mut h = bp
.bootstrap_with_cells(params)
.expect("chosen params pre-validated by the in-sample GridSpace::new");
let sources = data.windowed_sources(from, to);
let window = window_of(&sources).expect("non-empty out-of-sample window");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, pip);
if let Some(name) = trace {
persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let report = RunReport { manifest, metrics: summarize(&equity, &exposure) };
(equity, report)
}
/// Pool every OOS window's per-trade R series into one flat vector, in roll order
/// (window order, then within-window trade order). Windows with no `r` block
/// contribute nothing. The single home of the pooling-in-roll-order semantics —
/// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this.
fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
result
.windows
.iter()
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.trade_rs.clone()).unwrap_or_default())
.collect()
}
/// The walk-forward summary line: window count, stitched OOS total pips (the last
/// stitched-curve value), and the on-demand per-param stability. Canonical JSON
/// (C14).
fn walkforward_summary_json(result: &WalkForwardResult) -> String {
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0);
let pooled_rs = pooled_oos_trade_rs(result);
let mut obj = serde_json::json!({
"windows": result.windows.len(),
"stitched_total_pips": total,
"param_stability": param_stability(result),
});
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) {
// RMetrics serializes its scalar fields (trade_rs is serde-skipped, so the
// oos_r block is the clean R-metric summary of the pooled series).
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
.expect("RMetrics serializes");
}
serde_json::json!({ "walkforward": obj }).to_string()
}
/// The cross-instrument generalization line: the chosen metric, instrument count,
/// worst-case floor, sign-agreement count, and the per-instrument breakdown. Canonical
/// JSON (C14), mirroring `walkforward_summary_json`'s `{"generalize": obj}` shape.
fn generalize_json(agg: &Generalization) -> String {
let per: Vec<serde_json::Value> = agg
.per_instrument
.iter()
.map(|(sym, v)| serde_json::json!([sym, v]))
.collect();
let obj = serde_json::json!({
"metric": agg.selection_metric,
"n_instruments": agg.n_instruments,
"worst_case": agg.worst_case,
"sign_agreement": agg.sign_agreement,
"per_instrument": per,
});
serde_json::json!({ "generalize": obj }).to_string()
}
/// A longer deterministic stream than `showcase_prices` — enough for several
/// IS/OOS windows with SMA warm-up. Seed-determined via `SyntheticSpec` (C1).
fn walkforward_prices() -> Vec<(Timestamp, Scalar)> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
let mut src = spec.source(7);
let mut out = Vec::new();
while let Some(item) = aura_engine::Source::next(&mut src) {
out.push(item);
}
out
}
/// The in-memory windowed source the built-in demo uses (the firewall mapping to
/// `DataServer::stream_m1_windowed` is the real-data path; the demo stays in-memory,
/// mirroring `run_sweep`'s `showcase_prices`). Inclusive `[from, to]`.
fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
VecSource::new(
walkforward_prices()
.into_iter()
.filter(|&(t, _)| t >= from && t <= to)
.collect(),
)
}
/// Render the built-in walk-forward as the per-window OOS RunReport lines plus the
/// summary line — the `run_walkforward` shape minus registry persistence. Test
/// helper (mirrors `sweep_report`).
#[cfg(test)]
fn walkforward_report() -> String {
let result = walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default(), Selection::Argmax);
let mut out = String::new();
for w in &result.windows {
out.push_str(&w.run.oos_report.to_json());
out.push('\n');
}
out.push_str(&walkforward_summary_json(&result));
out.push('\n');
out
}
/// The built-in Monte-Carlo family: the sample harness over a fixed (empty) base
/// point, re-seeded across a built-in seed set — each seed a disjoint C1
/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`,
/// varying the *seed* rather than a tuning param. The seed -> `Source`
/// construction lives inside the per-draw closure (eager-agnostic, #71).
fn mc_family(trace: Option<&str>) -> McFamily {
let base_point: Vec<Scalar> = Vec::new();
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 };
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))];
let window = window_of(&sources).expect("non-empty synthetic stream");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(&format!("{name}/seed{seed}"), &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) }
})
}
/// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not
/// `Serialize` (only its `MetricStats` fields are), so the line is built from the
/// three per-metric stat blocks.
fn mc_aggregate_json(agg: &McAggregate) -> String {
serde_json::json!({
"mc_aggregate": {
"total_pips": agg.total_pips,
"max_drawdown": agg.max_drawdown,
"bias_sign_flips": agg.bias_sign_flips,
}
})
.to_string()
}
/// `aura mc [--name <n>|--trace <n>]`: run the built-in Monte-Carlo family, persist
/// it to the family store via `append_family` (C18/C21), print each draw's record
/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`,
/// also persist each draw's streams under `runs/traces/<n>/seed<seed>/` (opt-in).
fn run_mc(name: &str, persist: bool) {
if persist
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let reg = default_registry();
let family = mc_family(persist.then_some(name));
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
for draw in &family.draws {
println!("{}", mc_member_line(&id, draw.seed, &draw.report));
}
println!("{}", mc_aggregate_json(&family.aggregate));
}
/// Parsed form of the `mc` tail: either the synthetic seed-resweep (today's path,
/// preserved byte-for-byte) or the real-candidate R-bootstrap. The R path accepts
/// ONLY `--strategy stage1-r` (the sole R-reporting walk-forward strategy); a bare
/// `--real` without it is a usage error (the synthetic seed-resweep is undefined
/// over real bars).
// Parsed once at the dispatch boundary and immediately destructured into the run
// call; never stored or collected, so the inter-variant size gap is free here.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
enum McArgs {
Synthetic { name: String, persist: bool },
RealR { choice: DataChoice, grid: Stage1RGrid, block_len: usize, n_resamples: usize, seed: u64 },
}
fn parse_mc_args(rest: &[&str]) -> Result<McArgs, String> {
let usage = || "usage: mc [--name <n>|--trace <n>] | mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>]".to_string();
let mut strategy: Option<Strategy> = None;
let mut name: Option<(String, bool)> = None;
let mut real = RealWindowGrammar::default();
let mut grid = Stage1RGrid::default();
let mut block_len: usize = 1;
let mut n_resamples: usize = 1000;
let mut seed: u64 = 1;
let mut r_path = false;
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
if real.accept(flag, value, &usage)? {
r_path = true;
tail = t;
continue;
}
match *flag {
"--strategy" => {
strategy = Some(match *value {
"stage1-r" => Strategy::Stage1R,
_ => return Err(usage()),
});
r_path = true;
}
"--name" if name.is_none() => name = Some(((*value).to_string(), false)),
"--trace" if name.is_none() => name = Some(((*value).to_string(), true)),
"--fast" => { grid.fast = parse_csv_list(value).map_err(|()| usage())?; r_path = true; }
"--slow" => { grid.slow = parse_csv_list(value).map_err(|()| usage())?; r_path = true; }
"--stop-length" => { grid.stop_length = parse_csv_list(value).map_err(|()| usage())?; r_path = true; }
"--stop-k" => { grid.stop_k = parse_csv_list(value).map_err(|()| usage())?; r_path = true; }
"--block-len" => { block_len = value.parse().map_err(|_| usage())?; r_path = true; }
"--resamples" => { n_resamples = value.parse().map_err(|_| usage())?; r_path = true; }
"--seed" => { seed = value.parse().map_err(|_| usage())?; r_path = true; }
_ => return Err(usage()),
}
tail = t;
}
if r_path {
if strategy != Some(Strategy::Stage1R) {
return Err(usage());
}
if name.is_some() {
return Err(usage());
}
let choice = real.finish(&usage)?;
Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed })
} else {
let (name, persist) = name.unwrap_or_else(|| ("mc".to_string(), false));
Ok(McArgs::Synthetic { name, persist })
}
}
/// `aura mc --strategy stage1-r [--real <SYM>]`: run the stage1-r walk-forward, pool
/// every OOS window's per-trade R series in roll order, and print one moving-block
/// bootstrap `mc_r_bootstrap` line (`E[R]` distribution + P(`E[R]` <= 0)). Frictionless
/// Stage-1 (no costs); deterministic given `seed` (C1).
fn run_mc_r_bootstrap(data: DataSource, grid: &Stage1RGrid, block_len: usize, n_resamples: usize, seed: u64) {
println!("{}", mc_r_bootstrap_report(&data, grid, block_len, n_resamples, seed));
}
/// Assemble the `mc` R-bootstrap line: run the stage1-r walk-forward, pool the OOS
/// per-trade R series in roll order, bootstrap `E[R]`, and render the `mc_r_bootstrap`
/// line. The body of `run_mc_r_bootstrap` minus the `println!`, so the full real-R
/// wiring (walk-forward -> non-empty pooling -> bootstrap -> render) is reachable
/// over synthetic data in a `#[cfg(test)]` unit, mirroring `mc_report` /
/// `walkforward_report` / `sweep_report`.
fn mc_r_bootstrap_report(data: &DataSource, grid: &Stage1RGrid, block_len: usize, n_resamples: usize, seed: u64) -> String {
let result = walkforward_family(Strategy::Stage1R, None, data, grid, Selection::Argmax);
let pooled = pooled_oos_trade_rs(&result);
let boot = r_bootstrap(&pooled, n_resamples, block_len, seed);
mc_r_bootstrap_json(&boot)
}
/// Render an `RBootstrap` as one canonical JSON line (`MetricStats` serializes; the
/// scalar fields are spliced in), mirroring `mc_aggregate_json`.
fn mc_r_bootstrap_json(b: &RBootstrap) -> String {
serde_json::json!({
"mc_r_bootstrap": {
"n_trades": b.n_trades,
"block_len": b.block_len,
"n_resamples": b.n_resamples,
"e_r": b.e_r,
"prob_le_zero": b.prob_le_zero,
}
})
.to_string()
}
/// Render the built-in Monte-Carlo family as the per-draw `RunReport` lines plus
/// the aggregate line — the `run_mc` shape minus registry persistence (no
/// `family_id`, which is store-assigned). Test helper, mirroring `sweep_report` /
/// `walkforward_report`: it carries the C1-determinism test of the family
/// computation, separate from the store-dependent id.
#[cfg(test)]
fn mc_report() -> String {
let family = mc_family(None);
let mut out = String::new();
for draw in &family.draws {
out.push_str(&draw.report.to_json());
out.push('\n');
}
out.push_str(&mc_aggregate_json(&family.aggregate));
out.push('\n');
out
}
/// `aura runs families`: one header line per stored family (id, kind, member
/// count), in first-seen store order.
fn runs_families() {
let reg = default_registry();
let members = match reg.load_family_members() {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
for fam in group_families(members) {
println!(
"{}",
serde_json::json!({ "family_id": fam.id, "kind": fam.kind, "members": fam.members.len() })
);
}
}
/// `aura runs family <id> [rank <metric>]`: list one family's member reports in
/// ordinal order, or best-first by `metric`. An unknown id is an empty family
/// (prints nothing, exit 0); an unknown metric is a usage error (stderr + exit 2).
fn runs_family(id: &str, rank: Option<&str>) {
let reg = default_registry();
let members = match reg.load_family_members() {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else {
return; // unknown family id: empty, exit 0
};
let reports: Vec<RunReport> = family.members.iter().map(|m| m.report.clone()).collect();
let ordered = match rank {
Some(metric) => match rank_by(reports, metric) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
},
None => reports,
};
for report in &ordered {
println!("{}", report.to_json());
if let Some(sel) = &report.manifest.selection {
match sel.mode {
// `deflated_score` is `None` only on an Argmax record with no
// deflation run (report.rs); guard it, symmetric with the
// plateau branch, so a from-disk record cannot panic here. When
// present (the sole producer always stamps it), the bytes are
// unchanged.
SelectionMode::Argmax => if let Some(deflated) = sel.deflated_score {
match sel.overfit_probability {
Some(p) => println!(" deflated={deflated:.4} P(overfit)={p:.4}"),
None => println!(" deflated={deflated:.4}"),
}
},
SelectionMode::PlateauMean | SelectionMode::PlateauWorst => {
let label = if matches!(sel.mode, SelectionMode::PlateauMean) { "mean" } else { "worst" };
if let (Some(score), Some(n)) = (sel.neighbourhood_score, sel.n_neighbours) {
println!(" plateau({label})={score:.4} over {n} cells");
}
}
}
}
}
}
// --- 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 {
let mut g = GraphBuilder::new(name);
let fast = g.add(Ema::builder().named("fast")); // fast EMA
let slow = g.add(Ema::builder().named("slow")); // slow EMA
let line = g.add(Sub::builder()); // MACD line = fast slow
let signal = g.add(Ema::builder().named("signal")); // signal EMA of the MACD line
let hist = g.add(Sub::builder()); // histogram = MACD line signal
let price = g.input_role("price");
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), line.input("lhs")); // fast → line
g.connect(slow.output("value"), line.input("rhs")); // slow → line
g.connect(line.output("value"), signal.input("series")); // line → signal EMA
g.connect(line.output("value"), hist.input("lhs")); // line → histogram
g.connect(signal.output("value"), hist.input("rhs")); // signal → histogram
g.expose(line.output("value"), "macd"); // the MACD line
g.expose(signal.output("value"), "signal"); // the signal line
g.expose(hist.output("value"), "histogram"); // the histogram
g.build().expect("sample macd wiring resolves")
}
/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Bias` →
/// `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 {
let mut g = GraphBuilder::new("macd_strategy");
let macd_node = g.add(macd("macd"));
let exposure = g.add(Bias::builder().named("bias"));
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, [macd_node.input("price"), broker.input("price")]);
g.connect(macd_node.output("histogram"), exposure.input("signal")); // histogram → Bias
g.connect(exposure.output("bias"), broker.input("exposure")); // bias → broker slot 0
g.connect(broker.output("equity"), eq.input("col[0]")); // equity → sink
g.connect(exposure.output("bias"), ex.input("col[0]")); // bias → sink
g.build().expect("macd_strategy wiring resolves")
}
/// 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(trace: Option<&str>) -> RunReport {
if let Some(n) = trace
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
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 sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(macd_prices()))];
let window = window_of(&sources).expect("non-empty macd stream");
h.run(sources);
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 manifest = sim_optimal_manifest(
vec![
("ema_fast".to_string(), Scalar::i64(2)),
("ema_slow".to_string(), Scalar::i64(4)),
("ema_signal".to_string(), Scalar::i64(3)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
// --- Stage-1 R harness (the SMA-cross signal scored in R) --------------------
/// The stage1-r vol-stop EWMA length (cycles). Single source for the `StopRule::Vol`
/// the blueprint embeds and the `stop` param the manifest records — kept honest by one
/// constant instead of a hand-synced literal across the function boundary.
const STAGE1_R_STOP_LENGTH: i64 = 3;
/// The stage1-r vol-stop multiplier (1R = `k`·σ). Single source for the embedded
/// `StopRule::Vol` and its manifest record, like [`STAGE1_R_STOP_LENGTH`].
const STAGE1_R_STOP_K: f64 = 2.0;
/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1
/// close bars for a vetted symbol over an optional window.
enum RunData {
Synthetic,
Real { symbol: String, from: Option<i64>, to: Option<i64> },
}
/// A rise-fall-rise synthetic stream for the stage1-r smoke run: long enough to warm
/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the
/// RiskExecutor opens and closes at least one trade. Deterministic (C1).
fn stage1_r_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()
}
/// Interned `col[i]` recorder-port names, built once. The `GraphBuilder::input` API
/// wants `&'static str`; interning here (instead of `format!(...).leak()` per field)
/// means reusing the stage1-r harness in a sweep / Monte-Carlo loop reuses these
/// strings rather than leaking 14 fresh ones per build (#132).
static COL_PORTS: LazyLock<Vec<String>> =
LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect());
/// The Stage-1 R harness topology, shared by the single run and the sweep. The two
/// signal knobs are bound when `Some` (single run — identical to the old
/// build-time bind, output byte-unchanged) and left free when `None` (sweep — they
/// land in `param_space` as `fast.length` / `slow.length`). The vol-stop knobs are
/// bound to the pinned constants when `stop_open` is `false` (single run + the
/// default sweep, output byte-unchanged) and left free when `true` (a gridded sweep
/// — they land in `param_space` under the `.vol_stop.stop_length.length` /
/// `.vol_stop.stop_k.weights[0]` suffixes). Four taps: equity (SimBroker), exposure
/// (Bias), the 14-column R-record (→ summarize_r), and r_equity = cum_realized_r +
/// unrealized_r.
///
/// The seven-arg signature is a conscious keep, not an oversight: the four `tx_*`
/// are the per-tap recorder channels (one Recorder edge each — equity, exposure,
/// the R-record, r_equity), `fast_len`/`slow_len` are the two floatable signal
/// knobs, and `stop_open` selects the bound-vs-open vol-stop arm. A sender bundle
/// would only rename the same four channels into one struct field without removing
/// an edge, trading the lint for indirection; the `too_many_arguments` allow is
/// preferred over that churn.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn stage1_r_graph(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
fast_len: Option<i64>,
slow_len: Option<i64>,
stop_open: bool,
reduce: bool,
cost: Option<(f64, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
) -> Composite {
let mut g = GraphBuilder::new("stage1_r");
// SMA-cross signal → Bias (the same signal the pip sample uses).
let mut fast_b = Sma::builder().named("fast");
if let Some(l) = fast_len {
fast_b = fast_b.bind("length", Scalar::i64(l));
}
let fast = g.add(fast_b);
let mut slow_b = Sma::builder().named("slow");
if let Some(l) = slow_len {
slow_b = slow_b.bind("length", Scalar::i64(l));
}
let slow = g.add(slow_b);
let spread = g.add(Sub::builder());
let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5)));
// pip branch (verbatim from sample_blueprint_with_sinks).
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
// R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop knobs
// are bound to the pinned constants (default) or left open as sweep axes (`stop_open`).
// In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex
// f64 series to one summary row, GatedRecorder retains only the gated R rows — the
// O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the
// `--trace` path, where the full per-cycle series is persisted.
let gate_col = PM_FIELD_NAMES
.iter()
.position(|&n| n == "closed_this_cycle")
.expect("PM record has a closed_this_cycle column");
let eq = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
};
let ex = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
};
let exec = g.add(if stop_open {
risk_executor_vol_open(1.0)
} else {
risk_executor(StopRule::Vol { length: STAGE1_R_STOP_LENGTH, k: STAGE1_R_STOP_K }, 1.0)
});
let rrec = if reduce {
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
} else {
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
};
let price = g.source_role("price", ScalarKind::F64);
g.feed(
price,
[fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")],
);
g.connect(fast.output("value"), spread.input("lhs"));
g.connect(slow.output("value"), spread.input("rhs"));
g.connect(spread.output("value"), exposure.input("signal"));
g.connect(exposure.output("bias"), broker.input("exposure"));
g.connect(exposure.output("bias"), ex.input("col[0]"));
g.connect(exposure.output("bias"), exec.input("bias"));
g.connect(broker.output("equity"), eq.input("col[0]"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
}
if !reduce {
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
let r_equity = g.add(
LinComb::builder(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
if let Some((cost_per_trade, tx_net, tx_cost)) = cost {
let cost_node = g.add(
ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cost_per_trade)),
);
g.connect(exec.output("closed_this_cycle"), cost_node.input("closed"));
g.connect(exec.output("open"), cost_node.input("open"));
g.connect(exec.output("entry_price"), cost_node.input("entry_price"));
g.connect(exec.output("stop_price"), cost_node.input("stop_price"));
// net_r_equity = cum_realized_r + unrealized_r - cum_cost_in_r - open_cost_in_r
let net_eq = g.add(
LinComb::builder(4)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0))
.bind("weights[2]", Scalar::f64(-1.0))
.bind("weights[3]", Scalar::f64(-1.0)),
);
g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]"));
g.connect(exec.output("unrealized_r"), net_eq.input("term[1]"));
g.connect(cost_node.output("cum_cost_in_r"), net_eq.input("term[2]"));
g.connect(cost_node.output("open_cost_in_r"), net_eq.input("term[3]"));
let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_net));
g.connect(net_eq.output("value"), net_rec.input("col[0]"));
// The cost stream `summarize_r` folds: the `ConstantCost` node's full
// [cost_in_r, cum_cost_in_r, open_cost_in_r] schema. `summarize_r`'s `cost_col`
// reads col 0 (per-close) and col 2 (window-end open) — the lockstep contract
// with aura-std's ConstantCost output — so the stream is recorded 3-wide.
let cost_rec = g.add(Recorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
Firing::Any,
tx_cost,
));
g.connect(cost_node.output("cost_in_r"), cost_rec.input("col[0]"));
g.connect(cost_node.output("cum_cost_in_r"), cost_rec.input("col[1]"));
g.connect(cost_node.output("open_cost_in_r"), cost_rec.input("col[2]"));
}
}
g.build().expect("stage1_r wiring resolves")
}
/// The stage1-r harness with its signal leg swapped for a Donchian channel breakout:
/// `close -> Delay(1) -> {RollingMax,RollingMin}(channel) -> {Gt,Gt} -> {Latch,Latch}
/// -> Sub = bias in {-1,0,+1}`. The one Delay(1) on close feeds both rolling nodes, so
/// each channel covers `close[t-N..t-1]` (the C2 guard: the current bar is excluded).
/// `channel = None` leaves the lengths open (unused today; the family binds them); the
/// stop is bound (defines R), identical downstream to stage1_r_graph.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn stage1_breakout_graph(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
channel: Option<i64>,
stop_length: i64,
stop_k: f64,
reduce: bool,
) -> Composite {
let mut g = GraphBuilder::new("stage1_breakout");
// Donchian breakout signal leg.
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let mut mx_b = RollingMax::builder().named("channel_hi");
let mut mn_b = RollingMin::builder().named("channel_lo");
if let Some(n) = channel {
mx_b = mx_b.bind("length", Scalar::i64(n));
mn_b = mn_b.bind("length", Scalar::i64(n));
}
let mx = g.add(mx_b);
let mn = g.add(mn_b);
let gt_up = g.add(Gt::builder());
let gt_down = g.add(Gt::builder());
let up_latch = g.add(Latch::builder());
let down_latch = g.add(Latch::builder());
let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1}
// pip branch (verbatim from stage1_r_graph).
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
let gate_col = PM_FIELD_NAMES
.iter()
.position(|&n| n == "closed_this_cycle")
.expect("PM record has a closed_this_cycle column");
let eq = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
};
let ex = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
};
let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0));
let rrec = if reduce {
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
} else {
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
};
let price = g.source_role("price", ScalarKind::F64);
g.feed(
price,
[
delay.input("series"),
gt_up.input("a"),
gt_down.input("b"),
broker.input("price"),
exec.input("price"),
],
);
g.connect(delay.output("value"), mx.input("series"));
g.connect(delay.output("value"), mn.input("series"));
g.connect(mx.output("value"), gt_up.input("b"));
g.connect(mn.output("value"), gt_down.input("a"));
g.connect(gt_up.output("value"), up_latch.input("set"));
g.connect(gt_down.output("value"), up_latch.input("reset"));
g.connect(gt_down.output("value"), down_latch.input("set"));
g.connect(gt_up.output("value"), down_latch.input("reset"));
g.connect(up_latch.output("value"), exposure.input("lhs"));
g.connect(down_latch.output("value"), exposure.input("rhs"));
g.connect(exposure.output("value"), broker.input("exposure"));
g.connect(exposure.output("value"), ex.input("col[0]"));
g.connect(exposure.output("value"), exec.input("bias"));
g.connect(broker.output("equity"), eq.input("col[0]"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
}
if !reduce {
let r_equity = g.add(
LinComb::builder(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
}
g.build().expect("stage1_breakout wiring resolves")
}
/// The Stage-1 EWMA Bollinger-band mean-reversion candidate, mirroring
/// `stage1_breakout_graph` but swapping the signal leg: fade deviation from a
/// rolling mean (price above `mean + k*sigma` -> short; below `mean - k*sigma` ->
/// long), latched +-1. sigma = `Sqrt(Ema((price-mean)^2))` (deviation squared
/// then smoothed, the vol_stop shape — no catastrophic cancellation, no NaN).
/// No Delay: the current bar legitimately belongs to its own band (causal, C2).
/// `window` gangs the mean Ema and the variance Ema (one Bollinger window);
/// `band_k` is the band half-width in sigma. Everything below the signal leg is
/// byte-identical to `stage1_breakout_graph`.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn stage1_meanrev_graph(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
window: Option<i64>,
band_k: f64,
stop_length: i64,
stop_k: f64,
reduce: bool,
) -> Composite {
let mut g = GraphBuilder::new("stage1_meanrev");
// EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout).
let (mut mean_b, mut var_b) =
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
if let Some(n) = window {
mean_b = mean_b.bind("length", Scalar::i64(n));
var_b = var_b.bind("length", Scalar::i64(n));
}
let mean = g.add(mean_b);
let dev = g.add(Sub::builder()); // price - mean
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(var_b); // EWMA variance
let sigma = g.add(Sqrt::builder()); // sigma (price units)
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k*sigma
let upper = g.add(Add::builder()); // mean + k*sigma
let lower = g.add(Sub::builder()); // mean - k*sigma
let gt_hi = g.add(Gt::builder()); // price > upper -> overextended up -> fade short
let gt_lo = g.add(Gt::builder()); // lower > price -> overextended down -> fade long
let short_latch = g.add(Latch::builder());
let long_latch = g.add(Latch::builder());
let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias in {-1,0,+1}
// pip branch (VERBATIM from stage1_breakout_graph).
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
let gate_col = PM_FIELD_NAMES
.iter()
.position(|&n| n == "closed_this_cycle")
.expect("PM record has a closed_this_cycle column");
let eq = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
};
let ex = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
};
let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0));
let rrec = if reduce {
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
} else {
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
};
let price = g.source_role("price", ScalarKind::F64);
g.feed(
price,
[
mean.input("series"),
dev.input("lhs"),
gt_hi.input("a"),
gt_lo.input("b"),
broker.input("price"),
exec.input("price"),
],
);
g.connect(mean.output("value"), dev.input("rhs"));
g.connect(dev.output("value"), sq.input("lhs"));
g.connect(dev.output("value"), sq.input("rhs")); // square: feed dev to both legs
g.connect(sq.output("value"), var.input("series"));
g.connect(var.output("value"), sigma.input("value"));
g.connect(sigma.output("value"), band.input("term[0]"));
g.connect(mean.output("value"), upper.input("lhs"));
g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma
g.connect(mean.output("value"), lower.input("lhs"));
g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma
g.connect(upper.output("value"), gt_hi.input("b"));
g.connect(lower.output("value"), gt_lo.input("a"));
g.connect(gt_hi.output("value"), short_latch.input("set"));
g.connect(gt_lo.output("value"), short_latch.input("reset"));
g.connect(gt_lo.output("value"), long_latch.input("set"));
g.connect(gt_hi.output("value"), long_latch.input("reset"));
g.connect(long_latch.output("value"), exposure.input("lhs"));
g.connect(short_latch.output("value"), exposure.input("rhs"));
g.connect(exposure.output("value"), broker.input("exposure"));
g.connect(exposure.output("value"), ex.input("col[0]"));
g.connect(exposure.output("value"), exec.input("bias"));
g.connect(broker.output("equity"), eq.input("col[0]"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
}
if !reduce {
let r_equity = g.add(
LinComb::builder(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
}
g.build().expect("stage1_meanrev wiring resolves")
}
/// `aura run --harness stage1-r [--real <SYM> [--from][--to]] [--trace <n>]`: build the
/// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via
/// `summarize` and the dense R-record via `summarize_r`, and attach the R block as
/// `RunMetrics.r = Some(..)`. Pure/deterministic (C1). `cost` is the optional flat
/// round-trip cost per trade (price units): `Some(c)` wires the `ConstantCost` node and
/// the `net_r_equity` tap and folds the cost stream into `net_expectancy_r`; `None` is the
/// frictionless gross-R baseline (net == gross, no extra tap, byte-unchanged).
fn run_stage1_r(data: RunData, trace: Option<&str>, cost: Option<f64>) -> RunReport {
if let Some(n) = trace
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
}
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel();
let (tx_cost, rx_cost) = mpsc::channel();
let cost_bundle = cost.map(|c| (c, tx_net, tx_cost));
let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4), false, false, cost_bundle)
.compile_with_params(&[])
.expect("valid stage1-r blueprint");
let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness");
let (sources, window, pip_size): (Vec<Box<dyn aura_engine::Source>>, _, f64) = match &data {
RunData::Synthetic => {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(stage1_r_prices()))];
let window = window_of(&sources).expect("non-empty synthetic stream");
(sources, window, SYNTHETIC_PIP_SIZE)
}
RunData::Real { symbol, from, to } => {
let (source, window, pip_size) = open_real_source(symbol, *from, *to);
(vec![source], window, pip_size)
}
};
h.run(sources);
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
// The cost taps drain empty when `cost` is `None` (no cost node wired): an empty
// `net_rows` suppresses the `net_r_equity` trace and an empty `cost_rows` folds to
// net == gross — the byte-unchanged frictionless baseline.
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let mut manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
("stop_length".to_string(), Scalar::i64(STAGE1_R_STOP_LENGTH)), // vol_stop EWMA length
("stop_k".to_string(), Scalar::f64(STAGE1_R_STOP_K)), // vol_stop multiplier (1R = k·σ)
],
window,
0,
pip_size,
);
manifest.broker = stage1_r_broker_label(pip_size);
if let Some(name) = trace {
persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows, &net_rows);
}
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &cost_rows));
RunReport { manifest, metrics }
}
/// Persist a stage1-r run's taps: equity (off the SimBroker), exposure (off the Bias),
/// r_equity = cum_realized_r + unrealized_r (off the RiskExecutor), and — only on a cost
/// run — net_r_equity (gross r_equity minus the cost-in-R taps). Separate from the two-tap
/// `persist_traces` so the pip handlers stay byte-unchanged on disk; the `net_r_equity` tap
/// is emitted only when `net_rows` is non-empty, so a no-cost run's on-disk trace set is
/// byte-unchanged too.
fn persist_traces_r(
name: &str,
manifest: &RunManifest,
eq_rows: &[(Timestamp, Vec<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)],
req_rows: &[(Timestamp, Vec<Scalar>)],
net_rows: &[(Timestamp, Vec<Scalar>)],
) {
let mut taps = vec![
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
ColumnarTrace::from_rows("r_equity", &[ScalarKind::F64], req_rows),
];
if !net_rows.is_empty() {
taps.push(ColumnarTrace::from_rows("net_r_equity", &[ScalarKind::F64], net_rows));
}
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(2);
}
}
/// Which built-in harness `aura run` drives. A fixed compile-time enumeration over
/// Rust-authored harnesses — NOT a runtime node registry and NOT a DSL (C9/C17): the
/// CLI *runs* an authored harness, it does not wire one.
enum HarnessKind {
Sma,
Macd,
Stage1R,
}
/// The parsed `aura run` invocation: a harness, a data source, an optional trace name, and
/// an optional flat round-trip cost per trade (`--cost-per-trade`, stage1-r only this cycle).
struct RunArgs {
harness: HarnessKind,
data: RunData,
trace: Option<String>,
cost: Option<f64>,
}
/// Parse the `run` tail: `[--harness <name>] [--real <SYM> [--from <ms>] [--to <ms>]]
/// [--trace <name>] [--cost-per-trade <f64>]` in any order, each flag at most once.
/// `--harness sma` is the default (`--harness macd` / `--harness stage1-r` select the
/// others); `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the
/// grammar is unit-testable; `main` does the side effects.
fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
let usage = || {
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>]"
.to_string()
};
let mut harness: Option<HarnessKind> = None;
let mut symbol: Option<String> = None;
let mut from: Option<i64> = None;
let mut to: Option<i64> = None;
let mut trace: Option<String> = None;
let mut cost: Option<f64> = None;
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
match *flag {
"--harness" if harness.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
harness = Some(match *value {
"sma" => HarnessKind::Sma,
"macd" => HarnessKind::Macd,
"stage1-r" => HarnessKind::Stage1R,
_ => return Err(usage()),
});
tail = t;
}
"--real" if symbol.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
if value.is_empty() {
return Err(usage());
}
symbol = Some((*value).to_string());
tail = t;
}
"--from" if from.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
from = Some(value.parse().map_err(|_| usage())?);
tail = t;
}
"--to" if to.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
to = Some(value.parse().map_err(|_| usage())?);
tail = t;
}
"--trace" if trace.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
trace = Some((*value).to_string());
tail = t;
}
"--cost-per-trade" if cost.is_none() => {
let (value, t) = t.split_first().ok_or_else(usage)?;
cost = Some(value.parse().map_err(|_| usage())?);
tail = t;
}
_ => return Err(usage()),
}
}
let harness = harness.unwrap_or(HarnessKind::Sma);
// --from/--to require --real.
if symbol.is_none() && (from.is_some() || to.is_some()) {
return Err(usage());
}
let data = match symbol {
Some(s) => RunData::Real { symbol: s, from, to },
None => RunData::Synthetic,
};
Ok(RunArgs { harness, data, trace, cost })
}
/// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS
/// the selector — the harnesses are Rust-authored built-ins, picked by name (C9/C17).
fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
let trace = args.trace.as_deref();
Ok(match (args.harness, args.data) {
(HarnessKind::Sma, RunData::Synthetic) => run_sample(trace),
(HarnessKind::Macd, RunData::Synthetic) => run_macd(trace),
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost),
(HarnessKind::Sma, RunData::Real { symbol, from, to }) => {
run_sample_real(&symbol, from, to, trace)
}
(HarnessKind::Macd, RunData::Real { .. }) => {
return Err("the macd harness has no --real form".to_string())
}
})
}
const USAGE: &str =
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
fn main() {
// Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN
// at startup, so a write to a closed stdout pipe (`aura sweep | head`, a closed UI
// pane) returns EPIPE and panics in `println!` instead of terminating quietly on
// SIGPIPE — the conventional Unix CLI behaviour. One reset covers every
// family-emitting subcommand at once.
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// 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();
// A `--help` / `-h` anywhere prints usage to stdout and exits 0, uniformly across
// subcommands (#131): `aura chart --help` shows usage instead of erroring
// "unexpected argument", and the bare `aura --help` is the same affordance.
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("{USAGE}");
return;
}
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run", rest @ ..] => match parse_run_args(rest) {
Ok(args) => match run_dispatch(args) {
Ok(report) => println!("{}", report.to_json()),
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["chart", rest @ ..] => match parse_chart_args(rest) {
Ok((name, tap, mode)) => emit_chart(&name, tap.as_deref(), mode),
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep", rest @ ..] => match parse_sweep_args(rest) {
Ok((strategy, name, persist, choice, grid)) => {
run_sweep(strategy, &name, persist, DataSource::from_choice(choice), &grid)
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["walkforward", rest @ ..] => match parse_walkforward_args(rest) {
Ok((strategy, name, persist, choice, grid, select)) => {
run_walkforward(strategy, &name, persist, DataSource::from_choice(choice), &grid, select)
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["generalize", rest @ ..] => match parse_generalize_args(rest) {
Ok((name, symbols, grid, metric, from_ms, to_ms)) => {
run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms)
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["mc", rest @ ..] => match parse_mc_args(rest) {
Ok(McArgs::Synthetic { name, persist }) => run_mc(&name, persist),
Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed }) => {
run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed)
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["runs", "families"] => runs_families(),
["runs", "family", id] => runs_family(id, None),
["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_winner_refuses_plateau_without_a_lattice() {
// A plateau request with no lattice (a random sweep would yield None) is
// refused, never silently argmaxed. The refuse short-circuits before the
// family is read, so an empty family is fine here.
let fam = SweepFamily { space: vec![], points: vec![] };
let err = select_winner(&fam, "total_pips", Selection::Plateau(PlateauMode::Mean), None)
.unwrap_err();
assert!(err.contains("requires a grid sweep"), "refuse message: {err}");
}
#[test]
fn parse_walkforward_select_flag() {
let argmax = parse_walkforward_args(&["--strategy", "stage1-r"]).unwrap();
assert!(matches!(argmax.5, Selection::Argmax), "default is argmax");
let mean = parse_walkforward_args(&["--select", "plateau:mean"]).unwrap();
assert!(matches!(mean.5, Selection::Plateau(PlateauMode::Mean)));
let worst = parse_walkforward_args(&["--select", "plateau:worst"]).unwrap();
assert!(matches!(worst.5, Selection::Plateau(PlateauMode::Worst)));
assert!(parse_walkforward_args(&["--select", "bogus"]).is_err(),
"unknown --select token is a usage error");
}
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember {
cmp_member_win(key, ts, vals, (0, 0))
}
/// Like [`cmp_member`] but with an explicit manifest `window` so a test can
/// model walk-forward members (disjoint per-member OOS windows) and assert the
/// family window spans them.
fn cmp_member_win(key: &str, ts: &[i64], vals: &[f64], window: (i64, i64)) -> FamilyMember {
let rows: Vec<(Timestamp, Vec<Scalar>)> =
ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect();
let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
FamilyMember {
key: key.to_string(),
traces: RunTraces {
manifest: sim_optimal_manifest(
vec![],
(Timestamp(window.0), Timestamp(window.1)),
0,
1.0,
),
taps: vec![tap],
},
}
}
#[test]
fn comparison_overlays_one_shared_scale_series_per_member() {
let members = vec![
cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]),
cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]),
];
let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");
assert_eq!(data.xs, vec![1, 2, 3]);
assert_eq!(data.series.len(), 2);
assert_eq!(data.series[0].name, "a");
assert_eq!(data.series[1].name, "b");
// ONE shared y-scale across members (same quantity).
assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id);
// shared ts -> dense, no nulls.
assert!(data.series[0].points.iter().all(Option::is_some));
// #102 meta wiring: a family carries kind/name/member-count + the one
// compared tap, and never the per-member params (those are the labels).
assert_eq!(data.meta.kind, "family");
assert_eq!(data.meta.name, "fam");
assert_eq!(data.meta.members, Some(2));
assert_eq!(data.meta.taps, vec!["equity".to_string()]);
assert!(data.meta.params.is_empty(), "family meta must not repeat per-member params");
}
#[test]
fn comparison_disjoint_members_are_null_complementary() {
let members = vec![
cmp_member("oos1", &[1, 2], &[10.0, 11.0]),
cmp_member("oos2", &[3, 4], &[20.0, 21.0]),
];
let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");
assert_eq!(data.xs, vec![1, 2, 3, 4]);
assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]);
assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]);
}
/// #102 family-window semantics: the header's `window` for a family is the
/// SPAN across all members — `(min member.from, max member.to)` — not the first
/// member's window. The distinction is load-bearing for a walk-forward family,
/// whose members are DISJOINT OOS windows (commit 4c64feb): labelling such a
/// family with `members[0]`'s window mislabels the family's true coverage. The
/// span reading is correct for all three kinds (sweep/MC members share a window,
/// so their span collapses to that shared window).
#[test]
fn comparison_window_spans_disjoint_walk_forward_members() {
let members = vec![
cmp_member_win("oos1", &[10, 20], &[1.0, 2.0], (10, 20)),
cmp_member_win("oos2", &[30, 40], &[3.0, 4.0], (30, 40)),
cmp_member_win("oos3", &[50, 60], &[5.0, 6.0], (50, 60)),
];
let data = build_comparison_chart_data("wf", &members, "equity").expect("builds");
// SPAN of all OOS windows (10..60), NOT members[0]'s window (10..20).
assert_eq!(data.meta.window, (10, 60));
}
#[test]
fn comparison_errors_when_no_member_has_the_tap() {
let members = vec![cmp_member("a", &[1], &[1.0])];
assert!(build_comparison_chart_data("fam", &members, "nosuch").is_err());
}
#[test]
fn decimate_bounds_the_spine_to_twice_the_bucket_count() {
let n = 10_000usize;
let xs: Vec<i64> = (0..n as i64).collect();
let points: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64)).collect();
let data = ChartData {
xs,
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }],
meta: ChartMeta::default(),
};
let out = decimate(data, 2000);
assert!(out.xs.len() <= 4000, "spine not bounded: {}", out.xs.len());
assert_eq!(out.xs.len(), out.series[0].points.len(), "xs and points must stay aligned");
}
#[test]
fn decimate_preserves_per_bucket_min_and_max() {
// 10 points, 2 buckets -> bucket 0 = idx 0..5 (a spike), bucket 1 = idx 5..10 (a trough).
let xs: Vec<i64> = (0..10).collect();
let mut pv = vec![1.0_f64; 10];
pv[3] = 999.0;
pv[7] = -50.0;
let points: Vec<Option<f64>> = pv.into_iter().map(Some).collect();
let data = ChartData {
xs,
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }],
meta: ChartMeta::default(),
};
let out = decimate(data, 2);
let got = out.series[0].points.clone();
assert!(got.contains(&Some(999.0)), "bucket max (spike) dropped: {got:?}");
assert!(got.contains(&Some(-50.0)), "bucket min (trough) dropped: {got:?}");
}
#[test]
fn decimate_keeps_an_all_null_bucket_null() {
let xs: Vec<i64> = (0..10).collect();
let mut points: Vec<Option<f64>> = (0..5).map(|i| Some(i as f64)).collect();
points.extend(std::iter::repeat_n(None, 5));
let data = ChartData {
xs,
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }],
meta: ChartMeta::default(),
};
let out = decimate(data, 2);
assert_eq!(*out.series[0].points.last().unwrap(), None, "all-null bucket must stay null");
}
#[test]
fn decimate_is_a_noop_within_budget() {
let data = ChartData {
xs: vec![1, 2, 3],
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(1.0), Some(2.0), Some(3.0)], reduce: ReduceKind::MinMax }],
meta: ChartMeta::default(),
};
let out = decimate(data, 2000);
assert_eq!(out.xs, vec![1, 2, 3], "within-budget data must pass through unchanged");
assert_eq!(out.series[0].points, vec![Some(1.0), Some(2.0), Some(3.0)]);
}
#[test]
fn decimate_passes_meta_through_and_keeps_xs_monotonic() {
let n = 10_000usize;
let xs: Vec<i64> = (0..n as i64).collect();
let points: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64)).collect();
let meta = ChartMeta { name: "keep-me".into(), ..Default::default() };
let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta };
let out = decimate(data, 2000);
assert_eq!(out.meta.name, "keep-me", "meta must pass through decimation");
assert!(out.xs.windows(2).all(|w| w[0] < w[1]), "decimated spine must stay strictly increasing");
}
/// #111: a bounded *level* series with `reduce = Mean` decimates to each bucket's
/// MEAN, not its min/max envelope — so a high-flip bipolar exposure shows its
/// net/duty-cycle level instead of collapsing to a -1..+1 band. RED under the
/// shipped min/max-only decimation (any bucket holding a +1 emits +1); GREEN once
/// `decimate` honours `ReduceKind::Mean`.
#[test]
fn decimate_mean_reduces_a_bipolar_series_to_its_bucket_level() {
// 10 points, 2 buckets. Bucket 0 (idx 0..5) = [+1,+1,-1,+1,+1] -> mean +0.6;
// bucket 1 (idx 5..10) = all -1 -> mean -1.0.
let xs: Vec<i64> = (0..10).collect();
let pv = vec![1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0];
let points: Vec<Option<f64>> = pv.into_iter().map(Some).collect();
let data = ChartData {
xs,
series: vec![Series { name: "exposure".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::Mean }],
meta: ChartMeta::default(),
};
let out = decimate(data, 2);
let got = out.series[0].points.clone();
// No -1..+1 envelope: bucket 0 is its mean (+0.6), not a min/max pair.
assert!(!got.contains(&Some(1.0)), "mean reduce must not emit a +1 envelope point: {got:?}");
assert!(got.contains(&Some(0.6)), "bucket-0 duty-cycle mean (+0.6) missing: {got:?}");
// bucket 0 spans two slots, both = the mean (a flat step, not a -1->+1 ramp).
assert_eq!(got[0], Some(0.6), "first slot must be the bucket mean");
assert_eq!(got[1], Some(0.6), "second slot must also be the bucket mean");
}
/// #102 single-run meta wiring: `build_chart_data` maps the `RunManifest` into
/// `ChartData.meta` — kind "run", the name arg, the manifest window/broker, the
/// charted taps, and the bound params stringified (each typed `Scalar` rendered
/// via `render_value`, preserving its lexical form: `i64` decimal, `f64`
/// shortest round-trip). A single run carries no member count.
#[test]
fn build_chart_data_threads_run_manifest_into_meta() {
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> =
[1i64, 2, 3].iter().map(|&t| (Timestamp(t), vec![Scalar::f64(t as f64)])).collect();
let traces = RunTraces {
manifest: sim_optimal_manifest(
vec![("len".into(), Scalar::i64(10)), ("scale".into(), Scalar::f64(0.5))],
(Timestamp(1), Timestamp(3)),
7,
1.0,
),
taps: vec![ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &eq_rows)],
};
let data = build_chart_data("demo", traces);
let meta = &data.meta;
assert_eq!(meta.kind, "run");
assert_eq!(meta.name, "demo");
assert_eq!(meta.window, (1, 3));
assert_eq!(meta.broker, "sim-optimal(pip_size=1)");
assert_eq!(meta.seed, 7);
assert_eq!(meta.taps, vec!["equity".to_string()]);
assert_eq!(meta.members, None);
// params stringified via render_value: typed Scalars keep their lexical form.
assert_eq!(
meta.params,
vec![("len".to_string(), "10".to_string()), ("scale".to_string(), "0.5".to_string())]
);
}
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
/// its own declaration key order (manifest leads with `commit`), byte-matching the
/// stored `families.jsonl` — never `serde_json::Value`'s alphabetical order (which
/// would lead the manifest with `broker`).
#[test]
fn family_member_line_keeps_report_in_store_key_order() {
let report = RunReport {
manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0),
metrics: summarize(&[], &[]),
};
let line = family_member_line("demo-1", &report);
assert!(
line.starts_with(r#"{"family_id":"demo-1","report":{"manifest":{"commit":"#),
"got: {line}"
);
assert!(
!line.contains(r#""manifest":{"broker":"#),
"manifest re-alphabetized (broker-first), should be commit-first: {line}"
);
}
/// #99: the Monte-Carlo per-draw line carries the `seed` between `family_id` and
/// `report`, and the embedded report stays in store (commit-first) key order.
#[test]
fn mc_member_line_keeps_report_in_store_key_order_with_seed() {
let report = RunReport {
manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 7, 1.0),
metrics: summarize(&[], &[]),
};
let line = mc_member_line("mc-1", 7, &report);
assert!(
line.starts_with(r#"{"family_id":"mc-1","seed":7,"report":{"manifest":{"commit":"#),
"got: {line}"
);
assert!(
!line.contains(r#""manifest":{"broker":"#),
"manifest re-alphabetized (broker-first), should be commit-first: {line}"
);
}
// The vetted GER40 real-data window: the whole of September 2024 (UTC,
// inclusive), the same calendar month the gated ingest `ger40_breakout_real`
// test drives. Expressed in Unix-ms (`run_sample_real`'s window currency):
// `[2024-09-01T00:00:00Z, 2024-10-01T00:00:00Z - 1ms]`. Both gated GER40
// tests bound their runs to this window so the C1-determinism check stays
// fast — an unbounded `None, None` run drains the full archive every call.
const GER40_SEP2024_FROM_MS: i64 = 1_725_148_800_000;
const GER40_SEP2024_TO_MS: i64 = 1_727_740_799_999;
#[test]
fn data_source_synthetic_pip_and_window_match_the_built_ins() {
let d = DataSource::Synthetic;
assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE);
assert!(!d.run_sources().is_empty());
assert_eq!(d.wf_window_sizes(), (24, 12, 12));
// full_window equals window_of over the showcase stream (byte-unchanged source)
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
assert_eq!(d.full_window(), window_of(&s).unwrap());
}
#[test]
fn wf_real_roller_sizes_are_90_30_30_days_in_ns() {
// Independent expected value: a day reconstructed from its time units
// (24 h * 60 min * 60 s * 1e9 ns), not the constant's own `86_400_000_000_000`
// literal — so the test fails if either the literal or the day-count is wrong.
let day_ns: i64 = 24 * 60 * 60 * 1_000_000_000;
assert_eq!(WF_REAL_IS_NS, 90 * day_ns);
assert_eq!(WF_REAL_OOS_NS, 30 * day_ns);
assert_eq!(WF_REAL_STEP_NS, 30 * day_ns);
}
#[test]
fn strategy_lengths_are_short_for_synthetic_realistic_for_real() {
// Synthetic keeps the demo-stream lengths (byte-unchanged: the 18/60-bar
// built-in streams cannot warm a long MA).
assert_eq!(DataSource::Synthetic.strategy_lengths(), ([2, 3], [4, 5], (2, 4, 3)));
// Real uses realistic M1 lengths — no 2-5-bar noise over tens of thousands
// of bars. Constructing Real needs a server, but strategy_lengths matches on
// the variant only (no data access).
let real = DataSource::Real {
server: std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)),
symbol: "EURUSD".into(),
from_ms: None,
to_ms: None,
pip: 0.0001,
};
let (tf, ts, macd) = real.strategy_lengths();
assert_eq!((tf, ts, macd), ([50, 100], [200, 400], (12, 26, 9)));
// every trend-fast < every trend-slow (a valid SMA cross, both variants).
assert!(tf.iter().max().unwrap() < ts.iter().min().unwrap());
let (stf, sts, _) = DataSource::Synthetic.strategy_lengths();
assert!(stf.iter().max().unwrap() < sts.iter().min().unwrap());
}
#[test]
fn walkforward_report_is_deterministic() {
// The built-in WFO render is byte-identical across two
// calls (C1).
assert_eq!(walkforward_report(), walkforward_report());
}
#[test]
fn walkforward_report_has_one_oos_line_per_window_plus_summary() {
// N per-window OOS RunReport lines + one summary line.
let out = walkforward_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4); // built-in roll = 3 windows + 1 summary
assert!(lines[3].contains(r#""walkforward""#), "summary line: {}", lines[3]);
for line in &lines[..3] {
assert!(
line.contains(r#""manifest""#) && line.contains(r#""metrics""#),
"expected an OOS RunReport line, got: {line}",
);
}
}
/// The drained sink trace of a seeded run — the recorded rows of the equity
/// and exposure sinks. Compared row-for-row so the C1 seed-determinism
/// property is tested at the trace level (strictly stronger than the folded
/// 3-field metrics). `PartialEq` not `Eq`: `Scalar` carries `f64`.
#[derive(Debug, PartialEq)]
struct SeededTrace {
equity: Vec<(Timestamp, Vec<Scalar>)>,
exposure: Vec<(Timestamp, Vec<Scalar>)>,
}
/// A seeded run of the sample harness: the synthetic stream is generated
/// from `seed`, that same seed is recorded into the manifest, and the
/// drained sink trace is returned alongside the report. Every byte of both
/// is a function of `seed`.
fn run_sample_seeded(seed: u64) -> (RunReport, SeededTrace) {
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 };
let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1));
h.run(vec![Box::new(spec.source(seed))]);
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 metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
let report = RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed,
SYNTHETIC_PIP_SIZE,
),
metrics,
};
(report, SeededTrace { equity: eq_rows, exposure: ex_rows })
}
#[test]
fn same_seed_bit_identical_trace() {
// Bit-identical sink trace for a fixed seed (acceptance bullet 1, C1).
let (_, trace_a) = run_sample_seeded(42);
let (_, trace_b) = run_sample_seeded(42);
assert_eq!(trace_a, trace_b);
}
#[test]
fn different_seed_different_trace() {
// Different seeds perturb the trace (acceptance bullet 2).
let (a, _) = run_sample_seeded(1);
let (b, _) = run_sample_seeded(2);
assert_ne!(a.metrics, b.metrics);
}
#[test]
fn seed_recorded_in_manifest() {
// The seed that drove the run is recorded (acceptance bullet 3).
let (report, _) = run_sample_seeded(7);
assert_eq!(report.manifest.seed, 7);
}
#[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(SYNTHETIC_PIP_SIZE);
let mut h = bp
.with("signals.trend.fast.length", 2)
.with("signals.trend.slow.length", 4)
.with("signals.momentum.fast.length", 2)
.with("signals.momentum.slow.length", 4)
.with("signals.momentum.signal.length", 3)
.with("signals.blend.weights[0]", 1.0)
.with("signals.blend.weights[1]", 1.0)
.with("bias.scale", 0.5)
.bootstrap()
.expect("sample blueprint compiles under a valid point");
h.run(vec![Box::new(VecSource::new(showcase_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":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":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#""bias_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 mc_report_is_deterministic_and_one_line_per_seed() {
// C1 at the CLI edge: the family computation renders bit-identically.
assert_eq!(mc_report(), mc_report());
let out = mc_report();
let lines: Vec<&str> = out.lines().collect();
// three seeds -> three member lines + one aggregate line
assert_eq!(lines.len(), 4, "expected 3 members + 1 aggregate: {out}");
for line in &lines[..3] {
assert!(line.contains(r#""total_pips":"#), "member line missing metrics: {line}");
}
assert!(lines[3].contains(r#""mc_aggregate":"#), "missing aggregate line: {}", lines[3]);
}
#[test]
fn cli_families_persist_and_round_trip_per_kind() {
use aura_registry::{
group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports,
FamilyKind, Registry,
};
let dir = std::env::temp_dir().join(format!("aura-cli-fam-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl"));
// the exact persist chain `run_sweep`/`run_mc`/`run_walkforward` use, against
// a fresh temp store (the run_* fns themselves bind `default_registry()`):
// engine family -> per-kind extractor -> append_family.
let sid = reg
.append_family(
"sweep",
FamilyKind::Sweep,
&sweep_member_reports(&sweep_family(None, &DataSource::Synthetic)),
)
.expect("sweep family");
let mid = reg
.append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None)))
.expect("mc family");
let wid = reg
.append_family(
"walkforward",
FamilyKind::WalkForward,
&walkforward_member_reports(&walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default(), Selection::Argmax)),
)
.expect("walkforward family");
assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0"));
let families = group_families(reg.load_family_members().expect("load"));
assert_eq!(families.len(), 3);
let by_id = |id: &str| families.iter().find(|f| f.id == id).expect("family present");
assert_eq!(by_id("sweep-0").kind, FamilyKind::Sweep);
assert_eq!(by_id("mc-0").kind, FamilyKind::MonteCarlo);
assert_eq!(by_id("mc-0").members.len(), 3); // 3 seeds
assert_eq!(by_id("walkforward-0").kind, FamilyKind::WalkForward);
assert_eq!(by_id("walkforward-0").members.len(), 3); // 3 windows
let _ = std::fs::remove_dir_all(&dir);
}
#[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(None);
let r2 = run_macd(None);
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.bias_sign_flips >= 1,
"macd trace should flip exposure: {:?}",
r1.metrics
);
}
/// E2E acceptance (#41, the worked example): the real MACD strategy
/// blueprint's swept param surface qualifies the three otherwise-indistinguishable
/// EMA `length` slots by node name to `macd.fast.length` / `macd.slow.length` /
/// `macd.signal.length` — the named composite boundary visible end-to-end through
/// `param_space()`, with the slot count and order unchanged (C23 — node names are
/// non-load-bearing: every interior slot stays sweepable, the `bias.scale`
/// knob is unaffected).
#[test]
fn macd_param_space_surfaces_the_three_named_legs() {
let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three named composite-interior slots, in declared (fast, slow, signal)
// order, then the strategy-level Bias `scale` (a root-level leaf).
assert_eq!(
names,
vec![
"macd.fast.length".to_string(),
"macd.slow.length".to_string(),
"macd.signal.length".to_string(),
"bias.scale".to_string(),
],
"MACD param surface must expose the three named EMA lengths + scale",
);
}
/// `aura run --real <SYMBOL>` dogfoods the #71 streaming Source seam: the same
/// built-in sample signal-quality harness, but fed real M1 **close** bars
/// streamed lazily through `aura_ingest::M1FieldSource` (a `Box<dyn Source>`),
/// not synthetic `VecSource` prices. The property: over the verified bounded
/// Sept-2024 GER40 window, `run_sample_real` yields a `RunReport` whose
/// `total_pips` is finite and is C1-deterministic — two runs of the same
/// window are bit-identical JSON. Bounding the window (vs the full unbounded
/// archive) keeps the determinism check fast.
///
/// Gated like the ingest `streaming_seam` test: skip (early return) when the
/// local Pepperstone archive is absent, so the test never fails on a machine
/// without the data. Uses `GER40` — a *vetted* symbol (pip 1.0): the
/// per-instrument-pip refusal makes `run_sample_real` reject an un-specced
/// symbol before any data access (`std::process::exit(2)`), so this CLI-level
/// test must drive a symbol in the instrument table. The bounded-window AAPL.US
/// streaming property still lives in the ingest `streaming_seam` test, which
/// builds its source literally without the spec lookup.
#[test]
fn run_sample_real_streams_real_close_bars_deterministically() {
// GER40 is in the vetted instrument table (index pip 1.0); the un-specced
// AAPL.US would now refuse at the spec lookup before any data access.
const SYMBOL: &str = "GER40";
// Mirror skip_if_no_data: never fail where the local archive is absent.
let server = std::sync::Arc::new(data_server::DataServer::new(
data_server::DEFAULT_DATA_PATH,
));
if !server.has_symbol(SYMBOL) {
eprintln!(
"skip: no local data at {} (symbol {SYMBOL} absent)",
data_server::DEFAULT_DATA_PATH
);
return;
}
// The headline: a real-data run over the bounded Sept-2024 window yields
// a finite, C1-deterministic RunReport.
let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
assert!(
r1.metrics.total_pips.is_finite(),
"real-data run must yield finite pips: {:?}",
r1.metrics
);
// C1 at the CLI edge: the same real window streamed twice is bit-identical.
assert_eq!(
r1.to_json(),
r2.to_json(),
"two real-data runs of the same window must be bit-identical (C1)"
);
}
#[test]
fn sim_optimal_manifest_renders_per_instrument_pip() {
let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0);
assert_eq!(m.broker, "sim-optimal(pip_size=1)");
let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001);
assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)");
}
#[test]
fn run_real_ger40_uses_index_pip() {
// Gated: needs local GER40 data. Mirrors the existing real-path test's skip.
let server = data_server::DataServer::new(data_server::DEFAULT_DATA_PATH);
if !server.has_symbol("GER40") {
eprintln!("skip: no local GER40 data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
// Bounded to the vetted Sept-2024 window so the pip-label + determinism
// checks run fast (not the full unbounded archive).
let report =
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
// The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001.
assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)");
// Deterministic (C1): a second run yields the same report.
let again =
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
assert_eq!(report.manifest.broker, again.manifest.broker);
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
}
/// `parse_chart_args` parses `<name>`, `--tap <t>`, and `--panels` in any order
/// (the loop grammar's reason for being — the name need not lead), and surfaces
/// each of its three distinct errors: `--tap` missing its value, an unexpected
/// extra argument, and a missing `<name>`. The any-order contract is the only
/// reason `main` runs a loop here instead of an exhaustive argv match, so it is
/// asserted as a property, not just exercised.
#[test]
fn parse_chart_args_accepts_any_order_and_rejects_three_ways() {
// bare name -> default tap (None) + Overlay mode
let (name, tap, mode) = parse_chart_args(&["fam"]).expect("bare name parses");
assert_eq!(name, "fam");
assert_eq!(tap, None);
assert!(matches!(mode, ChartMode::Overlay));
// name + --tap + --panels in canonical order
let (name, tap, mode) =
parse_chart_args(&["fam", "--tap", "equity", "--panels"]).expect("full parses");
assert_eq!(name, "fam");
assert_eq!(tap.as_deref(), Some("equity"));
assert!(matches!(mode, ChartMode::Panels));
// any order: flags before the name, and --panels between name and --tap
let (name, tap, mode) =
parse_chart_args(&["--tap", "equity", "fam"]).expect("flag-first parses");
assert_eq!(name, "fam");
assert_eq!(tap.as_deref(), Some("equity"));
assert!(matches!(mode, ChartMode::Overlay));
let (name, tap, mode) =
parse_chart_args(&["--panels", "fam", "--tap", "exposure"]).expect("interleaved parses");
assert_eq!(name, "fam");
assert_eq!(tap.as_deref(), Some("exposure"));
assert!(matches!(mode, ChartMode::Panels));
// three distinct error branches, by message (the Ok tuple holds a
// non-Debug ChartMode, so match the Err arm instead of unwrapping).
let err = |args: &[&str]| match parse_chart_args(args) {
Err(e) => e,
Ok(_) => panic!("expected an error for {args:?}"),
};
assert_eq!(err(&["fam", "--tap"]), "--tap needs a value");
assert_eq!(err(&["fam", "extra"]), "unexpected chart argument 'extra'");
assert_eq!(err(&["--panels"]), "chart needs a <name>");
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample(None);
let r2 = run_sample(None);
// 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 bias sign flip in the demo trace (rises then reverses).
assert_eq!(m.bias_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);
}
fn pair(name: &str, v: Scalar) -> (String, Scalar) {
(name.to_string(), v)
}
#[test]
fn member_key_renders_varying_axes_portably() {
let named = vec![
pair("ema.length", Scalar::i64(5)),
pair("bias.scale", Scalar::f64(0.5)),
pair("longonly.enabled", Scalar::bool(true)),
];
let varying: std::collections::HashSet<String> =
named.iter().map(|(n, _)| n.clone()).collect();
let key = member_key(&named, &varying);
assert_eq!(key, "ema.length-5_bias.scale-0.5_longonly.enabled-true");
assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')));
}
#[test]
fn member_key_omits_pinned_axes() {
let named = vec![
pair("ema.length", Scalar::i64(5)),
pair("bias.scale", Scalar::f64(0.5)),
pair("longonly.enabled", Scalar::bool(false)),
];
let mut varying = std::collections::HashSet::new();
varying.insert("longonly.enabled".to_string());
assert_eq!(member_key(&named, &varying), "longonly.enabled-false");
}
#[test]
fn member_key_handles_negative_float_and_sanitises_names() {
let named = vec![pair("bias.scale", Scalar::f64(-0.5))];
let varying: std::collections::HashSet<String> =
["bias.scale".to_string()].into_iter().collect();
assert_eq!(member_key(&named, &varying), "bias.scale--0.5");
let named2 = vec![pair("weird key!", Scalar::f64(1.0))];
let varying2: std::collections::HashSet<String> =
["weird key!".to_string()].into_iter().collect();
assert_eq!(member_key(&named2, &varying2), "weird_key_-1");
}
#[test]
fn member_key_is_m_when_no_axis_varies() {
let named = vec![pair("bias.scale", Scalar::f64(0.5))];
let varying = std::collections::HashSet::new();
assert_eq!(member_key(&named, &varying), "m");
}
#[test]
fn member_key_caps_length_with_conformant_hash_fallback() {
let named: Vec<(String, Scalar)> = (0..40)
.map(|i| pair(&format!("some.long.axis.path.number.{i}"), Scalar::i64(i)))
.collect();
let varying: std::collections::HashSet<String> =
named.iter().map(|(n, _)| n.clone()).collect();
let key = member_key(&named, &varying);
assert!(key.len() <= MAX_KEY, "over-cap key not bounded: {} bytes", key.len());
assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')));
let mut named2 = named.clone();
named2[0].1 = Scalar::i64(999);
assert_ne!(key, member_key(&named2, &varying), "distinct over-cap inputs must differ");
}
#[test]
fn member_key_collision_free_over_a_non_trend_axis_set() {
// #105 regression: vary bias.scale + a bool (NOT the old hardcoded
// trend.fast/slow). Distinct points -> distinct keys; the old f…s… key
// would have collapsed them all to one dir.
let varying: std::collections::HashSet<String> =
["bias.scale".to_string(), "longonly.enabled".to_string()].into_iter().collect();
let p = |s: f64, b: bool| {
vec![
pair("ema.length", Scalar::i64(5)), // pinned -> omitted from key
pair("bias.scale", Scalar::f64(s)),
pair("longonly.enabled", Scalar::bool(b)),
]
};
let keys: Vec<String> = [(0.5, true), (0.5, false), (1.0, true), (1.0, false)]
.iter()
.map(|&(s, b)| member_key(&p(s, b), &varying))
.collect();
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(SYNTHETIC_PIP_SIZE).0.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(
names,
vec![
"ema.length".to_string(),
"bias.scale".to_string(),
"longonly.enabled".to_string(),
],
);
}
#[test]
fn momentum_sweep_is_deterministic_and_has_eight_points() {
let a = momentum_sweep_family(None, &DataSource::Synthetic);
let b = momentum_sweep_family(None, &DataSource::Synthetic);
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() {
let g = Stage1RGrid::default();
assert_eq!(
parse_sweep_args(&[]),
Ok((Strategy::SmaCross, "sweep".to_string(), false, DataChoice::Synthetic, g.clone()))
);
assert_eq!(
parse_sweep_args(&["--trace", "swp"]),
Ok((Strategy::SmaCross, "swp".to_string(), true, DataChoice::Synthetic, g.clone()))
);
assert_eq!(
parse_sweep_args(&["--name", "s"]),
Ok((Strategy::SmaCross, "s".to_string(), false, DataChoice::Synthetic, g.clone()))
);
assert_eq!(
parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]),
Ok((Strategy::Momentum, "mom".to_string(), true, DataChoice::Synthetic, g.clone()))
);
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
}
/// #137: the four optional grid flags parse comma-separated lists onto the
/// stage1-r grid axes; an absent flag keeps the historical default list, and a
/// malformed list (empty / non-numeric item) is the strict usage error.
#[test]
fn parse_sweep_args_parses_the_four_grid_flags() {
let parsed = parse_sweep_args(&[
"--strategy", "stage1-r",
"--fast", "240",
"--slow", "960,1200",
"--stop-length", "240",
"--stop-k", "2.0,3.5",
])
.expect("the four grid flags parse");
let grid = parsed.4;
assert_eq!(grid.fast, vec![240]);
assert_eq!(grid.slow, vec![960, 1200]);
assert_eq!(grid.stop_length, vec![240]);
assert_eq!(grid.stop_k, vec![2.0, 3.5]);
// absent flags fall back to the historical defaults.
assert_eq!(parse_sweep_args(&[]).unwrap().4, Stage1RGrid::default());
// a malformed list is the strict usage error (not a downstream panic).
assert!(parse_sweep_args(&["--fast", "2,x"]).is_err()); // non-numeric item
assert!(parse_sweep_args(&["--fast", ""]).is_err()); // empty item
assert!(parse_sweep_args(&["--stop-k", "abc"]).is_err()); // non-float
}
/// Property: the breakout `--channel` flag parses a comma-separated list onto
/// `Stage1RGrid.channel` (the breakout family's sole signal axis), with the same
/// strictness as the stage1-r grid flags — an absent flag keeps the historical
/// default `[1920]`, a malformed list is the usage error (not a downstream panic).
/// Guards the new flag added alongside the breakout family; without it the
/// `--channel` arm could silently drift (e.g. last-write-wins, lenient parse).
#[test]
fn parse_sweep_args_parses_the_channel_grid_flag() {
let parsed = parse_sweep_args(&["--strategy", "stage1-breakout", "--channel", "20,55,100"])
.expect("the breakout channel flag parses");
assert_eq!(parsed.0, Strategy::Stage1Breakout);
assert_eq!(parsed.4.channel, vec![20, 55, 100]);
// absent --channel keeps the historical default list.
assert_eq!(parse_sweep_args(&[]).unwrap().4.channel, vec![1920]);
// a malformed list is the strict usage error.
assert!(parse_sweep_args(&["--channel", "20,x"]).is_err()); // non-numeric item
assert!(parse_sweep_args(&["--channel", ""]).is_err()); // empty item
}
#[test]
fn parse_generalize_requires_a_single_value_candidate() {
// a candidate is one cell: a multi-value grid flag is refused WITH A REASON,
// not a bare usage dump (fieldtest friction).
let err = parse_generalize_args(&[
"--real", "GER40,USDJPY", "--fast", "2,3", "--slow", "12",
"--stop-length", "14", "--stop-k", "2.0",
]).unwrap_err();
assert!(err.contains("single candidate"), "refusal must say why: {err}");
}
#[test]
fn parse_generalize_requires_all_four_knobs() {
// --slow absent -> refuse (no defaulting to the multi-value sweep grid)
assert!(parse_generalize_args(&[
"--real", "GER40,USDJPY", "--fast", "3",
"--stop-length", "14", "--stop-k", "2.0",
]).is_err());
}
#[test]
fn parse_generalize_refuses_fewer_than_two_instruments() {
// refused WITH A REASON naming the >=2 requirement (fieldtest friction).
let err = parse_generalize_args(&[
"--real", "GER40", "--fast", "3", "--slow", "12",
"--stop-length", "14", "--stop-k", "2.0",
]).unwrap_err();
assert!(err.contains("at least two instruments"), "refusal must say why: {err}");
}
#[test]
fn parse_generalize_refuses_a_duplicate_instrument() {
assert!(parse_generalize_args(&[
"--real", "GER40,GER40", "--fast", "3", "--slow", "12",
"--stop-length", "14", "--stop-k", "2.0",
]).is_err());
}
#[test]
fn parse_generalize_refuses_a_non_stage1r_strategy() {
assert!(parse_generalize_args(&[
"--strategy", "sma", "--real", "GER40,USDJPY", "--fast", "3", "--slow", "12",
"--stop-length", "14", "--stop-k", "2.0",
]).is_err());
}
#[test]
fn parse_generalize_defaults_name_and_metric() {
let (name, symbols, grid, metric, _from, _to) = parse_generalize_args(&[
"--real", "GER40,USDJPY", "--fast", "3", "--slow", "12",
"--stop-length", "14", "--stop-k", "2.0",
]).expect("valid generalize args");
assert_eq!(name, "generalize");
assert_eq!(metric, "expectancy_r");
assert_eq!(symbols, vec!["GER40".to_string(), "USDJPY".to_string()]);
assert_eq!(grid.fast, vec![3]);
assert_eq!(grid.slow, vec![12]);
assert_eq!(grid.stop_length, vec![14]);
assert_eq!(grid.stop_k, vec![2.0]);
}
/// Property: the meanrev `--window` / `--band-k` flags parse comma-separated
/// lists onto `Stage1RGrid.{window,band_k}` (the meanrev family's signal axes),
/// with the same strictness as the other grid flags — absent flags keep the
/// historical defaults, a malformed list is the usage error.
#[test]
fn parse_sweep_args_parses_the_meanrev_grid_flags() {
let parsed = parse_sweep_args(&[
"--strategy", "stage1-meanrev", "--window", "120,240,480", "--band-k", "1.5,2.5",
])
.expect("the meanrev grid flags parse");
assert_eq!(parsed.0, Strategy::Stage1MeanRev);
assert_eq!(parsed.4.window, vec![120, 240, 480]);
assert_eq!(parsed.4.band_k, vec![1.5, 2.5]);
// absent flags keep the historical defaults.
assert_eq!(parse_sweep_args(&[]).unwrap().4.window, vec![1920]);
assert_eq!(parse_sweep_args(&[]).unwrap().4.band_k, vec![2.0]);
// a malformed list is the strict usage error.
assert!(parse_sweep_args(&["--window", "120,x"]).is_err());
assert!(parse_sweep_args(&["--band-k", ""]).is_err());
}
/// Property: the *shipped* `stage1_meanrev_graph` (the CLI compile unit, not
/// the hand-rebuilt subgraph in `stage1_meanrev_e2e.rs`) FADES against the
/// move — its exposure tap reads short (-1) above the band and long (+1)
/// below, the sign-inverted-vs-breakout polarity that defines mean-reversion.
/// A latch-polarity copy-paste from `stage1_breakout_graph` (swapping the
/// `set`/`reset` legs) would leave the fold-vs-raw and window-grid CLI tests
/// green yet silently invert the signal; only an observable read of this
/// function's bias catches it. `k = 0` collapses the band to the lagging EWMA
/// mean, isolating direction + latch from the sigma threshold; window 3
/// (alpha = 0.5) lags the level clearly. The exposure Recorder (`tx_ex`,
/// `reduce = false`) carries the bias in col[0].
#[test]
fn stage1_meanrev_graph_fades_short_above_the_band_and_long_below() {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, _rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let flat = stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false)
.compile_with_params(&[])
.expect("stage1-meanrev blueprint compiles");
let mut h = Harness::bootstrap(flat).expect("stage1-meanrev harness bootstraps");
// calm (price == lagging mean -> no fade) | sustained UP (price > mean ->
// fade SHORT) | sustained DOWN (price < mean -> fade LONG).
let closes = [
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0,
];
let prices: Vec<(Timestamp, Scalar)> =
closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect();
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(prices))];
h.run(src);
let bias: Vec<f64> =
rx_ex.try_iter().map(|(_, row): (Timestamp, Vec<Scalar>)| row[0].as_f64()).collect();
assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up");
assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}");
let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)");
let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)");
assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}");
assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}");
}
/// `parse_sweep_args` admits a real symbol with an optional `--from`/`--to`
/// window (yielding `DataChoice::Real`), still honouring `--strategy`/`--trace`;
/// a `--real` without its symbol, or a window flag without `--real`, is rejected.
#[test]
fn parse_sweep_args_accepts_real_symbol_and_window() {
assert_eq!(
parse_sweep_args(&["--real", "EURUSD", "--from", "100", "--to", "200", "--trace", "s"]),
Ok((Strategy::SmaCross, "s".to_string(), true,
DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) },
Stage1RGrid::default()))
);
assert!(parse_sweep_args(&["--real"]).is_err()); // --real needs a symbol
assert!(parse_sweep_args(&["--from", "100"]).is_err()); // --from without --real
}
/// `parse_sweep_args` mirrors the shared `RealWindowGrammar` real/window strictness: an
/// empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
/// errors (not last-write-wins, not a downstream refusal) — so sibling commands
/// reject the same malformed real-grammar the same way.
#[test]
fn parse_sweep_args_rejects_empty_symbol_and_repeated_real_window() {
assert!(parse_sweep_args(&["--real", ""]).is_err()); // empty symbol
assert!(parse_sweep_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err()); // repeated --real
assert!(parse_sweep_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err()); // repeated --from
assert!(parse_sweep_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err()); // repeated --to
}
/// `parse_walkforward_args` defaults to synthetic / name "walkforward" / no
/// persist, admits `--real <SYMBOL>` (window-less here) yielding `DataChoice::Real`,
/// and rejects two name flags or a `--real` missing its symbol.
#[test]
fn parse_walkforward_args_defaults_and_accepts_real() {
// Selection is not PartialEq/Debug (it carries the opaque PlateauMode), so
// the comparable fields are asserted via the 5-tuple prefix and `select`
// separately via `matches!`.
let (strategy, name, persist, choice, grid, select) =
parse_walkforward_args(&[]).expect("defaults parse");
assert_eq!(
(strategy, name, persist, choice, grid),
(Strategy::SmaCross, "walkforward".to_string(), false, DataChoice::Synthetic, Stage1RGrid::default())
);
assert!(matches!(select, Selection::Argmax), "default selection is argmax");
let (strategy, name, persist, choice, grid, _) =
parse_walkforward_args(&["--real", "EURUSD", "--trace", "w"]).expect("real/trace parse");
assert_eq!(
(strategy, name, persist, choice, grid),
(Strategy::SmaCross, "w".to_string(), true,
DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None },
Stage1RGrid::default())
);
assert!(parse_walkforward_args(&["--name", "a", "--trace", "b"]).is_err());
assert!(parse_walkforward_args(&["--real"]).is_err());
}
#[test]
fn parse_walkforward_args_accepts_strategy_and_grid_flags() {
let r = parse_walkforward_args(&["--strategy", "stage1-r", "--fast", "5,10", "--stop-k", "2.0,3.0"]);
let (strategy, _, _, _, grid, _) = r.expect("valid stage1-r walkforward args");
assert_eq!(strategy, Strategy::Stage1R);
assert_eq!(grid.fast, vec![5, 10]);
assert_eq!(grid.stop_k, vec![2.0, 3.0]);
assert!(parse_walkforward_args(&["--strategy", "bogus"]).is_err());
}
/// `parse_walkforward_args` mirrors the shared `RealWindowGrammar` real/window strictness:
/// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
/// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives.
#[test]
fn parse_walkforward_args_rejects_empty_symbol_and_repeated_real_window() {
assert!(parse_walkforward_args(&["--real", ""]).is_err()); // empty symbol
assert!(parse_walkforward_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err());
assert!(parse_walkforward_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err());
assert!(parse_walkforward_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err());
}
#[test]
fn run_stage1_r_synthetic_folds_an_r_block() {
// the stage1-r harness scores the SMA-cross signal in R: one shell-callable run
// yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade.
let report = run_stage1_r(RunData::Synthetic, None, None);
let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r");
assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades);
assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn);
assert!(r.expectancy_r.is_finite(), "E[R] must be finite, got {}", r.expectancy_r);
// #132: the dual-tap harness runs a RiskExecutor branch alongside the
// SimBroker, so its manifest carries a dedicated broker label.
assert!(
report.manifest.broker.contains("risk-executor"),
"stage1-r manifest should carry a dedicated broker label, got: {}",
report.manifest.broker
);
}
/// Property: a bare `aura run` parses to the default harness/data — `--harness sma`
/// over the synthetic stream, no trace. The grammar's identity element, so the
/// historic plain-`run` form keeps its meaning through the new tokenizer.
#[test]
fn parse_run_args_defaults_to_sma_synthetic() {
let a = parse_run_args(&[]).expect("bare run");
assert!(matches!(a.harness, HarnessKind::Sma));
assert!(matches!(a.data, RunData::Synthetic));
assert!(a.trace.is_none());
}
/// Property: `--harness`, `--real <SYM> [--from]` and `--trace` are orthogonal
/// modifiers — they compose in ANY order into one `RunArgs`, each captured into its
/// own field, so the tokenizer is order-independent (the historic literal arms were
/// not).
#[test]
fn parse_run_args_composes_harness_real_and_trace_in_any_order() {
let a = parse_run_args(&["--trace", "q1", "--harness", "stage1-r", "--real", "GER40", "--from", "100"])
.expect("composed flags");
assert!(matches!(a.harness, HarnessKind::Stage1R));
match a.data {
RunData::Real { symbol, from, to } => {
assert_eq!(symbol, "GER40");
assert_eq!(from, Some(100));
assert_eq!(to, None);
}
_ => panic!("expected real data"),
}
assert_eq!(a.trace.as_deref(), Some("q1"));
}
/// Property: `--harness macd` selects the MACD harness — the sole selector now that the
/// `--macd` back-compat flag is removed, so `--macd` is rejected as an unknown token; an
/// unknown harness name is rejected, and a window flag (`--from`) without `--real` is
/// rejected — the grammar's refusal edges.
#[test]
fn parse_run_args_harness_macd_selects_macd_and_rejects_removed_flag() {
assert!(matches!(parse_run_args(&["--harness", "macd"]).unwrap().harness, HarnessKind::Macd));
assert!(parse_run_args(&["--macd"]).is_err()); // the removed flag is now an unknown token
assert!(parse_run_args(&["--harness", "nope"]).is_err());
assert!(parse_run_args(&["--from", "1"]).is_err()); // --from without --real
}
/// Property: the run-arg parser preserves every strictness branch the removed
/// `parse_real_args` unit test pinned — empty symbol, non-i64 window ms, a repeated
/// flag, and a flag missing its value all reject, so the `run` path stays exit-2 strict.
#[test]
fn parse_run_args_rejects_malformed_real_and_repeated_flags() {
assert!(parse_run_args(&["--real", ""]).is_err()); // empty symbol
assert!(parse_run_args(&["--real", "GER40", "--from", "x"]).is_err()); // non-i64 from
assert!(parse_run_args(&["--real", "GER40", "--to", "1.5"]).is_err()); // non-i64 to
assert!(parse_run_args(&["--harness", "sma", "--harness", "macd"]).is_err()); // repeated --harness
assert!(parse_run_args(&["--real", "GER40", "--trace", "a", "--trace", "b"]).is_err()); // repeated --trace
assert!(parse_run_args(&["--harness"]).is_err()); // flag missing its value
assert!(parse_run_args(&["--real", "GER40", "--from"]).is_err()); // flag missing its value
assert!(parse_run_args(&["bogus"]).is_err()); // unknown trailing token
}
#[test]
fn parse_mc_args_bare_and_name_route_to_synthetic() {
assert_eq!(parse_mc_args(&[]), Ok(McArgs::Synthetic { name: "mc".to_string(), persist: false }));
assert_eq!(parse_mc_args(&["--name", "m"]), Ok(McArgs::Synthetic { name: "m".to_string(), persist: false }));
assert_eq!(parse_mc_args(&["--trace", "m"]), Ok(McArgs::Synthetic { name: "m".to_string(), persist: true }));
}
#[test]
fn parse_mc_args_stage1_r_routes_to_real_r_with_defaults_and_overrides() {
assert_eq!(
parse_mc_args(&["--strategy", "stage1-r"]),
Ok(McArgs::RealR {
choice: DataChoice::Synthetic, grid: Stage1RGrid::default(),
block_len: 1, n_resamples: 1000, seed: 1,
})
);
let r = parse_mc_args(&["--strategy", "stage1-r", "--real", "USDJPY",
"--block-len", "5", "--resamples", "200", "--seed", "9", "--fast", "5,10"]);
let McArgs::RealR { choice, grid, block_len, n_resamples, seed } = r.expect("valid real-R mc args")
else { panic!("expected RealR") };
assert_eq!(choice, DataChoice::Real { symbol: "USDJPY".to_string(), from_ms: None, to_ms: None });
assert_eq!((block_len, n_resamples, seed), (5, 200, 9));
assert_eq!(grid.fast, vec![5, 10]);
}
#[test]
fn parse_mc_args_real_without_stage1_r_is_usage_error() {
// a bare --real (no R candidate) is still rejected — the synthetic seed-resweep
// is undefined over real bars; the R path needs --strategy stage1-r.
assert!(parse_mc_args(&["--real", "EURUSD"]).is_err());
assert!(parse_mc_args(&["--strategy", "sma"]).is_err());
assert!(parse_mc_args(&["--strategy", "stage1-r", "--name", "x"]).is_err()); // name invalid on R path
}
#[test]
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
// each field is named and value-faithful, so a renamed/dropped field here
// breaks a test instead of silently shipping. Pins the user-visible wire
// shape of the mc R-bootstrap render (the parser + engine primitive are
// covered elsewhere; this is the output-shape layer).
let boot = r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7);
let line = mc_r_bootstrap_json(&boot);
let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line");
let obj = &v["mc_r_bootstrap"];
assert_eq!(obj["n_trades"], serde_json::json!(boot.n_trades));
assert_eq!(obj["block_len"], serde_json::json!(boot.block_len));
assert_eq!(obj["n_resamples"], serde_json::json!(boot.n_resamples));
assert_eq!(obj["prob_le_zero"], serde_json::json!(boot.prob_le_zero));
// e_r is the nested MetricStats block (mean + quantiles), not a flat scalar.
assert_eq!(obj["e_r"], serde_json::to_value(&boot.e_r).expect("MetricStats serializes"));
assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}");
}
#[test]
fn mc_r_bootstrap_report_pools_a_non_empty_oos_r_series_over_synthetic() {
// Property: the full real-R assembly path — walkforward_family(Stage1R) ->
// pooled_oos_trade_rs -> r_bootstrap -> mc_r_bootstrap_json — wires up and
// reduces a NON-EMPTY pooled OOS R series (the synthetic stage1-r walk-forward
// closes >= 1 trade across its windows). Guards the wiring + the non-empty
// pooling branch the parser/primitive unit tests cannot reach; mirrors
// `mc_report` / `walkforward_report`. Deterministic (C1).
let result = walkforward_family(Strategy::Stage1R, None, &DataSource::Synthetic, &Stage1RGrid::default(), Selection::Argmax);
let pooled = pooled_oos_trade_rs(&result);
assert!(!pooled.is_empty(), "synthetic stage1-r walk-forward must pool >= 1 OOS trade R");
let line = mc_r_bootstrap_report(&DataSource::Synthetic, &Stage1RGrid::default(), 1, 256, 1);
let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line");
let obj = &v["mc_r_bootstrap"];
// n_trades is the pooled-series length the bootstrap actually saw — non-zero
// proves the assembled pooling fed the primitive, not an empty fallback.
assert_eq!(obj["n_trades"], serde_json::json!(pooled.len()));
assert!(obj["n_trades"].as_u64().expect("n_trades is an integer") >= 1);
assert_eq!(obj["n_resamples"], serde_json::json!(256));
assert!(obj["e_r"]["mean"].as_f64().expect("e_r.mean is a number").is_finite());
// C1 at the CLI edge: the assembled real-R line is byte-identical on re-run.
assert_eq!(
line,
mc_r_bootstrap_report(&DataSource::Synthetic, &Stage1RGrid::default(), 1, 256, 1),
);
}
}