4928e289f7
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
6022 lines
283 KiB
Rust
6022 lines
283 KiB
Rust
//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and
|
||
//! automation drive: author a node, run a sim/sweep, emit structured metrics).
|
||
//!
|
||
//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample
|
||
//! signal-quality harness (synthetic source → SMA-cross → 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, ¶ms, 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(®, &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(®, &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(®, &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),
|
||
);
|
||
}
|
||
}
|