Files
Aura/crates/aura-cli/src/main.rs
T
Brummel 4928e289f7 feat(project): the project-as-crate load boundary (cycle 0102)
A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
2026-07-02 18:13:37 +02:00

6022 lines
283 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;
mod graph_construct;
mod project;
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_composites::{cost_graph, risk_executor, risk_executor_vol_open, StopRule};
use aura_engine::{
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, monte_carlo, param_stability, r_bootstrap,
r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, BindError, BlueprintNode, 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, WriteKind,
};
use aura_std::{
Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul,
Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost,
PM_FIELD_NAMES, PM_RECORD_KINDS,
};
// `std_vocabulary` is now only reached through `project::Env::resolve` in production
// code; the test module still builds reference blueprints against it directly, so
// the import is test-only.
#[cfg(test)]
use aura_std::std_vocabulary;
use std::sync::mpsc::{self, Receiver};
use std::sync::LazyLock;
use std::collections::HashSet;
use clap::{Args, Parser, Subcommand};
/// 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,
topology_hash: None,
project: 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 runtime error (stderr + exit 1), per the spec.
fn persist_traces(
name: &str,
manifest: &RunManifest,
eq_rows: &[(Timestamp, Vec<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)],
env: &project::Env,
) {
let taps = vec![
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
];
if let Err(e) = env.trace_store().write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(1);
}
}
/// 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 runtime error
/// (stderr + exit 1), never a panic.
fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env) {
let store = env.trace_store();
match store.name_kind(name) {
NameKind::Run => {
let traces = match store.read(name) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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(1);
}
};
}
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(1);
}
};
let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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(1);
}
}
}
/// 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>, env: &project::Env) -> RunReport {
if let Some(n) = trace
&& let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
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, env);
}
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 runtime error: stderr + exit(1), not a panic.
fn run_sample_real(
symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>, env: &project::Env,
) -> RunReport {
if let Some(n) = trace
&& let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms, env);
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, env);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
/// 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(1), mirroring `run_sample_real`.
fn no_real_data(symbol: &str, env: &project::Env) -> ! {
eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path());
std::process::exit(1)
}
/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse
/// (stderr + exit 1) 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, env: &project::Env,
) -> 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",
env.data_path()
);
std::process::exit(1);
}
}
}
/// 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>,
env: &project::Env,
) -> (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, env));
let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol, env));
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 1). Pre-data refusals keep the
/// pip honest by construction. Shared by `run_sample_real` and `run_r_sma`.
fn open_real_source(
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &project::Env,
) -> (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(env.data_path()));
let pip = pip_or_refuse(&server, symbol, env);
if !server.has_symbol(symbol) {
no_real_data(symbol, env);
}
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
let window = probe_window(&server, symbol, from_ms, to_ms, env);
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, env),
};
(source, window, pip)
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) 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, env: &project::Env) -> DataSource {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Real { symbol, from_ms, to_ms } => {
let server = std::sync::Arc::new(data_server::DataServer::new(env.data_path()));
let pip = pip_or_refuse(&server, &symbol, env);
if !server.has_symbol(&symbol) {
no_real_data(&symbol, env);
}
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, env: &project::Env) -> (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, env)
}
}
}
/// 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, env: &project::Env) -> (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, env)
}
}
}
/// A fresh full-window source per member (single-pass). Synthetic: showcase.
fn run_sources(&self, env: &project::Env) -> 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, env)),
)],
}
}
/// 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, env: &project::Env,
) -> 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, env)),
)],
}
}
/// 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, env: &project::Env) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(env);
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(env);
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, env);
}
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, env: &project::Env) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(env);
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(env);
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, env);
}
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 r-sma 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 r_sma_broker_label(pip_size: f64) -> String {
format!("sim-optimal+risk-executor(pip_size={pip_size})")
}
/// `aura sweep --strategy r-sma`: sweep the r-sma 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 r-sma 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 RGrid {
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 RGrid {
fn default() -> Self {
Self {
fast: vec![2, 3],
slow: vec![6, 12],
stop_length: vec![R_SMA_STOP_LENGTH],
stop_k: vec![R_SMA_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 path-qualified `param_space()` suffixes of the two open SMA-cross signal knobs.
/// Since cycle 0092 the signal leg is a nested `sma_signal` composite, so its knobs
/// land in `param_space` under the composite prefix (`sma_signal.fast.length` /
/// `sma_signal.slow.length`); like the stop knobs they are resolved by suffix so the
/// prefix is never hand-synced, and rendered back to the bare `fast.length` / `slow.length`
/// manifest names by `r_sma_friendly_name` (the pre-0092 family-record names — C18).
const FAST_LENGTH_SUFFIX: &str = ".fast.length";
const SLOW_LENGTH_SUFFIX: &str = ".slow.length";
/// 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 r_sma_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 if space_name.ends_with(FAST_LENGTH_SUFFIX) {
"fast.length".to_string()
} else if space_name.ends_with(SLOW_LENGTH_SUFFIX) {
"slow.length".to_string()
} else {
space_name.to_string()
}
}
fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(env);
// 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 = r_sma_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 r-sma 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 r-sma vol-stop exposes a stop_k axis");
let fast_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(FAST_LENGTH_SUFFIX))
.expect("open r-sma signal exposes a fast.length axis");
let slow_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(SLOW_LENGTH_SUFFIX))
.expect("open r-sma signal exposes a slow.length axis");
let binder = bp
.axis(&fast_axis, grid.fast.clone())
.axis(&slow_axis, 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| r_sma_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 = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, reduce, None)
.bootstrap_with_cells(point)
.expect("r-sma grid points are kind-checked against param_space");
h.run(data.run_sources(env));
// 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)| (r_sma_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 = r_sma_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, &[], env);
}
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 r-sma named grid matches the r-sma param-space")
}
/// The param-space of the OPEN r-sma 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 `r_sma_sweep_family`.
fn r_sma_space() -> Vec<ParamSpec> {
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None).param_space()
}
/// Windowed reduce-mode r-sma sweep over `[from, to]` — the in-sample leg of the
/// r-sma walk-forward. Identical grid/axis/fold logic to `r_sma_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 r_sma_sweep_over(
from: Timestamp, to: Timestamp, data: &DataSource, grid: &RGrid, env: &project::Env,
) -> (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 = r_sma_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 r-sma 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 r-sma vol-stop exposes a stop_k axis");
let fast_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(FAST_LENGTH_SUFFIX))
.expect("open r-sma signal exposes a fast.length axis");
let slow_axis = space
.iter()
.map(|p| p.name.clone())
.find(|n| n.ends_with(SLOW_LENGTH_SUFFIX))
.expect("open r-sma signal exposes a slow.length axis");
bp.axis(&fast_axis, grid.fast.clone())
.axis(&slow_axis, 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 = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None)
.bootstrap_with_cells(point)
.expect("r-sma grid points are kind-checked against param_space");
let sources = data.windowed_sources(from, to, env);
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)| (r_sma_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 = r_sma_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 r-sma named grid matches the r-sma param-space")
}
/// Run the chosen r-sma 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,
env: &project::Env,
) -> (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 = r_sma_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, env);
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)| (r_sma_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 = r_sma_broker_label(pip);
if let Some(name) = trace {
persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env);
}
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 r-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_r_sma) 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
/// r-sma). With --trace, raw recorders persist the per-cycle streams.
fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(env);
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 = r_breakout_graph(tx_eq, tx_ex, tx_r, tx_req, Some(c), sl, sk, reduce)
.compile_with_params(&[])
.expect("valid r-breakout blueprint");
let mut h = Harness::bootstrap(flat).expect("valid r-breakout harness");
h.run(data.run_sources(env));
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 = r_sma_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, &[], env);
}
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 r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(env);
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 =
r_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce)
.compile_with_params(&[])
.expect("valid r-meanrev blueprint");
let mut h = Harness::bootstrap(flat).expect("valid r-meanrev harness");
h.run(data.run_sources(env));
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 = r_sma_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, &[], env);
}
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, &project::Env::std()).points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
}
/// 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,
RSma,
RBreakout,
RMeanRev,
}
/// 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::RSma => "r-sma",
Strategy::RBreakout => "r-breakout",
Strategy::RMeanRev => "r-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 r-sma 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)
}
/// 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|r-sma|r-breakout|r-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`/`r-sma`) persists each member's streams under
/// `runs/traces/<n>/<member_key>/` (opt-in); the `r-sma` member also carries
/// the `r_equity` tap (via `persist_traces_r`).
fn run_sweep(
strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid,
env: &project::Env,
) {
if persist
&& let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
let reg = env.registry();
let family = match strategy {
Strategy::SmaCross => sweep_family(persist.then_some(name), &data, env),
Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data, env),
Strategy::RSma => r_sma_sweep_family(persist.then_some(name), &data, grid, env),
Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid, env),
Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid, env),
};
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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 r-sma 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: &RGrid,
metric: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &project::Env,
) {
// 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, env); // per-instrument pip_or_refuse + has_symbol
let family = r_sma_sweep_family(None, &data, grid, env); // 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 = env.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: &RGrid, select: Selection,
env: &project::Env,
) {
if persist
&& let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
let reg = env.registry();
let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select, env);
let id =
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
{
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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
/// `RSma` arm sweeps the r-sma 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: &RGrid,
select: Selection, env: &project::Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
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, env);
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, env);
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::RSma => {
let space = r_sma_space();
walk_forward(roller, space, |w: WindowBounds| {
let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid, env);
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, env);
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, env: &project::Env,
) -> (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, env);
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,
env: &project::Env,
) -> (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, env);
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, env);
}
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, &RGrid::default(), Selection::Argmax,
&project::Env::std(),
);
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>, env: &project::Env) -> 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, env);
}
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, env: &project::Env) {
if persist
&& let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
let reg = env.registry();
let family = mc_family(persist.then_some(name), env);
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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 r-sma` (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: RGrid, block_len: usize, n_resamples: usize, seed: u64 },
}
/// `aura mc --strategy r-sma [--real <SYM>]`: run the r-sma 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
/// gross R (no costs); deterministic given `seed` (C1).
fn run_mc_r_bootstrap(
data: DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64,
env: &project::Env,
) {
println!("{}", mc_r_bootstrap_report(&data, grid, block_len, n_resamples, seed, env));
}
/// Assemble the `mc` R-bootstrap line: run the r-sma 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: &RGrid, block_len: usize, n_resamples: usize, seed: u64,
env: &project::Env,
) -> String {
let result = walkforward_family(Strategy::RSma, None, data, grid, Selection::Argmax, env);
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, &project::Env::std());
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(env: &project::Env) {
let reg = env.registry();
let members = match reg.load_family_members() {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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>, env: &project::Env) {
let reg = env.registry();
let members = match reg.load_family_members() {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
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");
}
}
}
}
}
}
/// The outcome of reproducing one persisted family: per member, whether its re-run
/// metrics are bit-identical to the stored metrics (C1).
struct ReproduceReport {
outcomes: Vec<(String, bool)>,
}
/// Reconstruct a member's bootstrap point from its recorded named params — the inverse
/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order
/// (deterministic for the same blueprint) and reads each knob's value from the manifest.
fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Cell> {
space
.iter()
.map(|ps| {
params
.iter()
.find(|(n, _)| n == &ps.name)
.map(|(_, s)| s.cell())
.unwrap_or_else(|| {
// A manifest missing a param the reloaded space expects is
// corrupted-on-disk data — exit cleanly (`aura:` + exit 1) like
// every other persisted-data failure on the reproduce path, not
// a panic.
eprintln!("aura: manifest is missing param {}", ps.name);
std::process::exit(1);
})
})
.collect()
}
/// Re-derive every member of a persisted sweep family from the content-addressed store
/// and compare to the stored result, against an explicit registry (testable seam).
fn reproduce_family_in(
reg: &Registry,
id: &str,
data: &DataSource,
env: &project::Env,
) -> ReproduceReport {
let members = reg.load_family_members().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else {
// reproduce is an action, not a lookup: an unknown id is a hard error (distinct
// from `runs family <id>`'s treat-as-empty exit 0).
eprintln!("aura: no such family '{id}'");
std::process::exit(1);
};
let pip = data.pip_size();
let window = data.full_window(env);
let mut outcomes = Vec::new();
for member in &family.members {
let stored = &member.report;
let hash = stored.manifest.topology_hash.clone().unwrap_or_else(|| {
eprintln!("aura: family member has no topology_hash; not a generated run");
std::process::exit(1);
});
let doc = reg
.get_blueprint(&hash)
.unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
})
.unwrap_or_else(|| {
eprintln!("aura: blueprint {hash} missing from store");
std::process::exit(1);
});
// Reload the stored blueprint per use: a Composite is !Clone, and both the
// param-space probe (below) and the re-run each consume one. The doc was
// canonical-serialized at store time, so every reload is infallible.
let reload = || {
blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| {
eprintln!("aura: stored blueprint {hash} does not parse: {e:?}");
std::process::exit(1);
})
};
// The param_space of the WRAPPED signal — its knobs carry the `r_sma`
// wrapper's `sma_signal.` node-path prefix, exactly the names the manifest
// recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the
// reproduce-side space name-matches the stored params (raw `signal.param_space()`
// would drop the prefix and `point_from_params` could not find the knobs).
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let space =
wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, false, true, None).param_space();
let point = point_from_params(&space, &stored.manifest.params);
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
// reproduce line would print a BLANK member label; the seed IS its realization
// identity, so surface `seed=<N>` instead. Sweep / walk-forward members echo their
// tuning params (the params-join), unchanged.
let label = match family.kind {
FamilyKind::MonteCarlo => format!("seed={}", stored.manifest.seed),
_ => stored
.manifest
.params
.iter()
.map(|(n, v)| format!("{n}={}", render_value(v)))
.collect::<Vec<_>>()
.join(", "),
};
// Realization-aware: a MonteCarlo member ran over a seed-driven synthetic walk,
// not the showcase — reconstruct it from manifest.seed so the re-run matches (C1).
// The seed is manifest-carried and identical across realizations; only the sources
// and window vary by kind.
let seed = stored.manifest.seed;
let (sources, member_window) = match family.kind {
FamilyKind::MonteCarlo => {
let s = synthetic_walk_sources(seed);
let w = window_of(&s).expect("non-empty synthetic walk");
(s, w)
}
FamilyKind::WalkForward => {
// each member is one OOS window: rebuild its windowed slice from the
// stored window bounds; the winner params come from the shared
// manifest->cells recovery below (as Sweep members do).
let (from, to) = stored.manifest.window;
let s = data.windowed_sources(from, to, env);
let w = window_of(&s).expect("non-empty OOS window");
(s, w)
}
_ => (data.run_sources(env), window),
};
let rerun = run_blueprint_member(
reload(),
&point,
&space,
sources,
member_window,
seed,
pip,
&hash,
env,
);
outcomes.push((label, rerun.metrics == stored.metrics));
}
ReproduceReport { outcomes }
}
/// `aura reproduce <family-id>`: re-derive a persisted sweep family from the
/// content-addressed blueprint store and verify each member reproduces bit-identically
/// (C18 "re-derives full results on demand"). Synthetic data this cycle (deterministic);
/// recorded-dataset reproduction rides the DataServer seam (#124).
fn reproduce_family(id: &str, env: &project::Env) {
let rep = reproduce_family_in(&env.registry(), id, &DataSource::Synthetic, env);
let total = rep.outcomes.len();
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
for (label, identical) in &rep.outcomes {
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
println!("{id} member {label} reproduced: {verdict}");
}
println!("reproduced {ok}/{total} members bit-identically");
if ok != total {
std::process::exit(1);
}
}
// --- 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>, env: &project::Env) -> RunReport {
if let Some(n) = trace
&& let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
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, env);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
// --- r-sma harness (the SMA-cross signal scored in R) --------------------
/// The r-sma 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 R_SMA_STOP_LENGTH: i64 = 3;
/// The r-sma vol-stop multiplier (1R = `k`·σ). Single source for the embedded
/// `StopRule::Vol` and its manifest record, like [`R_SMA_STOP_LENGTH`].
const R_SMA_STOP_K: f64 = 2.0;
/// The r-sma demo signal SMA lengths (fast/slow), bound at single-run build time.
/// Single source for the graph that runs (`r_sma_graph`), the signal that is hashed
/// (`sma_signal` → `topology_hash`), and the recorded manifest params — so the
/// hashed topology cannot drift from the executed one across the function boundary.
const R_SMA_FAST: i64 = 2;
const R_SMA_SLOW: i64 = 4;
/// The r-sma demo `Bias` scale (conviction magnitude). Single source for the
/// `Bias` node bound in `sma_signal` (the hashed + executed signal) and the
/// recorded manifest param, so the hashed topology cannot drift from the recorded
/// `bias_scale` across the function boundary — the same guard as the SMA lengths.
const R_SMA_BIAS_SCALE: f64 = 0.5;
/// Short-horizon realized-range window for vol-scaled slippage. Deliberately
/// distinct from `R_SMA_STOP_LENGTH` (3): scaling slippage by the stop's own
/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm
/// within the synthetic smoke fixture so the run path exercises non-zero slippage.
const SLIP_VOL_LENGTH: i64 = 5;
/// Which cost nodes the run-path cost graph builds. At least one field is `Some`
/// (the carrier `Option` is `None` when no cost flag was given).
struct CostConfig {
const_cost: Option<f64>, // --cost-per-trade
slip_vol_mult: Option<f64>, // --slip-vol-mult
carry_per_cycle: Option<f64>, // --carry-per-cycle
}
/// 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.
#[derive(Debug)]
enum RunData {
Synthetic,
Real { symbol: String, from: Option<i64>, to: Option<i64> },
}
/// A rise-fall-rise synthetic stream for the r-sma 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 r_sma_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 r-sma 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 r-sma signal leg as a standalone, serializable Composite: SMA-cross →
/// Bias. Exposes a `price` input-role and a single `bias` output — the boundary
/// shape a serialized blueprint round-trips (the `blueprint_serde_e2e.rs`
/// template). The two SMA knobs are bound when `Some` (byte-identical to the old
/// build-time bind) and left open as `fast.length` / `slow.length` when `None`.
fn sma_signal(fast_len: Option<i64>, slow_len: Option<i64>) -> Composite {
let mut g = GraphBuilder::new("sma_signal");
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(R_SMA_BIAS_SCALE)),
);
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, vec![fast.input("series"), slow.input("series")]);
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.expose(exposure.output("bias"), "bias");
g.build().expect("sma_signal wiring resolves")
}
/// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158).
/// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`)
/// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the
/// two surfaces agree by construction over the same canonical bytes. Research-side
/// (aura-cli), off the frozen engine (invariant 8).
fn content_id(canonical_json: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(canonical_json.as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a
/// signal blueprint — the run's `topology_hash` (#158).
fn topology_hash(signal: &Composite) -> String {
content_id(&blueprint_to_json(signal).expect("a buildable signal serializes"))
}
/// The r-sma 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
/// now nest in the `sma_signal` composite, so they land in `param_space` as
/// `sma_signal.fast.length` / `sma_signal.slow.length`, rendered back to the
/// bare `fast.length` / `slow.length` manifest names by `r_sma_friendly_name`).
/// 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 r_sma_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<(CostConfig, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
) -> Composite {
wrap_r(
sma_signal(fast_len, slow_len),
tx_eq,
tx_ex,
tx_r,
tx_req,
stop_open,
reduce,
cost,
)
}
/// Wrap a `signal` composite (a `price`→`bias` leg) in the R run
/// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the
/// r_equity / cost legs. The signal is nested and its `price`/`bias` boundary is
/// wired across; everything else is verbatim from the old `r_sma_graph` body, so
/// a serialized signal loaded via `blueprint_from_json` runs through exactly the
/// scaffolding the Rust-built signal does.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn wrap_r(
signal: Composite,
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>)>,
stop_open: bool,
reduce: bool,
cost: Option<(
CostConfig,
mpsc::Sender<(Timestamp, Vec<Scalar>)>,
mpsc::Sender<(Timestamp, Vec<Scalar>)>,
)>,
) -> Composite {
let mut g = GraphBuilder::new("r_sma");
// SMA-cross signal → Bias, nested as a serializable `price`→`bias` leg.
let sig = g.add(BlueprintNode::Composite(signal));
// 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: R_SMA_STOP_LENGTH, k: R_SMA_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))
};
// Hoisted above the single main feed: the short-horizon vol proxy iff a
// vol-slippage cost is actually wired (run path, non-reduce), so its `price`
// inputs join the one `price_targets` array (no second feed call).
let vol_proxy = match &cost {
Some((cfg, _, _)) if !reduce && cfg.slip_vol_mult.is_some() => {
let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
let vrange = g.add(Sub::builder().named("slip_vol_range"));
g.connect(vhi.output("value"), vrange.input("lhs"));
g.connect(vlo.output("value"), vrange.input("rhs"));
Some((vhi, vlo, vrange))
}
_ => None,
};
let price = g.source_role("price", ScalarKind::F64);
let mut price_targets = vec![
sig.input("price"),
broker.input("price"),
exec.input("price"),
];
if let Some((vhi, vlo, _)) = vol_proxy {
price_targets.push(vhi.input("series"));
price_targets.push(vlo.input("series"));
}
g.feed(price, price_targets);
g.connect(sig.output("bias"), broker.input("exposure"));
g.connect(sig.output("bias"), ex.input("col[0]"));
g.connect(sig.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((cfg, tx_net, tx_cost)) = cost {
// Build the active cost nodes (same conditional order), tracking the vol
// node's index so its `cost[k].volatility` role can be fed below.
let mut cost_nodes = Vec::new();
let mut vol_slot = None;
if let Some(cpt) = cfg.const_cost {
cost_nodes.push(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt)));
}
if let Some(svm) = cfg.slip_vol_mult {
vol_slot = Some(cost_nodes.len());
cost_nodes.push(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm)));
}
if let Some(cpc) = cfg.carry_per_cycle {
cost_nodes.push(CarryCost::builder().bind("carry_per_cycle", Scalar::f64(cpc)));
}
// A single cost_graph composite fans the shared PM-geometry into the active
// cost nodes and sums their per-field charges into the run's cost streams.
let cg = g.add(cost_graph(cost_nodes));
g.connect(exec.output("closed_this_cycle"), cg.input("closed"));
g.connect(exec.output("open"), cg.input("open"));
g.connect(exec.output("entry_price"), cg.input("entry_price"));
g.connect(exec.output("stop_price"), cg.input("stop_price"));
if let Some(k) = vol_slot {
let (_, _, vrange) = vol_proxy.expect("vol proxy is built whenever slip_vol_mult is set");
let role: &'static str = format!("cost[{k}].volatility").leak();
g.connect(vrange.output("value"), cg.input(role));
}
// 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(cg.output("cum_cost_in_r"), net_eq.input("term[2]"));
g.connect(cg.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 aggregate cost record summarize_r folds (col 0 per-close, col 2 window-end).
let cost_rec = g.add(Recorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
Firing::Any,
tx_cost,
));
g.connect(cg.output("cost_in_r"), cost_rec.input("col[0]"));
g.connect(cg.output("cum_cost_in_r"), cost_rec.input("col[1]"));
g.connect(cg.output("open_cost_in_r"), cost_rec.input("col[2]"));
}
}
g.build().expect("r_sma wiring resolves")
}
/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the
/// r-sma run paths feed to the harness: the built-in synthetic R stream,
/// or a lazily-streamed real M1 close source (with its sidecar pip + probed window).
/// One definition shared by `run_r_sma` and `run_signal_r` so the
/// source/window/pip wiring cannot drift between the two paths.
#[allow(clippy::type_complexity)]
fn resolve_run_data(
data: &RunData,
env: &project::Env,
) -> (
Vec<Box<dyn aura_engine::Source>>,
(Timestamp, Timestamp),
f64,
) {
match data {
RunData::Synthetic => {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(r_sma_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, env);
(vec![source], window, pip_size)
}
}
}
/// Run a signal blueprint through the R scaffolding: hash the signal,
/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap,
/// run over `data`, and build the RunReport (manifest carries topology_hash).
/// The single construction+run path shared by the `aura run <blueprint.json>` CLI
/// arm and its bit-identical test.
fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env,
) -> RunReport {
let topo = topology_hash(&signal); // before signal is consumed
let names: Vec<String> = signal
.param_space()
.iter()
.map(|p| p.name.clone())
.collect();
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
// The req tap (r_equity recorder) is wired but not persisted on this path; keep the
// receiver alive so the sink's sends do not fail, but do not drain it.
let (tx_req, _rx_req) = mpsc::channel();
let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, false, false, None);
let flat = wrapped
.compile_with_params(params)
.expect("signal binds + wraps to a valid harness");
let mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
let (sources, window, pip_size) = resolve_run_data(&data, env);
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 named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size);
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &[]));
RunReport { manifest, metrics }
}
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
/// reproduced member re-derives bit-identically (C1). `signal` is a freshly
/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound
/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash.
#[allow(clippy::too_many_arguments)]
fn run_blueprint_member(
signal: Composite,
point: &[Cell],
space: &[ParamSpec],
sources: Vec<Box<dyn aura_engine::Source>>,
window: (Timestamp, Timestamp),
seed: u64,
pip: f64,
topo: &str,
env: &project::Env,
) -> RunReport {
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 = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None)
.bootstrap_with_cells(point)
.expect("member bootstraps (point kind-checked against param_space)");
h.run(sources);
let named = zip_params(space, point); // by-name params for the manifest record
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
manifest.broker = r_sma_broker_label(pip);
manifest.topology_hash = Some(topo.to_string());
manifest.project = env.provenance();
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 }
}
/// The exact wrapped probe the loaded-blueprint sweep resolves its axes
/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound,
/// reduce, no cost), taps discarded. `param_space()` on it is the axis
/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single
/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so
/// the listed names track the swept names by construction (incl. across #159's
/// harness retirement). The reload is infallible under the SAME
/// dispatch-boundary contract the callers already rely on: the doc is
/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]`
/// boundary before this runs, so a malformed doc has already exited 2 and
/// never reaches the `.expect`.
fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite {
let signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None)
}
/// `aura sweep <blueprint.json> --list-axes`: one `<name>:<kind>` line per open
/// sweepable knob, in `param_space()` order; a closed blueprint prints nothing.
/// Names are exactly what `--axis` binds (same probe as the sweep terminal).
fn list_blueprint_axes(doc: &str, env: &project::Env) {
for p in blueprint_axis_probe(doc, env).param_space() {
println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp
}
}
/// Sweep a serialized signal `doc` over user-named param-space axes — the structural
/// twin of [`r_sma_sweep_family`], with three deviations. (1) The signal source is
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
/// string — a named error, never a panic. A FULLY BOUND (closed) blueprint has an empty
/// `param_space` — nothing to sweep — and is refused up front with a clear message
/// (the symmetric inverse of [`blueprint_mc_family`]'s closed-blueprint requirement),
/// pre-empting the misleading `UnknownKnob(<axis>)` the per-axis resolve would emit for a
/// knob that is not unknown but bound out. Every member manifest carries the shared
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the mirror's
/// default (no-trace) arm.
fn blueprint_sweep_family(
doc: &str,
axes: &[(String, Vec<Scalar>)],
data: &DataSource,
env: &project::Env,
) -> Result<SweepFamily, String> {
// The doc is parse-validated at the dispatch boundary (with file-path context),
// so every reload here is infallible: the builder has a single error contract —
// the `BindError` returned by the sweep terminal — and no hidden process exit.
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let topo = topology_hash(&reload(doc));
let pip = data.pip_size();
let window = data.full_window(env);
// a single throwaway floated build, only to resolve param_space (borrow) then seed
// the named axes (move) — its taps are never drained. The wrapped probe is the one
// `blueprint_axis_probe` single-sources (identical `false, true, None` wrap).
let probe = blueprint_axis_probe(doc, env);
let space = probe.param_space();
// A fully bound blueprint has no open knob — there is nothing to sweep. Refuse it up
// front (mirroring blueprint_mc_family's closed-blueprint guard, inverted) BEFORE the
// per-axis resolve, which would otherwise return `UnknownKnob(<axis>)` — misleading, as
// the named knob is not unknown but bound out. Exit-free: the message is returned for
// the IO wrapper to render + exit 2, so the precondition is unit-testable.
if space.is_empty() {
return Err(
"this blueprint is fully bound; nothing to sweep (use `aura sweep <bp> --list-axes` \
to confirm there are no open knobs)"
.to_string(),
);
}
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
run_blueprint_member(reload(doc), point, &space, data.run_sources(env), window, 0, pip, &topo, env)
})
// render the sweep terminal's BindError to a message (the fn's String error contract),
// so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper.
.map_err(|e| format!("{e:?}"))
}
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
/// `[from,to]` — the windowed, lattice-carrying twin of `r_sma_sweep_over` and
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
/// sweep terminal (no panic, no hidden exit) for the caller to render.
fn blueprint_sweep_over(
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &project::Env,
) -> Result<(SweepFamily, Vec<usize>), BindError> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let pip = data.pip_size();
let topo = topology_hash(&reload(doc));
let probe = blueprint_axis_probe(doc, env);
let space = probe.param_space();
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env);
let window = window_of(&sources).expect("non-empty in-sample window");
run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env)
})
}
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
/// blueprint — the loaded-member analog of `run_oos_r`. The reduce-mode member
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
/// segment is empty (an empty segment leaves the stitched curve unbroken).
#[allow(clippy::too_many_arguments)]
fn run_oos_blueprint(
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
topo: &str, data: &DataSource, env: &project::Env,
) -> (Vec<(Timestamp, f64)>, RunReport) {
let reload = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env);
let window = window_of(&sources).expect("non-empty out-of-sample window");
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env);
(Vec::new(), report)
}
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
/// winner OOS. Structural twin of `walkforward_family`'s `RSma` arm — the
/// generic `walk_forward` driver + `select_winner` reused; only the per-window
/// sweep/OOS source the loaded blueprint. In-closure errors (a bad `--axis`)
/// `exit(2)` with the sweep terminal's message, as the hard-wired arm does.
fn blueprint_walkforward_family(
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
env: &project::Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
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);
}
};
let space = blueprint_axis_probe(doc, env).param_space();
let topo = topology_hash(&blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"));
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic,
// so pre-flighting the first IS window here surfaces the error exactly once (it fails in
// `resolve_axes`, before any member runs); a per-window resolve then cannot re-raise.
let first_is = WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling)
.expect("roller config validated just above")
.next()
.expect("roller yields >= 1 window (validated above)")
.is;
if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data, env) {
eprintln!("aura: {e:?}");
std::process::exit(2);
}
walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env)
.expect("axes validated in the dispatch-boundary pre-flight");
let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
let (oos_equity, mut oos_report) =
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env);
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
})
}
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — the `mc_family`
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
vec![Box::new(spec.source(seed))]
}
/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed
/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The
/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free
/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling
/// [`blueprint_sweep_family`]: the IO wrapper [`run_blueprint_mc`] renders it to stderr +
/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw
/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce
/// re-runs), so reproduction is bit-identical (C1); every member carries the shared
/// `topology_hash`.
fn blueprint_mc_family(
doc: &str, n_seeds: u64, data: &DataSource, env: &project::Env,
) -> Result<McFamily, String> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let topo = topology_hash(&reload(doc));
let pip = data.pip_size();
// probe the wrapped param_space (the same probe the sweep resolves against);
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
let space = blueprint_axis_probe(doc, env).param_space();
if !space.is_empty() {
// Exit-free like blueprint_sweep_family: the builder's single error contract is this
// returned message (no hidden process exit), so the rejection is unit-testable; the IO
// wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary.
return Err(format!(
"mc requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
space.len()
));
}
// Closed blueprint -> an empty base point (as `aura run <blueprint.json>`); the MC
// draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws
// to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1),
// deterministic in seed-input order — exactly as the sibling `mc_family` does. Each draw
// re-runs the shared reduce-mode member path over its own seeded synthetic walk.
let seeds: Vec<u64> = (1..=n_seeds).collect();
let base_point: Vec<Scalar> = Vec::new();
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk");
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env)
});
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
// the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the
// walk is long), so the "distribution" is a single point masquerading as a family: a wrong
// result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's
// `seed` differs per draw by construction, so a whole-report compare could never detect the
// collapse; the metrics are the realization the seed is meant to move. A single-draw MC
// (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes.
if family.draws.len() >= 2
&& family
.draws
.iter()
.all(|d| d.report.metrics == family.draws[0].report.metrics)
{
return Err(
"mc is vacuous: every seed produced an identical result — the strategy never warmed \
over the synthetic walk, so no seed reached a distinguishable realization; use a \
shallower-lookback blueprint or a longer walk"
.to_string(),
);
}
Ok(family)
}
/// `aura sweep <blueprint.json> --axis <name>=<csv> …`: sweep a loaded signal over its
/// named param-space axes (the cycle-2 World/C21 verb). Builds the family via
/// [`blueprint_sweep_family`], surfaces an unknown / kind-mismatched axis as a named
/// error (stderr + exit 2, never a panic), ALWAYS records it as a `FamilyKind::Sweep`
/// family (C18/C21 lineage, exactly as the other family verbs), and prints each member
/// carrying the assigned `family_id` via [`family_member_line`] — so a printed member is
/// linkable back to its stored family, like `run_sweep` / `run_walkforward`.
///
/// Divergence from `run_sweep` (the one place it does less): the blueprint sweep is
/// reduce-only this cycle — [`blueprint_sweep_family`] writes no per-member traces — so
/// `persist`/`--trace` neither writes trace files nor reserves a trace-store name (that
/// reservation would guard a write that never happens, and could spuriously reject a
/// valid sweep on a name collision). `persist` is therefore not yet load-bearing here;
/// it is retained for the deferred per-member trace path. The family record itself is
/// written unconditionally, so lineage (C18/C21) holds whether or not `--trace` is given.
fn run_blueprint_sweep(
doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, persist: bool, data: DataSource,
env: &project::Env,
) {
let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional
let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
});
let reg = env.registry();
// Store the canonical blueprint ONCE, keyed by the family's shared topology_hash —
// exactly the bytes whose SHA256 the members carry (#164 byte-canonical, round-trip
// idempotent). One stored topology per family (C18/C11/C12).
let topo = family.points[0]
.report
.manifest
.topology_hash
.clone()
.expect("a blueprint sweep stamps every member's topology_hash");
let canonical = blueprint_to_json(
&blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary"),
)
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
// Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`.
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
for pt in &family.points {
println!("{}", family_member_line(&id, &pt.report));
}
}
/// `aura walkforward <blueprint.json> --axis …`: build the loaded-blueprint
/// IS-refit walk-forward, store the canonical blueprint ONCE keyed by the shared
/// `topology_hash` (the C18 hook, so `aura reproduce` re-derives it), record it as
/// a `FamilyKind::WalkForward` family, and print each OOS member line + the summary.
/// Mirrors `run_blueprint_sweep` (content-addressed family verb; no ensure_name_free).
fn run_blueprint_walkforward(
doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, data: DataSource, select: Selection,
env: &project::Env,
) {
let result = blueprint_walkforward_family(doc, axes, &data, select, env);
let reg = env.registry();
let topo = result.windows[0]
.run
.oos_report
.manifest
.topology_hash
.clone()
.expect("a blueprint walk-forward stamps every member's topology_hash");
let canonical = blueprint_to_json(
&blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"),
)
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); });
let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) {
Ok(id) => id,
Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); }
};
for w in &result.windows {
println!("{}", family_member_line(&id, &w.run.oos_report));
}
println!("{}", walkforward_summary_json(&result));
}
/// `aura mc <blueprint.json> --seeds N`: build a Monte-Carlo family from a loaded CLOSED
/// blueprint (the World/C21 verb), store the canonical blueprint ONCE keyed by the shared
/// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a
/// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line
/// (carrying the seed) plus the aggregate — mirroring `run_mc` / `run_blueprint_sweep`.
fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &project::Env) {
let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
});
let reg = env.registry();
// Store the canonical blueprint ONCE, keyed by the family's shared topology_hash.
let topo = family.draws[0]
.report
.manifest
.topology_hash
.clone()
.expect("a blueprint mc stamps every member's topology_hash");
let canonical = blueprint_to_json(
&blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary"),
)
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
for draw in &family.draws {
println!("{}", mc_member_line(&id, draw.seed, &draw.report));
}
println!("{}", mc_aggregate_json(&family.aggregate));
}
/// The r-sma 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 r_sma_graph.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn r_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("r_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 r_sma_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("r_breakout wiring resolves")
}
/// The EWMA Bollinger-band mean-reversion candidate, mirroring
/// `r_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 `r_breakout_graph`.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn r_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("r_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 r_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("r_meanrev wiring resolves")
}
/// `aura run --harness r-sma [--real <SYM> [--from][--to]] [--trace <n>]`: build the
/// dual-tap r-sma 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_r_sma(
data: RunData,
trace: Option<&str>,
const_cost: Option<f64>,
slip_vol_mult: Option<f64>,
carry_per_cycle: Option<f64>,
env: &project::Env,
) -> RunReport {
if let Some(n) = trace
&& let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(1);
}
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 =
if const_cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some() {
Some((
CostConfig {
const_cost,
slip_vol_mult,
carry_per_cycle,
},
tx_net,
tx_cost,
))
} else {
None
};
let flat = r_sma_graph(
tx_eq,
tx_ex,
tx_r,
tx_req,
Some(R_SMA_FAST),
Some(R_SMA_SLOW),
false,
false,
cost_bundle,
)
.compile_with_params(&[])
.expect("valid r-sma blueprint");
let mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
let (sources, window, pip_size) = resolve_run_data(&data, env);
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(R_SMA_FAST)),
("sma_slow".to_string(), Scalar::i64(R_SMA_SLOW)),
("bias_scale".to_string(), Scalar::f64(R_SMA_BIAS_SCALE)),
("stop_length".to_string(), Scalar::i64(R_SMA_STOP_LENGTH)), // vol_stop EWMA length
("stop_k".to_string(), Scalar::f64(R_SMA_STOP_K)), // vol_stop multiplier (1R = k·σ)
],
window,
0,
pip_size,
);
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topology_hash(&sma_signal(
Some(R_SMA_FAST),
Some(R_SMA_SLOW),
)));
// Project provenance is paired with `topology_hash` — the same #158
// reproducibility-anchor group, exactly the three spots that already stamp
// `topology_hash` today (this fn, `run_signal_r`, `run_blueprint_member`).
manifest.project = env.provenance();
if let Some(name) = trace {
persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows, &net_rows, env);
}
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 r-sma 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>)],
env: &project::Env,
) {
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) = env.trace_store().write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(1);
}
}
/// 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.
#[derive(Debug)]
enum HarnessKind {
Sma,
Macd,
RSma,
}
/// 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`, r-sma only this cycle).
#[derive(Debug)]
struct RunArgs {
harness: HarnessKind,
data: RunData,
trace: Option<String>,
cost: Option<f64>,
slip_vol_mult: Option<f64>,
carry_per_cycle: Option<f64>,
}
/// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells
/// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells
/// bind positionally against the loaded signal's `param_space`. A malformed array is
/// refused with the flag named, never silently coerced to an empty param vector
/// (refuse-don't-guess, C10) — a dropped param would bootstrap a *different* graph.
fn parse_param_cells(json: &str) -> Result<Vec<Scalar>, String> {
serde_json::from_str(json).map_err(|e| format!("--params: {e}"))
}
/// Lex a `--axis` CSV into typed Scalars by shape: an integer-shaped token is i64,
/// otherwise f64. `resolve_axes` kind-checks each value against the param's declared
/// kind afterwards (a mismatch is a named error, not a panic).
fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
csv.split(',').map(|t| {
let t = t.trim();
if t.is_empty() { return None; }
match t.parse::<i64>() {
Ok(i) => Some(Scalar::i64(i)),
Err(_) => t.parse::<f64>().ok().map(Scalar::f64),
}
}).collect()
}
/// 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, env: &project::Env) -> Result<RunReport, String> {
let trace = args.trace.as_deref();
Ok(match (args.harness, args.data) {
(HarnessKind::Sma, RunData::Synthetic) => run_sample(trace, env),
(HarnessKind::Macd, RunData::Synthetic) => run_macd(trace, env),
(HarnessKind::RSma, data) => {
run_r_sma(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle, env)
}
(HarnessKind::Sma, RunData::Real { symbol, from, to }) => {
run_sample_real(&symbol, from, to, trace, env)
}
(HarnessKind::Macd, RunData::Real { .. }) => {
return Err("the macd harness has no --real form".to_string())
}
})
}
// ============================== clap parser surface ==============================
// The declarative argument grammar. clap owns argv tokenizing, scoped `--help`,
// `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*`
// handlers below convert each `*Cmd` into the argument shapes the existing execution
// fns accept, reusing the value helpers (`Strategy`, `RGrid`, `Selection`,
// `DataSource::from_choice`, `parse_scalar_csv`, `parse_csv_list`, `parse_select`,
// `parse_param_cells`). The four dual-grammar subcommands carry an optional
// `[blueprint]` positional; a first-positional that names an existing `.json` file
// (`is_blueprint_file`) selects the loaded-blueprint branch, otherwise the built-in
// grammar. Usage errors (clap parse + argv-applicability guards) exit 2; runtime failures exit 1.
/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION`
/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`.
#[derive(Parser)]
#[command(name = "aura", version, about = "Author, backtest, and validate trading strategies — research CLI", infer_long_args = true)]
struct Cli {
#[command(subcommand)]
command: Command,
/// Load the project dylib from target/release instead of target/debug.
#[arg(long, global = true)]
release: bool,
}
#[derive(Subcommand)]
enum Command {
/// Run a single backtest (built-in harness or a loaded blueprint).
Run(RunCmd),
/// Render a recorded run's trace to an HTML chart.
Chart(ChartCmd),
/// Emit / construct / introspect a graph.
Graph(GraphCmd),
/// Sweep a parameter grid over a strategy or a loaded blueprint.
Sweep(SweepCmd),
/// Walk-forward validation over a strategy or a loaded blueprint.
Walkforward(WalkforwardCmd),
/// Grade one candidate across multiple instruments.
Generalize(GeneralizeCmd),
/// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint.
Mc(McCmd),
/// List or inspect recorded run families.
Runs(RunsCmd),
/// Reproduce a recorded family by content id.
Reproduce(ReproduceCmd),
}
#[derive(Args)]
struct ChartCmd {
/// The recorded run or family name to chart.
name: String,
/// Chart only the given tap.
#[arg(long)]
tap: Option<String>,
/// Render stacked panels instead of an overlay.
#[arg(long)]
panels: bool,
}
#[derive(Args)]
struct GraphCmd {
#[command(subcommand)]
sub: Option<GraphSub>,
}
#[derive(Subcommand)]
enum GraphSub {
/// Construct a graph from a stdin op-list.
Build,
/// Introspect a graph.
Introspect(GraphIntrospectCmd),
}
#[derive(Args)]
struct GraphIntrospectCmd {
/// List the closed node vocabulary (one node type per line).
#[arg(long)]
vocabulary: bool,
/// Describe one node type's ports by name.
#[arg(long)]
node: Option<String>,
/// List the graph's unwired (unbound) ports.
#[arg(long)]
unwired: bool,
/// Print the graph's content id (topology hash).
#[arg(long)]
content_id: bool,
}
#[derive(Args)]
struct GeneralizeCmd {
/// The candidate strategy; must be r-sma (the candidate must produce R).
#[arg(long)]
strategy: Option<String>,
/// Comma-separated instrument list (>=2 distinct, required).
#[arg(long)]
real: Option<String>,
/// Candidate fast-MA length (single value; required).
#[arg(long)]
fast: Option<i64>,
/// Candidate slow-MA length (single value; required).
#[arg(long)]
slow: Option<i64>,
/// Candidate stop length (single value; required).
#[arg(long)]
stop_length: Option<i64>,
/// Candidate stop-k multiple (single value; required).
#[arg(long)]
stop_k: Option<f64>,
/// Window start (Unix ms, inclusive).
#[arg(long)]
from: Option<i64>,
/// Window end (Unix ms, inclusive).
#[arg(long)]
to: Option<i64>,
/// Grading metric (default expectancy_r).
#[arg(long)]
metric: Option<String>,
/// Family name (default generalize).
#[arg(long)]
name: Option<String>,
}
#[derive(Args)]
struct RunsCmd {
#[command(subcommand)]
sub: RunsSub,
}
#[derive(Subcommand)]
enum RunsSub {
/// List recorded families.
Families,
/// Inspect one family, optionally ranked by a metric.
Family {
id: String,
/// The literal keyword `rank`, if a ranking metric follows.
rank_kw: Option<String>,
/// The metric to rank by (only valid after `rank`).
metric: Option<String>,
},
}
#[derive(Args)]
struct ReproduceCmd {
/// The family content id to reproduce.
id: String,
}
#[derive(Args)]
struct RunCmd {
/// A serialized signal blueprint (.json). An existing file selects the
/// loaded-blueprint grammar; otherwise the built-in harness grammar.
blueprint: Option<String>,
/// Built-in harness: sma | macd | r-sma (default sma).
#[arg(long)]
harness: Option<String>,
/// Blueprint params (JSON scalar-cell array; .json mode).
#[arg(long)]
params: Option<String>,
/// Blueprint seed (.json mode).
#[arg(long)]
seed: Option<u64>,
/// Real instrument symbol to backtest over (recorded data); omit for the synthetic stream.
#[arg(long)]
real: Option<String>,
/// Window start (Unix ms, inclusive); requires --real.
#[arg(long)]
from: Option<i64>,
/// Window end (Unix ms, inclusive); requires --real.
#[arg(long)]
to: Option<i64>,
/// Persist the run's taps under `runs/traces/<name>` and still print the report.
#[arg(long)]
trace: Option<String>,
// `allow_hyphen_values`: the cost rates accept a negative value (e.g. `-0.5`) as
// the flag value so the non-negativity guard (`run_args_from`) can surface the
// named `must be non-negative` refusal, rather than clap rejecting `-0.5` as an
// unknown option before the value is parsed.
#[arg(long, allow_hyphen_values = true, long_help = "per-trade cost in price units. r-sma only; charged in R as cost/|entry-stop|.")]
cost_per_trade: Option<f64>,
#[arg(long, allow_hyphen_values = true, long_help = "slippage multiplier on realized vol. r-sma only.")]
slip_vol_mult: Option<f64>,
#[arg(long, allow_hyphen_values = true, long_help = "carry in price units per ENGINE cycle (not per day, not an overnight swap). r-sma only.")]
carry_per_cycle: Option<f64>,
}
#[derive(Args)]
struct SweepCmd {
/// A loaded blueprint (.json); omit for the built-in --strategy grammar.
blueprint: Option<String>,
/// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma).
#[arg(long)]
strategy: Option<String>,
/// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream.
#[arg(long)]
real: Option<String>,
/// Window start (Unix ms, inclusive); requires --real.
#[arg(long)]
from: Option<i64>,
/// Window end (Unix ms, inclusive); requires --real.
#[arg(long)]
to: Option<i64>,
/// Family name (records to the registry without persisting per-member traces).
#[arg(long)]
name: Option<String>,
/// Family name that also persists each member's taps (mutually exclusive with --name).
#[arg(long)]
trace: Option<String>,
/// Fast-MA grid axis (comma-separated values).
#[arg(long)]
fast: Option<String>,
/// Slow-MA grid axis (comma-separated values).
#[arg(long)]
slow: Option<String>,
/// Stop-length grid axis (comma-separated values).
#[arg(long)]
stop_length: Option<String>,
/// Stop-k grid axis (comma-separated values).
#[arg(long)]
stop_k: Option<String>,
/// Breakout-channel grid axis (comma-separated values; r-breakout).
#[arg(long)]
channel: Option<String>,
/// Mean-reversion window grid axis (comma-separated values; r-meanrev).
#[arg(long)]
window: Option<String>,
/// Mean-reversion band-k grid axis (comma-separated values; r-meanrev).
#[arg(long)]
band_k: Option<String>,
/// Blueprint sweep axis `<name>=<csv>` (repeatable; .json mode).
#[arg(long)]
axis: Vec<String>,
/// List a loaded blueprint's sweepable axes and exit (.json mode, stands alone).
#[arg(long)]
list_axes: bool,
}
#[derive(Args)]
struct WalkforwardCmd {
/// A loaded blueprint (.json); omit for the built-in --strategy grammar.
blueprint: Option<String>,
/// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma).
#[arg(long)]
strategy: Option<String>,
/// Real instrument symbol to validate over (recorded data); omit for the synthetic stream.
#[arg(long)]
real: Option<String>,
/// Window start (Unix ms, inclusive); requires --real.
#[arg(long)]
from: Option<i64>,
/// Window end (Unix ms, inclusive); requires --real.
#[arg(long)]
to: Option<i64>,
/// Family name (records to the registry without persisting per-member traces).
#[arg(long)]
name: Option<String>,
/// Family name that also persists each OOS window's taps (mutually exclusive with --name).
#[arg(long)]
trace: Option<String>,
/// Fast-MA IS-refit grid axis (comma-separated values).
#[arg(long)]
fast: Option<String>,
/// Slow-MA IS-refit grid axis (comma-separated values).
#[arg(long)]
slow: Option<String>,
/// Stop-length IS-refit grid axis (comma-separated values).
#[arg(long)]
stop_length: Option<String>,
/// Stop-k IS-refit grid axis (comma-separated values).
#[arg(long)]
stop_k: Option<String>,
/// In-sample winner selection: argmax | plateau:mean | plateau:worst (default argmax).
#[arg(long)]
select: Option<String>,
/// Blueprint IS-refit axis `<name>=<csv>` (repeatable, >=1 required; .json mode).
#[arg(long)]
axis: Vec<String>,
}
#[derive(Args)]
struct McCmd {
/// A loaded blueprint (.json); omit for the built-in grammar.
blueprint: Option<String>,
/// Built-in strategy: r-sma selects the R-bootstrap path (else the synthetic seed-resweep).
#[arg(long)]
strategy: Option<String>,
/// Real instrument symbol for the R-bootstrap (recorded data); requires --strategy r-sma.
#[arg(long)]
real: Option<String>,
/// Window start (Unix ms, inclusive); requires --real.
#[arg(long)]
from: Option<i64>,
/// Window end (Unix ms, inclusive); requires --real.
#[arg(long)]
to: Option<i64>,
/// Family name for the synthetic seed-resweep (records without persisting traces).
#[arg(long)]
name: Option<String>,
/// Family name that also persists each seed's taps (mutually exclusive with --name).
#[arg(long)]
trace: Option<String>,
/// Fast-MA grid axis for the R-bootstrap (comma-separated values).
#[arg(long)]
fast: Option<String>,
/// Slow-MA grid axis for the R-bootstrap (comma-separated values).
#[arg(long)]
slow: Option<String>,
/// Stop-length grid axis for the R-bootstrap (comma-separated values).
#[arg(long)]
stop_length: Option<String>,
/// Stop-k grid axis for the R-bootstrap (comma-separated values).
#[arg(long)]
stop_k: Option<String>,
/// Moving-block bootstrap block length (R-bootstrap; default 1).
#[arg(long)]
block_len: Option<usize>,
/// Number of bootstrap resamples (R-bootstrap; default 1000).
#[arg(long)]
resamples: Option<usize>,
/// Bootstrap RNG seed (R-bootstrap; default 1).
#[arg(long)]
seed: Option<u64>,
/// Number of synthetic draws (required; .json mode).
#[arg(long)]
seeds: Option<u64>,
}
/// The dual-grammar discriminator: a first-positional that names an existing
/// `.json` file selects the loaded-blueprint branch. Single-sourced so the
/// four dual-grammar subcommands stay in lockstep.
fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
arg.as_deref()
.filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file())
}
/// Resolve a `[blueprint].json`-branch `--real`/`--from`/`--to` into a `RunData`,
/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to`
/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2).
fn run_data_from(real: Option<&str>, from: Option<i64>, to: Option<i64>) -> RunData {
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]";
match real {
Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to },
Some(_) => {
eprintln!("aura: {usage}");
std::process::exit(2);
}
None if from.is_some() || to.is_some() => {
eprintln!("aura: {usage}");
std::process::exit(2);
}
None => RunData::Synthetic,
}
}
/// Build the existing `RunArgs` (the type `run_dispatch` consumes) from the
/// built-in-branch `RunCmd` fields — the old `parse_run_args` body minus the argv
/// tokenizing clap now owns. A stray positional (a non-`.json`-file `[blueprint]`)
/// is an unexpected token; the harness-enum map, the cost-flags-require-R-harness
/// guard, and the non-negative-rate checks reuse the existing message strings.
fn run_args_from(a: &RunCmd) -> Result<RunArgs, String> {
let usage = || "Usage: aura run [--harness <sma|macd|r-sma>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>]".to_string();
// A positional that is not an existing `.json` blueprint is an unexpected token
// (the built-in run grammar takes only flags) — the #16 strict reading.
if a.blueprint.is_some() {
return Err(usage());
}
let harness = match a.harness.as_deref() {
None | Some("sma") => HarnessKind::Sma,
Some("macd") => HarnessKind::Macd,
Some("r-sma") => HarnessKind::RSma,
Some(_) => return Err(usage()),
};
// A parsed-but-negative rate is a named refusal (a sign typo distinguished from a
// mistyped flag), matching the old `parse_nonneg_rate` message. Checked before the
// R-harness guard so the precedence matches the old per-flag parse order.
let nonneg = |flag: &str, v: Option<f64>| -> Result<Option<f64>, String> {
match v {
Some(x) if x < 0.0 => Err(format!("{flag} must be non-negative, got {x}")),
other => Ok(other),
}
};
let cost = nonneg("--cost-per-trade", a.cost_per_trade)?;
let slip_vol_mult = nonneg("--slip-vol-mult", a.slip_vol_mult)?;
let carry_per_cycle = nonneg("--carry-per-cycle", a.carry_per_cycle)?;
if !matches!(harness, HarnessKind::RSma)
&& (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some())
{
return Err(
"cost flags require an R-evaluator harness (r-sma); \
--harness sma/macd produces no R to charge against"
.to_string(),
);
}
if a.real.is_none() && (a.from.is_some() || a.to.is_some()) {
return Err(usage());
}
let data = match a.real.as_deref() {
Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from: a.from, to: a.to },
Some(_) => return Err(usage()),
None => RunData::Synthetic,
};
Ok(RunArgs { harness, data, trace: a.trace.clone(), cost, slip_vol_mult, carry_per_cycle })
}
/// The old `parse_generalize_args` body minus tokenizing: convert `GeneralizeCmd`
/// into the `run_generalize` argument shape. The candidate is a single cell (clap
/// already types `--fast`/etc. as one `i64`/`f64`), all four knobs required, `--real`
/// a `>=2`-distinct comma list; every refusal reuses the old message string.
#[allow(clippy::type_complexity)]
fn generalize_args_from(
a: &GeneralizeCmd,
) -> Result<(String, Vec<String>, RGrid, String, Option<i64>, Option<i64>), String> {
if let Some(s) = a.strategy.as_deref()
&& s != "r-sma"
{
return Err("generalize requires --strategy r-sma (the candidate must produce R)".to_string());
}
let symbols: Vec<String> = match a.real.as_deref() {
None => return Err("generalize requires --real <SYM1,SYM2,...> — a comma list of two or more instruments".to_string()),
Some(v) => {
let parts: Vec<String> = v.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());
}
parts
}
};
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()
));
}
let mut seen = 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 knobs = || "generalize requires all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string();
let grid = RGrid {
fast: vec![a.fast.ok_or_else(knobs)?],
slow: vec![a.slow.ok_or_else(knobs)?],
stop_length: vec![a.stop_length.ok_or_else(knobs)?],
stop_k: vec![a.stop_k.ok_or_else(knobs)?],
..RGrid::default()
};
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
let name = a.name.clone().unwrap_or_else(|| "generalize".to_string());
Ok((name, symbols, grid, metric, a.from, a.to))
}
/// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a
/// non-empty symbol yields `DataChoice::Real`, a window flag without `--real` is a
/// usage error, absence is synthetic. Single-sourced (the old `RealWindowGrammar`
/// finish logic) so the family subcommands agree.
fn data_choice_from(
real: Option<&str>,
from: Option<i64>,
to: Option<i64>,
usage: &impl Fn() -> String,
) -> Result<DataChoice, String> {
match real {
Some("") => Err(usage()),
Some(s) => Ok(DataChoice::Real { symbol: s.to_string(), from_ms: from, to_ms: to }),
None if from.is_some() || to.is_some() => Err(usage()),
None => Ok(DataChoice::Synthetic),
}
}
/// Map a built-in `--strategy` token to a `Strategy` (default sma), reusing the old
/// five-way parse arms; an unknown token is a usage error.
fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result<Strategy, String> {
Ok(match s {
None | Some("sma") => Strategy::SmaCross,
Some("momentum") => Strategy::Momentum,
Some("r-sma") => Strategy::RSma,
Some("r-breakout") => Strategy::RBreakout,
Some("r-meanrev") => Strategy::RMeanRev,
Some(_) => return Err(usage()),
})
}
/// Resolve `--name`/`--trace` (mutually exclusive) into `(family_name, persist)`,
/// defaulting the name when neither is given.
fn name_persist(
name: Option<&str>,
trace: Option<&str>,
default: &str,
usage: &impl Fn() -> String,
) -> Result<(String, bool), String> {
match (name, trace) {
(Some(_), Some(_)) => Err(usage()),
(Some(n), None) => Ok((n.to_string(), false)),
(None, Some(t)) => Ok((t.to_string(), true)),
(None, None) => Ok((default.to_string(), false)),
}
}
/// Parse the repeatable `--axis <name>=<csv>` list into by-name grid axes, mirroring
/// the old blueprint-sweep axis grammar: an empty/duplicate name or a malformed csv
/// is a usage error.
fn parse_axes(
raw: &[String],
usage: &impl Fn() -> String,
) -> Result<Vec<(String, Vec<Scalar>)>, String> {
let mut axes: Vec<(String, Vec<Scalar>)> = Vec::new();
for item in raw {
let (n, csv) = item.split_once('=').ok_or_else(usage)?;
if n.is_empty() || axes.iter().any(|(a, _)| a == n) {
return Err(usage());
}
let vals = parse_scalar_csv(csv).ok_or_else(usage)?;
axes.push((n.to_string(), vals));
}
Ok(axes)
}
/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or
/// the built-in harness-kind dispatch.
fn dispatch_run(a: RunCmd, env: &project::Env) {
match is_blueprint_file(&a.blueprint) {
Some(path) => {
if a.harness.is_some() {
eprintln!("aura: --harness is not valid with a blueprint file");
std::process::exit(2);
}
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to;
// the built-in-only flags are rejected here (exit 2), never silently dropped —
// mirroring the sweep/mc blueprint branches, which reject their non-branch flags
// exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional
// makes these structurally parseable, so the guard is re-asserted at dispatch.
if a.trace.is_some()
|| a.cost_per_trade.is_some()
|| a.slip_vol_mult.is_some()
|| a.carry_per_cycle.is_some()
{
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]");
std::process::exit(2);
}
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
let signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
});
// Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring
// `blueprint_mc_family`'s closed-guard: `run` bootstraps over the EMPTY point, so a
// free knob would panic in `compile_with_params` — reject it clean instead (#176).
let free = blueprint_axis_probe(&doc, env).param_space();
if !free.is_empty() {
eprintln!(
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
free.len()
);
std::process::exit(2);
}
let params = match a.params.as_deref() {
Some(j) => parse_param_cells(j).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
}),
None => Vec::new(),
};
let data = run_data_from(a.real.as_deref(), a.from, a.to);
let report = run_signal_r(signal, &params, data, a.seed.unwrap_or(0), env);
println!("{}", report.to_json());
}
None => {
if a.params.is_some() || a.seed.is_some() {
eprintln!("aura: --params/--seed require a blueprint file");
std::process::exit(2);
}
let run_args = run_args_from(&a).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
match run_dispatch(run_args, env) {
Ok(report) => println!("{}", report.to_json()),
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
}
}
}
}
fn dispatch_chart(a: ChartCmd, env: &project::Env) {
emit_chart(
&a.name,
a.tap.as_deref(),
if a.panels { ChartMode::Panels } else { ChartMode::Overlay },
env,
);
}
fn dispatch_graph(a: GraphCmd, env: &project::Env) {
match a.sub {
None => print!("{}", render::render_html(&sample_blueprint())),
Some(GraphSub::Build) => graph_construct::build_cmd(env),
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env),
}
}
fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
let (name, symbols, grid, metric, from_ms, to_ms) = generalize_args_from(&a).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms, env);
}
fn dispatch_runs(a: RunsCmd, env: &project::Env) {
match a.sub {
RunsSub::Families => runs_families(env),
RunsSub::Family { id, rank_kw, metric } => match (rank_kw.as_deref(), metric) {
(None, None) => runs_family(&id, None, env),
(Some("rank"), Some(m)) => runs_family(&id, Some(&m), env),
_ => {
eprintln!("aura: Usage: aura runs family <id> [rank <metric>]");
std::process::exit(2);
}
},
}
}
fn dispatch_reproduce(a: ReproduceCmd, env: &project::Env) {
reproduce_family(&a.id, env);
}
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
match is_blueprint_file(&a.blueprint) {
Some(path) => {
let usage = || "Usage: aura sweep <blueprint.json> --axis <name>=<csv> [--axis …] [--name <n> | --trace <n>] [--real <SYM> [--from <ms>] [--to <ms>]]".to_string();
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
// Parse-validate the blueprint once at the boundary (with file-path context).
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
}
// A built-in-only flag with a blueprint file is not in this grammar.
if a.strategy.is_some()
|| a.fast.is_some()
|| a.slow.is_some()
|| a.stop_length.is_some()
|| a.stop_k.is_some()
|| a.channel.is_some()
|| a.window.is_some()
|| a.band_k.is_some()
{
eprintln!("aura: {}", usage());
std::process::exit(2);
}
if a.list_axes {
// A query, not a sweep: it must stand alone.
if !a.axis.is_empty()
|| a.name.is_some()
|| a.trace.is_some()
|| a.real.is_some()
|| a.from.is_some()
|| a.to.is_some()
{
eprintln!("aura: --list-axes lists axes and takes no other flags");
std::process::exit(2);
}
list_blueprint_axes(&doc, env);
return;
}
let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
if axes.is_empty() {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data, env), env);
}
None => {
let usage = || "Usage: aura sweep [--strategy <sma|momentum|r-sma|r-breakout|r-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();
if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
let mut grid = RGrid::default();
let bad = |m: String| -> ! {
eprintln!("aura: {m}");
std::process::exit(2)
};
if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.channel.as_deref() { grid.channel = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.window.as_deref() { grid.window = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.band_k.as_deref() { grid.band_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), &grid, env);
}
}
}
/// `aura walkforward`: IS-refit walk-forward over a loaded blueprint (an existing
/// `.json` first-positional) or the built-in `--strategy` walk-forward.
fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
match is_blueprint_file(&a.blueprint) {
Some(path) => {
let usage = || "Usage: aura walkforward <blueprint.json> --axis <name>=<csv> [--axis …] [--select <argmax|plateau:mean|plateau:worst>] [--name <n>]".to_string();
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
}
if a.strategy.is_some()
|| a.fast.is_some()
|| a.slow.is_some()
|| a.stop_length.is_some()
|| a.stop_k.is_some()
|| a.trace.is_some()
{
eprintln!("aura: {}", usage());
std::process::exit(2);
}
let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
if axes.is_empty() {
eprintln!("aura: walkforward <blueprint.json> requires >= 1 --axis to re-fit per window");
std::process::exit(2);
}
let select = match a.select.as_deref() {
Some(s) => parse_select(s).unwrap_or_else(|()| {
eprintln!("aura: {}", usage());
std::process::exit(2);
}),
None => Selection::Argmax,
};
let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string());
run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env);
}
None => {
let usage = || "Usage: aura walkforward [--strategy <sma|momentum|r-sma|r-breakout|r-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();
if a.blueprint.is_some() || !a.axis.is_empty() {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
let mut grid = RGrid::default();
let bad = |m: String| -> ! {
eprintln!("aura: {m}");
std::process::exit(2)
};
if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
let select = match a.select.as_deref() {
Some(s) => parse_select(s).unwrap_or_else(|()| bad(usage())),
None => Selection::Argmax,
};
let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "walkforward", &usage)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
run_walkforward(strategy, &name, persist, DataSource::from_choice(data, env), &grid, select, env);
}
}
}
/// `aura mc`: loaded-blueprint Monte-Carlo (an existing `.json` first-positional), or
/// the built-in synthetic seed-resweep / r-sma R-bootstrap split.
fn dispatch_mc(a: McCmd, env: &project::Env) {
match is_blueprint_file(&a.blueprint) {
Some(path) => {
let usage = "Usage: aura mc <blueprint.json> --seeds <n> [--name <n>]";
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
}
// A built-in-only flag with a blueprint file is not in this grammar
// (MC over a loaded blueprint is synthetic-only this cycle).
if a.strategy.is_some()
|| a.real.is_some()
|| a.from.is_some()
|| a.to.is_some()
|| a.fast.is_some()
|| a.slow.is_some()
|| a.stop_length.is_some()
|| a.stop_k.is_some()
|| a.block_len.is_some()
|| a.resamples.is_some()
|| a.seed.is_some()
|| a.trace.is_some()
{
eprintln!("aura: {usage}");
std::process::exit(2);
}
let n_seeds = match a.seeds {
Some(n) if n > 0 => n,
_ => {
eprintln!("aura: {usage}");
std::process::exit(2);
}
};
let name = a.name.clone().unwrap_or_else(|| "mc".to_string());
run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env);
}
None => {
let usage = || "Usage: aura mc [--name <n>|--trace <n>] | aura mc --strategy r-sma [--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();
if a.blueprint.is_some() || a.seeds.is_some() {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
// The R-bootstrap path is selected by any r-sma knob; otherwise the
// synthetic seed-resweep family. Name flags are invalid on the R path.
let r_path = a.strategy.is_some()
|| a.real.is_some()
|| a.from.is_some()
|| a.to.is_some()
|| a.fast.is_some()
|| a.slow.is_some()
|| a.stop_length.is_some()
|| a.stop_k.is_some()
|| a.block_len.is_some()
|| a.resamples.is_some()
|| a.seed.is_some();
let mc_args = if r_path {
if a.strategy.as_deref() != Some("r-sma") || a.name.is_some() || a.trace.is_some() {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
let mut grid = RGrid::default();
let bad = |m: String| -> ! {
eprintln!("aura: {m}");
std::process::exit(2)
};
if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
let choice = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| bad(m));
McArgs::RealR {
choice,
grid,
block_len: a.block_len.unwrap_or(1),
n_resamples: a.resamples.unwrap_or(1000),
seed: a.seed.unwrap_or(1),
}
} else {
let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "mc", &usage)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
McArgs::Synthetic { name, persist }
};
match mc_args {
McArgs::Synthetic { name, persist } => run_mc(&name, persist, env),
McArgs::RealR { choice, grid, block_len, n_resamples, seed } => run_mc_r_bootstrap(
DataSource::from_choice(choice, env), &grid, block_len, n_resamples, seed, env,
),
}
}
}
}
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);
}
let cli = Cli::parse();
let env = match std::env::current_dir()
.ok()
.and_then(|d| project::discover_from(&d))
{
Some(root) => match project::load(&root, cli.release) {
Ok(p) => project::Env::with_project(p),
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
},
None => project::Env::std(),
};
match cli.command {
Command::Run(a) => dispatch_run(a, &env),
Command::Chart(a) => dispatch_chart(a, &env),
Command::Graph(a) => dispatch_graph(a, &env),
Command::Sweep(a) => dispatch_sweep(a, &env),
Command::Walkforward(a) => dispatch_walkforward(a, &env),
Command::Generalize(a) => dispatch_generalize(a, &env),
Command::Mc(a) => dispatch_mc(a, &env),
Command::Runs(a) => dispatch_runs(a, &env),
Command::Reproduce(a) => dispatch_reproduce(a, &env),
}
}
#[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}");
}
/// The `--select` token grammar (`parse_select`) maps argmax / plateau:mean /
/// plateau:worst and rejects an unknown token — the pure selector clap's
/// `--select` value feeds on both the built-in and blueprint walk-forward paths.
#[test]
fn parse_select_token_grammar() {
assert!(matches!(parse_select("argmax").unwrap(), Selection::Argmax), "default is argmax");
assert!(matches!(parse_select("plateau:mean").unwrap(), Selection::Plateau(PlateauMode::Mean)));
assert!(matches!(parse_select("plateau:worst").unwrap(), Selection::Plateau(PlateauMode::Worst)));
assert!(parse_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 env = project::Env::std();
let d = DataSource::Synthetic;
assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE);
assert!(!d.run_sources(&env).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(&env), 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 env = project::Env::std();
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 `env.registry()`):
// engine family -> per-kind extractor -> append_family.
let sid = reg
.append_family(
"sweep",
FamilyKind::Sweep,
&sweep_member_reports(&sweep_family(None, &DataSource::Synthetic, &env)),
)
.expect("sweep family");
let mid = reg
.append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None, &env)))
.expect("mc family");
let wid = reg
.append_family(
"walkforward",
FamilyKind::WalkForward,
&walkforward_member_reports(&walkforward_family(
Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax,
&env,
)),
)
.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 env = project::Env::std();
let r1 = run_macd(None, &env);
let r2 = run_macd(None, &env);
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(1)`), 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 env = project::Env::std();
let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env);
let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env);
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 env = project::Env::std();
let report =
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env);
// 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, &env);
assert_eq!(report.manifest.broker, again.manifest.broker);
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let env = project::Env::std();
let r1 = run_sample(None, &env);
let r2 = run_sample(None, &env);
// 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 env = project::Env::std();
let a = momentum_sweep_family(None, &DataSource::Synthetic, &env);
let b = momentum_sweep_family(None, &DataSource::Synthetic, &env);
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");
}
/// Property: the *shipped* `r_meanrev_graph` (the CLI compile unit, not
/// the hand-rebuilt subgraph in `r_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 `r_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 r_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 = r_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false)
.compile_with_params(&[])
.expect("r-meanrev blueprint compiles");
let mut h = Harness::bootstrap(flat).expect("r-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:?}");
}
/// Property: a `blueprint_sweep_family` member built from a serialized signal is
/// the SAME trading result as the cycle-1 single run of that signal at the same
/// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path
/// (the keystone). Every member of one family carries the SAME `topology_hash` (the
/// loaded signal's, the deviation from the Rust-built mirror), and distinct grid
/// points key to distinct `member_key`s (members are distinguishable).
#[test]
fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() {
// An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the
// serialized doc round-trips to the topology the single run hashes.
let env = project::Env::std();
let open = sma_signal(None, None);
let doc = blueprint_to_json(&open).expect("serializes");
let data = DataSource::Synthetic;
// fast pinned at 2, slow varied over {4, 6}: a 2x1 grid, slow the varying axis.
let axes = vec![
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]),
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
];
let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("named axes resolve");
assert_eq!(family.points.len(), 2, "2x1 grid -> 2 members");
// (b) every member carries the shared topology_hash of the loaded signal.
let topo = topology_hash(&open);
assert_eq!(topo.len(), 64, "topology_hash is a 64-hex SHA256");
for pt in &family.points {
assert_eq!(pt.report.manifest.topology_hash.as_deref(), Some(topo.as_str()));
}
// (a) the slow=4 member reproduces the cycle-1 single run at fast=2, slow=4 —
// same equity/exposure stream (total_pips/max_drawdown/bias_sign_flips) and the
// same topology_hash, proving the loaded blueprint runs through the identical path.
let single = run_signal_r(
sma_signal(None, None),
&[Scalar::i64(2), Scalar::i64(4)],
RunData::Synthetic,
0,
&env,
);
let member4 = &family.points[0].report; // slow=4 is the first odometer point
assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run");
assert_eq!(member4.manifest.topology_hash, single.manifest.topology_hash);
// (c) the two members' keys differ (member_key over the varying slow.length axis).
let varying: HashSet<String> =
["sma_signal.slow.length".to_string()].into_iter().collect();
let k4 = member_key(&family.points[0].report.manifest.params, &varying);
let k6 = member_key(&family.points[1].report.manifest.params, &varying);
assert_ne!(k4, k6, "distinct grid points key distinctly");
}
#[test]
fn blueprint_axis_probe_lists_prefixed_open_knobs() {
// The open fixture's two SMA lengths are the sweepable knobs; the probe
// wraps the signal (name "sma_signal") so the names are prefixed —
// exactly what `--axis` binds.
let env = project::Env::std();
let open = include_str!("../tests/fixtures/sma_signal_open.json");
let space = blueprint_axis_probe(open, &env).param_space();
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["sma_signal.fast.length", "sma_signal.slow.length"]);
assert!(space.iter().all(|p| matches!(p.kind, ScalarKind::I64)));
// A closed blueprint (both lengths bound) has no open axes.
let closed = include_str!("../tests/fixtures/sma_signal.json");
assert!(blueprint_axis_probe(closed, &env).param_space().is_empty());
}
#[test]
fn blueprint_walkforward_family_refits_each_window() {
// The open fixture's two SMA lengths are re-fit per IS window over a 2x2 grid.
let env = project::Env::std();
let doc = include_str!("../tests/fixtures/sma_signal_open.json");
let axes = vec![
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]),
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
];
let result = blueprint_walkforward_family(doc, &axes, &DataSource::Synthetic, Selection::Argmax, &env);
// 24/12/12 over the 60-bar synthetic span -> 3 rolling windows.
assert_eq!(result.windows.len(), 3, "three rolling IS/OOS windows");
for w in &result.windows {
assert_eq!(w.run.chosen_params.len(), 2, "both axes re-fit each window");
assert!(w.run.oos_report.metrics.r.is_some(), "OOS record is R-metrics");
}
// reduce-mode retains no raw pip curve -> the stitched pip-equity is empty.
assert!(result.stitched_oos_equity.is_empty(), "no raw pip curve in reduce-mode");
}
#[test]
fn blueprint_mc_family_seeds_differ() {
// MC over a CLOSED signal (both SMA knobs bound): 3 seeds -> 3 draws, one shared
// topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct
// synthetic walk per draw), not just the manifest label. The anti-degenerate guard:
// a regression to seed-as-label-only would make the three draws identical.
let env = project::Env::std();
let closed = sma_signal(Some(2), Some(4));
let doc = blueprint_to_json(&closed).expect("serializes");
let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint");
assert_eq!(family.draws.len(), 3, "one draw per seed");
assert_eq!(family.draws.iter().map(|d| d.seed).collect::<Vec<_>>(), vec![1, 2, 3]);
let topo = family.draws[0].report.manifest.topology_hash.clone();
assert!(topo.is_some(), "members carry a topology_hash");
assert!(
family.draws.iter().all(|d| d.report.manifest.topology_hash == topo),
"all members share one topology_hash"
);
let m: Vec<_> = family.draws.iter().map(|d| &d.report.metrics).collect();
assert!(m[0] != m[1] || m[1] != m[2], "seeds must yield differing realizations");
}
#[test]
fn blueprint_mc_family_rejects_vacuous_deep_lookback() {
// Property: the mc family builder REFUSES a silent-vacuous Monte-Carlo — one where
// every per-seed draw collapses to a bit-identical realization — by RETURNING a named
// error rather than an `Ok` family that looks like a real (but indistinguishable)
// distribution. A CLOSED deep-lookback signal whose slow SMA length (60) equals the
// fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus
// identical metrics; that is a wrong result with no error (C10 refuse-don't-guess).
let env = project::Env::std();
let deep = sma_signal(Some(2), Some(60)); // slow len == walk len -> never warms
let doc = blueprint_to_json(&deep).expect("serializes");
let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env)
.expect_err("a vacuous (all-identical) Monte-Carlo is rejected, not returned");
assert!(
err.contains("vacuous") || err.contains("identical"),
"names the vacuous/degenerate condition: {err}"
);
}
#[test]
fn blueprint_mc_family_rejects_an_open_blueprint() {
// Property: the mc family builder REFUSES an open blueprint (free knobs) by RETURNING
// a named error — never a hidden process exit — so the closed-blueprint precondition
// is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling
// blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the
// rejection pre-empts the downstream compile_with_params arity panic.
let env = project::Env::std();
let open = sma_signal(None, None); // both SMA knobs free -> non-empty param_space
let doc = blueprint_to_json(&open).expect("serializes");
let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic, &env)
.expect_err("an open blueprint is rejected, not run");
assert!(err.contains("closed blueprint"), "names the closed-blueprint requirement: {err}");
}
#[test]
fn reproduce_family_re_derives_every_member_bit_identically() {
// a unique temp runs store so the on-disk family + blueprint store do not collide.
let dir = std::env::temp_dir().join(format!("aura-repro-{}", 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"));
let env = project::Env::std();
let open = sma_signal(None, None);
let doc = blueprint_to_json(&open).expect("serializes");
let data = DataSource::Synthetic;
// 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member.
let axes = vec![
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]),
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
];
let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("axes resolve");
// persist exactly as run_blueprint_sweep does: store the blueprint, append the family.
let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo");
let canonical =
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
let id = reg
.append_family("repro", FamilyKind::Sweep, &sweep_member_reports(&family))
.expect("append");
// reproduce: every member re-derives bit-identically (incl the open-at-end member).
let rep = reproduce_family_in(&reg, &id, &data, &env);
assert_eq!(rep.outcomes.len(), 2, "two members reproduced");
assert!(
rep.outcomes.iter().all(|(_, ok)| *ok),
"every member re-derives bit-identically: {:?}",
rep.outcomes
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reproduce_family_re_derives_every_mc_member_bit_identically() {
let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", 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"));
// a CLOSED signal (both SMA knobs bound) — MC binds no axis.
let env = project::Env::std();
let closed = sma_signal(Some(2), Some(4));
let doc = blueprint_to_json(&closed).expect("serializes");
let data = DataSource::Synthetic;
let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint");
// persist exactly as run_blueprint_mc does: store the blueprint, append the MC family.
let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo");
let canonical =
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
let id = reg
.append_family("mcrepro", FamilyKind::MonteCarlo, &mc_member_reports(&family))
.expect("append");
// reproduce: every MC member re-derives bit-identically (its seed-driven walk is
// reconstructed from manifest.seed — the realization branch).
let rep = reproduce_family_in(&reg, &id, &data, &env);
assert_eq!(rep.outcomes.len(), 3, "three MC members reproduced");
assert!(
rep.outcomes.iter().all(|(_, ok)| *ok),
"every MC member re-derives bit-identically: {:?}",
rep.outcomes
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Property: sweeping a FULLY BOUND (closed) blueprint is refused up front with a
/// clear "fully bound / nothing to sweep" message — NOT the terse `UnknownKnob(<axis>)`
/// the per-axis resolve would otherwise emit (misleading: the named knob is not unknown,
/// it is bound out, so there is simply nothing to sweep). Symmetric inverse of
/// `blueprint_mc_family_rejects_an_open_blueprint` (mc requires a closed blueprint; a
/// sweep requires >= 1 open knob). Type-agnostic on the error (renders it via Debug), so
/// the RED phase compiles whether the builder returns a `BindError` or a `String`.
#[test]
fn blueprint_sweep_family_rejects_a_fully_bound_blueprint() {
let env = project::Env::std();
let closed = sma_signal(Some(2), Some(4)); // both SMA knobs bound -> empty param_space
let doc = blueprint_to_json(&closed).expect("serializes");
// an axis naming a bound-out knob: the pre-fix path returns UnknownKnob for it.
let axes = vec![(
"sma_signal.slow.length".to_string(),
vec![Scalar::i64(4), Scalar::i64(6)],
)];
let err = match blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) {
Ok(_) => panic!("a fully-bound blueprint has nothing to sweep"),
Err(e) => format!("{e:?}"),
};
assert!(
err.contains("fully bound") || err.contains("nothing to sweep"),
"names the fully-bound / nothing-to-sweep condition: {err}"
);
assert!(
!err.contains("UnknownKnob"),
"must not leak the misleading UnknownKnob: {err}"
);
}
/// Property: an MC family's `aura reproduce` lines carry the member's own `seed=<N>`
/// label. MC members hold no tuning params (the params-join is empty), so the line would
/// otherwise print a BLANK member label; the seed is each draw's realization identity and
/// must show. Sweep / walk-forward labels still echo their params (covered elsewhere).
#[test]
fn reproduce_mc_member_labels_carry_the_seed() {
let dir = std::env::temp_dir().join(format!("aura-repro-mc-seed-{}", 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"));
let env = project::Env::std();
let closed = sma_signal(Some(2), Some(4)); // MC binds no axis -> closed blueprint
let doc = blueprint_to_json(&closed).expect("serializes");
let data = DataSource::Synthetic;
let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint");
let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo");
let canonical =
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
let id = reg
.append_family("mcseed", FamilyKind::MonteCarlo, &mc_member_reports(&family))
.expect("append");
let rep = reproduce_family_in(&reg, &id, &data, &env);
assert_eq!(rep.outcomes.len(), 3, "three MC members");
for (label, _) in &rep.outcomes {
assert!(
label.starts_with("seed="),
"an MC reproduce label carries the seed, not a blank params-join: {label:?}"
);
}
let seen: HashSet<&str> = rep.outcomes.iter().map(|(l, _)| l.as_str()).collect();
assert!(
seen.contains("seed=1") && seen.contains("seed=2") && seen.contains("seed=3"),
"each MC draw's own seed appears: {seen:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Property: an f64 blueprint param survives the content-addressed store's
/// serialize -> parse -> re-serialize round-trip **bit-identically**, so `aura
/// reproduce` re-derives an f64-bearing member without DIVERGED. This is exactly
/// what workspace `serde_json/float_roundtrip` buys: the constant below is a
/// full-precision f64 (`0.12387080150408619`) that the DEFAULT serde_json parser
/// mis-parses by 1 ULP — with `float_roundtrip` on it parses back exactly, so the
/// canonical bytes are stable and the re-run reproduces. The i64-axis reproduce
/// tests exercise a `scale=0.5` blueprint (exactly representable), so this is the
/// only test that actually depends on the feature.
#[test]
fn f64_blueprint_param_survives_store_round_trip_bit_identically() {
// 1-ULP canary for serde_json float_roundtrip: parses back off-by-one without it.
const HARD_SCALE: f64 = 0.12387080150408619;
// A one-node signal whose only knob is the non-short-decimal f64 scale.
let mut g = GraphBuilder::new("scale_probe");
let bias = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(HARD_SCALE)));
let signal = g.source_role("signal", ScalarKind::F64);
g.feed(signal, vec![bias.input("signal")]);
g.expose(bias.output("bias"), "bias");
let sig = g.build().expect("one-node bias signal wiring resolves");
// the store keeps canonical bytes verbatim (dumb bytes-by-key), so the f64
// fidelity lives entirely in this serialize -> parse -> re-serialize hop.
let doc = blueprint_to_json(&sig).expect("serializes");
assert!(
doc.contains("0.12387080150408619"),
"canonical JSON carries the full-precision f64: {doc}"
);
let reloaded = blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let doc2 = blueprint_to_json(&reloaded).expect("re-serializes");
// Without float_roundtrip the reparsed scale drifts 1 ULP and ryu re-serializes
// it to a different string, so these canonical bytes would differ.
assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically");
}
#[test]
fn run_r_sma_synthetic_folds_an_r_block() {
// the r-sma 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_r_sma(RunData::Synthetic, None, None, None, None, &project::Env::std());
let r = report.metrics.r.as_ref().expect("r-sma 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"),
"r-sma manifest should carry a dedicated broker label, got: {}",
report.manifest.broker
);
}
#[test]
fn parse_param_cells_decodes_typed_cells_in_order_and_refuses_malformed() {
// The property: `--params` round-trips the externally-tagged Scalar wire form in
// array order, and a malformed array is refused with the flag named — never
// silently dropped (a dropped cell would compile-bind a different graph).
let cells = parse_param_cells("[{\"I64\":2},{\"F64\":0.5}]").expect("valid cell array");
assert_eq!(cells, vec![Scalar::I64(2), Scalar::F64(0.5)]);
assert!(parse_param_cells("[]").expect("empty array").is_empty());
let err = parse_param_cells("{not an array}").unwrap_err();
assert!(err.contains("--params"), "a malformed --params value names the flag: {err}");
}
/// Regenerates the committed demo signal blueprint the `aura run <file.json>`
/// E2E loads. Ignored by default; run with `--ignored` after a signal change.
#[test]
#[ignore = "regenerates the committed demo signal blueprint fixture"]
fn emit_demo_signal_fixture() {
let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes");
std::fs::write("tests/fixtures/sma_signal.json", json).expect("write fixture");
}
/// Regenerates the committed OPEN demo signal blueprint the `aura sweep
/// <file.json>` E2E sweeps — fast/slow left unbound, so its param_space exposes
/// `sma_signal.fast.length` / `.slow.length` axes to grid. Ignored by default.
#[test]
#[ignore = "regenerates the committed open demo signal blueprint fixture"]
fn emit_demo_signal_open_fixture() {
let json = blueprint_to_json(&sma_signal(None, None)).expect("serializes");
std::fs::write("tests/fixtures/sma_signal_open.json", json).expect("write fixture");
}
/// The cycle-1 keystone (C1): a signal serialized → loaded back through the
/// public `blueprint_from_json` path runs bit-identically to its Rust-built
/// twin — same metrics, same traces, same topology_hash.
#[test]
fn loaded_signal_runs_bit_identical_to_rust_built() {
let env = project::Env::std();
let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes");
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("loads");
let a = run_signal_r(sma_signal(Some(2), Some(4)), &[], RunData::Synthetic, 0, &env);
let b = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env);
assert_eq!(a.to_json(), b.to_json(), "loaded run is bit-identical incl. topology_hash");
assert_eq!(a.manifest.topology_hash.as_deref().map(str::len), Some(64), "64-hex sha256 present");
}
/// `topology_hash` is deterministic per signal and distinguishes topologies —
/// the #158 reproducibility-anchor property.
#[test]
fn topology_hash_is_stable_and_distinguishes() {
let h = topology_hash(&sma_signal(Some(2), Some(4)));
assert_eq!(h, topology_hash(&sma_signal(Some(2), Some(4))), "same signal -> same hash");
assert_ne!(h, topology_hash(&sma_signal(Some(3), Some(4))), "different topology -> different hash");
}
/// #158 acc 1 (content-id stability across the store round-trip): a blueprint's content
/// id survives serialize -> reload -> re-serialize (#164 idempotence) — the property
/// content-addressed reproduction rests on (the stored bytes re-hash to the members'
/// topology_hash). `content_id` is the shared primitive `topology_hash` uses.
#[test]
fn content_id_is_stable_across_the_store_round_trip() {
let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes");
let id = content_id(&json);
assert_eq!(id.len(), 64, "a 64-hex sha256");
let reloaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("reloads");
assert_eq!(
content_id(&blueprint_to_json(&reloaded).expect("re-serializes")),
id,
"content id survives serialize -> reload -> re-serialize (#164)"
);
}
/// #158 acc 3 (Tier-1 format addition leaves the content id unchanged): a Tier-1
/// optional field the blueprint does not use is tolerated by the loader (#156) and
/// absent from the canonical omit-defaults form, so re-serializing yields the same
/// bytes and thus the same content id.
#[test]
fn content_id_is_stable_across_a_tolerated_tier1_field() {
let base = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes");
// a future Tier-1 optional field injected at the top level (the blueprint does not use it).
let with_extra =
base.replacen("{\"format_version\":1,", "{\"format_version\":1,\"future_optional\":123,", 1);
assert_ne!(base, with_extra, "the doc actually carries the extra field");
let reparsed = blueprint_from_json(&with_extra, &|t| std_vocabulary(t))
.expect("loader tolerates an unknown Tier-1 field (#156)");
assert_eq!(
content_id(&blueprint_to_json(&reparsed).expect("re-serializes")),
content_id(&base),
"a Tier-1 optional the blueprint does not use leaves the content id unchanged"
);
}
/// #158 acc 1 (cross-surface agreement): `topology_hash` (from a live `Composite`, the
/// run/sweep path) and the op-script `graph introspect --content-id` surface are the
/// SAME hash of the SAME canonical bytes — both go through the one `content_id`
/// primitive. Pins that the two command paths cannot silently drift apart.
#[test]
fn topology_hash_is_the_content_id_of_the_canonical_form() {
let sig = sma_signal(Some(2), Some(4));
assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes")));
}
#[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(RSma) ->
// pooled_oos_trade_rs -> r_bootstrap -> mc_r_bootstrap_json — wires up and
// reduces a NON-EMPTY pooled OOS R series (the synthetic r-sma 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 env = project::Env::std();
let result = walkforward_family(
Strategy::RSma, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, &env,
);
let pooled = pooled_oos_trade_rs(&result);
assert!(!pooled.is_empty(), "synthetic r-sma walk-forward must pool >= 1 OOS trade R");
let line = mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env);
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, &RGrid::default(), 1, 256, 1, &env),
);
}
}