feat(stage1-r): operate the R layer from the CLI (iter 3)
`aura run --harness stage1-r [--real <SYM>] [--trace <n>]` now runs a Stage-1 R
backtest and prints its signal-quality metrics (the R block) in the RunReport
JSON - one shell call, the research loop's primary verb, ergonomic for Claude
to drive.
What shipped:
- vol_stop + risk_executor promoted from test fixtures to public aura-engine
composite-builders, with a StopRule{Fixed,Vol} axis (C11). aura-std becomes a
normal dependency of aura-engine (the composites are the engine's convenience
layer over the standard nodes - the sibling tier of summarize_r; the graph
stays acyclic: aura-engine -> aura-std -> aura-core). Both fixtures call the
public fns; a Vol-backed RED test pins the new match arm.
- stage1_r_blueprint fans one bias into BOTH a SimBroker (pip) and the
RiskExecutor (R), so one report carries both yardsticks honestly. run_stage1_r
folds summarize (pip) + summarize_r (R, round_trip_cost 0.0 - Stage-1 is
frictionless signal quality) and sets RunMetrics.r = Some(..). An r_equity tap
(cum_realized_r + unrealized_r via LinComb) persists through a separate 3-tap
persist_traces_r and renders via the existing `aura chart --tap r_equity`.
- `aura run --harness <sma|macd|stage1-r>`: a parse_run_args tokenizer replaces
the five `run` literal-slice arms so --harness/--real/--from/--to/--trace
compose in any order; --macd kept as a back-compat alias; --harness sma the
default. A compile-time match over Rust-authored built-ins - not a runtime node
registry, not a DSL (C9/C17): the CLI runs an authored harness, it does not
wire one.
Back-compat: --harness sma/macd leave RunMetrics.r = None, so skip_serializing_if
keeps pip-only and legacy runs.jsonl JSON byte-unchanged; the plain-run tap list
stays ["equity","exposure"].
Refactor beyond the plan (verified behavior-equivalent): the old standalone
parse_real_args was orphaned by the new tokenizer, so it and its now-redundant
unit test were removed and run's --real parsing inlined into parse_run_args. The
--real strictness (empty-symbol, non-i64 ms, repeated-flag, window-requires-real
-> exit 2) is preserved and now pinned by
parse_run_args_rejects_malformed_real_and_repeated_flags (added to restore the
deleted coverage). All five run_real integration tests stay green.
Verification: cargo build/test --workspace green (every crate, 0 failed); clippy
--workspace --all-targets -D warnings clean. The full diff was adversarially
reviewed (run-arg refactor equivalence against the removed function, harness
wiring, C1/C2/C9/C17, the serde back-compat pin) - verdict SOUND; the one flagged
coverage gap is closed here.
Follow-on (noted, not done): the stage1-r manifest reuses the
"sim-optimal(pip_size=...)" broker label; a dedicated "risk-executor" label would
read more honestly for a dual-tap harness that runs a RiskExecutor branch.
closes #129
refs #117 #128
This commit is contained in:
+408
-93
@@ -16,17 +16,20 @@ use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
||||
|
||||
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
f64_field, join_on_ts, monte_carlo, param_stability, summarize, walk_forward, window_of,
|
||||
ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow, McAggregate,
|
||||
McFamily, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target,
|
||||
VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
|
||||
f64_field, join_on_ts, monte_carlo, param_stability, risk_executor, summarize, summarize_r,
|
||||
walk_forward, window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness,
|
||||
JoinedRow, McAggregate, McFamily, RollMode, RunManifest, RunReport, SourceSpec, StopRule,
|
||||
SweepFamily, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller,
|
||||
WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||
walkforward_member_reports, FamilyKind, FamilyMember, NameKind, Registry, RunTraces, TraceStore,
|
||||
WriteKind,
|
||||
};
|
||||
use aura_std::{Bias, Ema, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_std::{
|
||||
Bias, Ema, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -578,23 +581,8 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
|
||||
// refuses without touching local data. Honest by construction.
|
||||
let spec = instrument_spec_or_refuse(symbol);
|
||||
let (mut h, rx_eq, rx_ex) = sample_harness(spec.pip_size);
|
||||
let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
|
||||
if !server.has_symbol(symbol) {
|
||||
no_real_data(symbol);
|
||||
}
|
||||
|
||||
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
|
||||
let window = probe_window(&server, symbol, from_ms, to_ms);
|
||||
|
||||
let source: Box<dyn aura_engine::Source> =
|
||||
match aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) {
|
||||
Some(s) => Box::new(s),
|
||||
None => no_real_data(symbol),
|
||||
};
|
||||
let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms);
|
||||
let (mut h, rx_eq, rx_ex) = sample_harness(pip_size);
|
||||
h.run(vec![source]);
|
||||
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
@@ -607,7 +595,7 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace
|
||||
],
|
||||
window,
|
||||
0,
|
||||
spec.pip_size,
|
||||
pip_size,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(name, &manifest, &eq_rows, &ex_rows);
|
||||
@@ -616,36 +604,6 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// Parse the `run --real` tail: a mandatory `<SYMBOL>` then zero-or-more
|
||||
/// `--from <ms>` / `--to <ms>` pairs in any order. Pure (no I/O, no exit) so the
|
||||
/// arg grammar is unit-testable; `main` does the side effects. An unknown token, a
|
||||
/// flag without its value, a non-`i64` ms, or a repeated flag is an `Err` carrying
|
||||
/// a short usage message.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn parse_real_args(
|
||||
rest: &[&str],
|
||||
) -> Result<(String, Option<i64>, Option<i64>, Option<String>), String> {
|
||||
let usage = || "run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>]".to_string();
|
||||
let (symbol, mut tail) = match rest.split_first() {
|
||||
Some((sym, t)) if !sym.is_empty() => (*sym, t),
|
||||
_ => return Err(usage()),
|
||||
};
|
||||
let mut from: Option<i64> = None;
|
||||
let mut to: Option<i64> = None;
|
||||
let mut trace: Option<String> = None;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
match *flag {
|
||||
"--from" if from.is_none() => from = Some(value.parse().map_err(|_| usage())?),
|
||||
"--to" if to.is_none() => to = Some(value.parse().map_err(|_| usage())?),
|
||||
"--trace" if trace.is_none() => trace = Some((*value).to_string()),
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
tail = t;
|
||||
}
|
||||
Ok((symbol.to_string(), from, to, trace))
|
||||
}
|
||||
|
||||
/// Parse `aura chart` args: `<name> [--tap <t>] [--panels]` in any order.
|
||||
fn parse_chart_args(rest: &[&str]) -> Result<(String, Option<String>, ChartMode), String> {
|
||||
let mut name: Option<String> = None;
|
||||
@@ -678,7 +636,8 @@ fn parse_chart_args(rest: &[&str]) -> Result<(String, Option<String>, ChartMode)
|
||||
/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the
|
||||
/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol
|
||||
/// rejection, and the "window flags require `--real`" rule — so the two siblings
|
||||
/// agree with each other and with `parse_real_args`. Each parser owns its other
|
||||
/// agree with each other (the `run` parser inlines the same strictness — its
|
||||
/// valueless `--macd` flag rules out reusing this accumulator). Each parser owns its other
|
||||
/// flags (strategy / name / trace); on a `--real`/`--from`/`--to` token it calls
|
||||
/// `accept`, and at the end calls `finish` to validate and build the `DataChoice`.
|
||||
#[derive(Default)]
|
||||
@@ -692,8 +651,8 @@ impl RealWindowGrammar {
|
||||
/// Consume one `--real`/`--from`/`--to` flag and its already-split value.
|
||||
/// Returns `Ok(true)` if the flag was a real/window flag (consumed),
|
||||
/// `Ok(false)` if it is not ours (the caller handles it), `Err` on a malformed
|
||||
/// real/window flag: a repeated flag (mirroring `parse_real_args`'s
|
||||
/// `if from.is_none()` strictness) or an empty `--real` symbol.
|
||||
/// real/window flag: a repeated flag (the `if from.is_none()` strictness) or
|
||||
/// an empty `--real` symbol.
|
||||
fn accept(&mut self, flag: &str, value: &str, usage: &impl Fn() -> String) -> Result<bool, String> {
|
||||
match flag {
|
||||
"--real" if self.symbol.is_none() && !value.is_empty() => {
|
||||
@@ -794,6 +753,34 @@ fn probe_window(
|
||||
(first, last)
|
||||
}
|
||||
|
||||
/// Open a real M1-close source for a vetted 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
|
||||
/// vetted-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and
|
||||
/// the run-source `open` (each refusal an stderr + exit 2). Pre-data refusals keep the
|
||||
/// pip honest by construction. Shared by `run_sample_real` and `run_stage1_r`.
|
||||
fn open_real_source(
|
||||
symbol: &str,
|
||||
from_ms: Option<i64>,
|
||||
to_ms: Option<i64>,
|
||||
) -> (Box<dyn aura_engine::Source>, (Timestamp, Timestamp), f64) {
|
||||
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
|
||||
// refuses without touching local data. Honest by construction.
|
||||
let spec = instrument_spec_or_refuse(symbol);
|
||||
let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
|
||||
if !server.has_symbol(symbol) {
|
||||
no_real_data(symbol);
|
||||
}
|
||||
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
|
||||
let window = probe_window(&server, symbol, from_ms, to_ms);
|
||||
let source: Box<dyn aura_engine::Source> =
|
||||
match aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) {
|
||||
Some(s) => Box::new(s),
|
||||
None => no_real_data(symbol),
|
||||
};
|
||||
(source, window, spec.pip_size)
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
/// Build a provider from a parsed choice, or refuse (stderr + exit 2) on an
|
||||
/// un-vetted symbol / absent data — both BEFORE any member runs (Fork C/G),
|
||||
@@ -1698,8 +1685,292 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
// --- Stage-1 R harness (the SMA-cross signal scored in R) --------------------
|
||||
|
||||
/// The stage1-r vol-stop EWMA length (cycles). Single source for the `StopRule::Vol`
|
||||
/// the blueprint embeds and the `stop` param the manifest records — kept honest by one
|
||||
/// constant instead of a hand-synced literal across the function boundary.
|
||||
const STAGE1_R_STOP_LENGTH: i64 = 3;
|
||||
|
||||
/// The stage1-r vol-stop multiplier (1R = `k`·σ). Single source for the embedded
|
||||
/// `StopRule::Vol` and its manifest record, like [`STAGE1_R_STOP_LENGTH`].
|
||||
const STAGE1_R_STOP_K: f64 = 2.0;
|
||||
|
||||
/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1
|
||||
/// close bars for a vetted symbol over an optional window.
|
||||
enum RunData {
|
||||
Synthetic,
|
||||
Real { symbol: String, from: Option<i64>, to: Option<i64> },
|
||||
}
|
||||
|
||||
/// A rise-fall-rise synthetic stream for the stage1-r smoke run: long enough to warm
|
||||
/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the
|
||||
/// RiskExecutor opens and closes at least one trade. Deterministic (C1).
|
||||
fn stage1_r_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
[
|
||||
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
|
||||
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The Stage-1 R harness: the SMA-cross → `Bias` signal fanned into BOTH a `SimBroker`
|
||||
/// (pip equity, the existing pip yardstick) AND the `risk_executor` (the dense R-record,
|
||||
/// the new R yardstick), so one run scores the same signal in pips and in R. Four taps:
|
||||
/// equity (SimBroker), exposure (Bias), the 14-column R-record (drained into
|
||||
/// `summarize_r`), and `r_equity = cum_realized_r + unrealized_r` (a single series for
|
||||
/// `aura chart --tap r_equity`). All params bound at build → compiles with an empty point.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn stage1_r_blueprint(
|
||||
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>)>,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_r");
|
||||
// SMA-cross signal → Bias (the same signal the pip sample uses).
|
||||
let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2)));
|
||||
let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4)));
|
||||
let spread = g.add(Sub::builder());
|
||||
let exposure = g.add(Bias::builder().named("exposure").bind("scale", Scalar::f64(0.5)));
|
||||
// pip branch (verbatim from sample_blueprint_with_sinks).
|
||||
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));
|
||||
// R branch: bias + price → RiskExecutor(vol_stop) → dense R-record.
|
||||
let exec = g.add(risk_executor(
|
||||
StopRule::Vol { length: STAGE1_R_STOP_LENGTH, k: STAGE1_R_STOP_K },
|
||||
1.0,
|
||||
));
|
||||
let rrec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r));
|
||||
// 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));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(
|
||||
price,
|
||||
[fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")],
|
||||
);
|
||||
g.connect(fast.output("value"), spread.input("lhs"));
|
||||
g.connect(slow.output("value"), spread.input("rhs"));
|
||||
g.connect(spread.output("value"), exposure.input("signal"));
|
||||
// bias fans to: broker (pip), exposure sink, and the RiskExecutor (R).
|
||||
g.connect(exposure.output("bias"), broker.input("exposure"));
|
||||
g.connect(exposure.output("bias"), ex.input("col[0]"));
|
||||
g.connect(exposure.output("bias"), exec.input("bias"));
|
||||
g.connect(broker.output("equity"), eq.input("col[0]"));
|
||||
// tap the dense R-record (all PM fields) for summarize_r.
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
let col: &'static str = format!("col[{i}]").leak();
|
||||
g.connect(exec.output(field), rrec.input(col));
|
||||
}
|
||||
// r_equity sum + tap.
|
||||
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
|
||||
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
|
||||
g.connect(r_equity.output("value"), req.input("col[0]"));
|
||||
g.build().expect("stage1_r wiring resolves")
|
||||
}
|
||||
|
||||
/// `aura run --harness stage1-r [--real <SYM> [--from][--to]] [--trace <n>]`: build the
|
||||
/// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via
|
||||
/// `summarize` and the dense R-record via `summarize_r`, and attach the R block as
|
||||
/// `RunMetrics.r = Some(..)`. Pure/deterministic (C1). round_trip_cost is 0.0 (Stage-1 is
|
||||
/// frictionless; a --cost knob is a follow-on).
|
||||
fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport {
|
||||
if let Some(n) = trace
|
||||
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||
{
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let flat = stage1_r_blueprint(tx_eq, tx_ex, tx_r, tx_req)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid stage1-r blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness");
|
||||
|
||||
let (sources, window, pip_size): (Vec<Box<dyn aura_engine::Source>>, _, f64) = match &data {
|
||||
RunData::Synthetic => {
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||
vec![Box::new(VecSource::new(stage1_r_prices()))];
|
||||
let window = window_of(&sources).expect("non-empty synthetic stream");
|
||||
(sources, window, SYNTHETIC_PIP_SIZE)
|
||||
}
|
||||
RunData::Real { symbol, from, to } => {
|
||||
let (source, window, pip_size) = open_real_source(symbol, *from, *to);
|
||||
(vec![source], window, pip_size)
|
||||
}
|
||||
};
|
||||
h.run(sources);
|
||||
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
|
||||
|
||||
let manifest = sim_optimal_manifest(
|
||||
vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
("stop_length".to_string(), Scalar::i64(STAGE1_R_STOP_LENGTH)), // vol_stop EWMA length
|
||||
("stop_k".to_string(), Scalar::f64(STAGE1_R_STOP_K)), // vol_stop multiplier (1R = k·σ)
|
||||
],
|
||||
window,
|
||||
0,
|
||||
pip_size,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows);
|
||||
}
|
||||
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
metrics.r = Some(summarize_r(&r_rows, 0.0));
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// Persist a stage1-r run's three taps: equity (off the SimBroker), exposure (off the
|
||||
/// Bias), and r_equity = cum_realized_r + unrealized_r (off the RiskExecutor). Separate
|
||||
/// from the two-tap `persist_traces` so the pip handlers stay byte-unchanged on disk.
|
||||
fn persist_traces_r(
|
||||
name: &str,
|
||||
manifest: &RunManifest,
|
||||
eq_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
ex_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
req_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
) {
|
||||
let 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 let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
|
||||
eprintln!("aura: trace persist failed: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which built-in harness `aura run` drives. A fixed compile-time enumeration over
|
||||
/// Rust-authored harnesses — NOT a runtime node registry and NOT a DSL (C9/C17): the
|
||||
/// CLI *runs* an authored harness, it does not wire one.
|
||||
enum HarnessKind {
|
||||
Sma,
|
||||
Macd,
|
||||
Stage1R,
|
||||
}
|
||||
|
||||
/// The parsed `aura run` invocation: a harness, a data source, and an optional trace name.
|
||||
struct RunArgs {
|
||||
harness: HarnessKind,
|
||||
data: RunData,
|
||||
trace: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the `run` tail: `[--harness <name>] [--macd] [--real <SYM> [--from <ms>] [--to <ms>]]
|
||||
/// [--trace <name>]` in any order, each flag at most once. `--harness sma` is the default;
|
||||
/// `--macd` is a back-compat alias for `--harness macd` and conflicts with `--harness`;
|
||||
/// `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the grammar is
|
||||
/// unit-testable; `main` does the side effects.
|
||||
fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let usage = || {
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>]"
|
||||
.to_string()
|
||||
};
|
||||
let mut harness: Option<HarnessKind> = None;
|
||||
let mut macd_flag = false;
|
||||
let mut symbol: Option<String> = None;
|
||||
let mut from: Option<i64> = None;
|
||||
let mut to: Option<i64> = None;
|
||||
let mut trace: Option<String> = None;
|
||||
let mut tail = rest;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
match *flag {
|
||||
"--macd" if !macd_flag => {
|
||||
macd_flag = true;
|
||||
tail = t;
|
||||
}
|
||||
"--harness" if harness.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
harness = Some(match *value {
|
||||
"sma" => HarnessKind::Sma,
|
||||
"macd" => HarnessKind::Macd,
|
||||
"stage1-r" => HarnessKind::Stage1R,
|
||||
_ => return Err(usage()),
|
||||
});
|
||||
tail = t;
|
||||
}
|
||||
"--real" if symbol.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
if value.is_empty() {
|
||||
return Err(usage());
|
||||
}
|
||||
symbol = Some((*value).to_string());
|
||||
tail = t;
|
||||
}
|
||||
"--from" if from.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
from = Some(value.parse().map_err(|_| usage())?);
|
||||
tail = t;
|
||||
}
|
||||
"--to" if to.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
to = Some(value.parse().map_err(|_| usage())?);
|
||||
tail = t;
|
||||
}
|
||||
"--trace" if trace.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
trace = Some((*value).to_string());
|
||||
tail = t;
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
// --macd is the alias; it conflicts with an explicit --harness.
|
||||
let harness = match (harness, macd_flag) {
|
||||
(Some(_), true) => return Err(usage()),
|
||||
(Some(h), false) => h,
|
||||
(None, true) => HarnessKind::Macd,
|
||||
(None, false) => HarnessKind::Sma,
|
||||
};
|
||||
// --from/--to require --real.
|
||||
if symbol.is_none() && (from.is_some() || to.is_some()) {
|
||||
return Err(usage());
|
||||
}
|
||||
let data = match symbol {
|
||||
Some(s) => RunData::Real { symbol: s, from, to },
|
||||
None => RunData::Synthetic,
|
||||
};
|
||||
Ok(RunArgs { harness, data, trace })
|
||||
}
|
||||
|
||||
/// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS
|
||||
/// the selector — the harnesses are Rust-authored built-ins, picked by name (C9/C17).
|
||||
fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
let trace = args.trace.as_deref();
|
||||
Ok(match (args.harness, args.data) {
|
||||
(HarnessKind::Sma, RunData::Synthetic) => run_sample(trace),
|
||||
(HarnessKind::Macd, RunData::Synthetic) => run_macd(trace),
|
||||
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace),
|
||||
(HarnessKind::Sma, RunData::Real { symbol, from, to }) => {
|
||||
run_sample_real(&symbol, from, to, trace)
|
||||
}
|
||||
(HarnessKind::Macd, RunData::Real { .. }) => {
|
||||
return Err("the macd harness has no --real form".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -1707,14 +1978,14 @@ fn main() {
|
||||
// than masquerading as a successful run (#16 strict reading).
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
|
||||
["run"] => println!("{}", run_sample(None).to_json()),
|
||||
["run", "--macd"] => println!("{}", run_macd(None).to_json()),
|
||||
["run", "--trace", name] => println!("{}", run_sample(Some(name)).to_json()),
|
||||
["run", "--macd", "--trace", name] => println!("{}", run_macd(Some(name)).to_json()),
|
||||
["run", "--real", rest @ ..] => match parse_real_args(rest) {
|
||||
Ok((sym, from, to, trace)) => {
|
||||
println!("{}", run_sample_real(&sym, from, to, trace.as_deref()).to_json())
|
||||
}
|
||||
["run", rest @ ..] => match parse_run_args(rest) {
|
||||
Ok(args) => match run_dispatch(args) {
|
||||
Ok(report) => println!("{}", report.to_json()),
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
@@ -2399,32 +2670,6 @@ mod tests {
|
||||
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
|
||||
}
|
||||
|
||||
/// `parse_real_args` accepts a bare symbol (no window) and a full
|
||||
/// symbol + `--from`/`--to` pair in any order, and rejects a flag missing its
|
||||
/// value — the pure arg grammar `main` relies on for `run --real`.
|
||||
#[test]
|
||||
fn parse_real_args_accepts_symbol_and_optional_window() {
|
||||
assert_eq!(parse_real_args(&["EURUSD"]), Ok(("EURUSD".to_string(), None, None, None)));
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--from", "100", "--to", "200"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
|
||||
);
|
||||
// flags in any order
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--to", "200", "--from", "100"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
|
||||
);
|
||||
// the --trace tail flag carries a string name, parsed alongside the window
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--from", "100", "--trace", "demo"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), None, Some("demo".to_string())))
|
||||
);
|
||||
// a flag without its value, an empty symbol, and a non-numeric ms all reject
|
||||
assert!(parse_real_args(&["EURUSD", "--from"]).is_err());
|
||||
assert!(parse_real_args(&[]).is_err());
|
||||
assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err());
|
||||
}
|
||||
|
||||
/// `parse_chart_args` parses `<name>`, `--tap <t>`, and `--panels` in any order
|
||||
/// (the loop grammar's reason for being — the name need not lead), and surfaces
|
||||
/// each of its three distinct errors: `--tap` missing its value, an unexpected
|
||||
@@ -2655,7 +2900,7 @@ mod tests {
|
||||
assert!(parse_sweep_args(&["--from", "100"]).is_err()); // --from without --real
|
||||
}
|
||||
|
||||
/// `parse_sweep_args` mirrors `parse_real_args`'s real/window strictness: an
|
||||
/// `parse_sweep_args` mirrors the shared `RealWindowGrammar` real/window strictness: an
|
||||
/// empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
|
||||
/// errors (not last-write-wins, not a downstream refusal) — so sibling commands
|
||||
/// reject the same malformed real-grammar the same way.
|
||||
@@ -2681,7 +2926,7 @@ mod tests {
|
||||
assert!(parse_walkforward_args(&["--real"]).is_err());
|
||||
}
|
||||
|
||||
/// `parse_walkforward_args` mirrors `parse_real_args`'s real/window strictness:
|
||||
/// `parse_walkforward_args` mirrors the shared `RealWindowGrammar` real/window strictness:
|
||||
/// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
|
||||
/// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives.
|
||||
#[test]
|
||||
@@ -2691,4 +2936,74 @@ mod tests {
|
||||
assert!(parse_walkforward_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err());
|
||||
assert!(parse_walkforward_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stage1_r_synthetic_folds_an_r_block() {
|
||||
// the stage1-r harness scores the SMA-cross signal in R: one shell-callable run
|
||||
// yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade.
|
||||
let report = run_stage1_r(RunData::Synthetic, None);
|
||||
let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r");
|
||||
assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades);
|
||||
assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn);
|
||||
assert!(r.expectancy_r.is_finite(), "E[R] must be finite, got {}", r.expectancy_r);
|
||||
}
|
||||
|
||||
/// Property: a bare `aura run` parses to the default harness/data — `--harness sma`
|
||||
/// over the synthetic stream, no trace. The grammar's identity element, so the
|
||||
/// historic plain-`run` form keeps its meaning through the new tokenizer.
|
||||
#[test]
|
||||
fn parse_run_args_defaults_to_sma_synthetic() {
|
||||
let a = parse_run_args(&[]).expect("bare run");
|
||||
assert!(matches!(a.harness, HarnessKind::Sma));
|
||||
assert!(matches!(a.data, RunData::Synthetic));
|
||||
assert!(a.trace.is_none());
|
||||
}
|
||||
|
||||
/// Property: `--harness`, `--real <SYM> [--from]` and `--trace` are orthogonal
|
||||
/// modifiers — they compose in ANY order into one `RunArgs`, each captured into its
|
||||
/// own field, so the tokenizer is order-independent (the historic literal arms were
|
||||
/// not).
|
||||
#[test]
|
||||
fn parse_run_args_composes_harness_real_and_trace_in_any_order() {
|
||||
let a = parse_run_args(&["--trace", "q1", "--harness", "stage1-r", "--real", "GER40", "--from", "100"])
|
||||
.expect("composed flags");
|
||||
assert!(matches!(a.harness, HarnessKind::Stage1R));
|
||||
match a.data {
|
||||
RunData::Real { symbol, from, to } => {
|
||||
assert_eq!(symbol, "GER40");
|
||||
assert_eq!(from, Some(100));
|
||||
assert_eq!(to, None);
|
||||
}
|
||||
_ => panic!("expected real data"),
|
||||
}
|
||||
assert_eq!(a.trace.as_deref(), Some("q1"));
|
||||
}
|
||||
|
||||
/// Property: `--macd` is a back-compat ALIAS for `--harness macd` (so the historic
|
||||
/// `run --macd` form survives), it conflicts with an explicit `--harness`, an unknown
|
||||
/// harness name is rejected, and a window flag (`--from`) without `--real` is rejected
|
||||
/// — the grammar's four refusal edges.
|
||||
#[test]
|
||||
fn parse_run_args_macd_is_an_alias_and_rejects_harness_conflict() {
|
||||
assert!(matches!(parse_run_args(&["--macd"]).unwrap().harness, HarnessKind::Macd));
|
||||
assert!(parse_run_args(&["--macd", "--harness", "sma"]).is_err());
|
||||
assert!(parse_run_args(&["--harness", "nope"]).is_err());
|
||||
assert!(parse_run_args(&["--from", "1"]).is_err()); // --from without --real
|
||||
}
|
||||
|
||||
/// Property: the run-arg parser preserves every strictness branch the removed
|
||||
/// `parse_real_args` unit test pinned — empty symbol, non-i64 window ms, a repeated
|
||||
/// flag, and a flag missing its value all reject, so the `run` path stays exit-2 strict.
|
||||
#[test]
|
||||
fn parse_run_args_rejects_malformed_real_and_repeated_flags() {
|
||||
assert!(parse_run_args(&["--real", ""]).is_err()); // empty symbol
|
||||
assert!(parse_run_args(&["--real", "GER40", "--from", "x"]).is_err()); // non-i64 from
|
||||
assert!(parse_run_args(&["--real", "GER40", "--to", "1.5"]).is_err()); // non-i64 to
|
||||
assert!(parse_run_args(&["--harness", "sma", "--harness", "macd"]).is_err()); // repeated --harness
|
||||
assert!(parse_run_args(&["--macd", "--macd"]).is_err()); // repeated --macd
|
||||
assert!(parse_run_args(&["--real", "GER40", "--trace", "a", "--trace", "b"]).is_err()); // repeated --trace
|
||||
assert!(parse_run_args(&["--harness"]).is_err()); // flag missing its value
|
||||
assert!(parse_run_args(&["--real", "GER40", "--from"]).is_err()); // flag missing its value
|
||||
assert!(parse_run_args(&["bogus"]).is_err()); // unknown trailing token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1451,3 +1451,114 @@ fn chart_family_serves_member_count_and_spanning_window_in_meta() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
// --- `aura run --harness <name>` selector (iter-3 Task 3) --------------------
|
||||
|
||||
/// Property: `aura run --harness stage1-r` runs the R-scored harness — its stdout
|
||||
/// `RunReport` carries the nested `r` block (the R yardstick), and that block carries
|
||||
/// the `sqn` field. Pins the selector wiring stage1-r → `run_stage1_r` at the
|
||||
/// built-binary boundary; a miswiring to the pip-only SMA arm would drop the `r` key.
|
||||
#[test]
|
||||
fn run_harness_stage1_r_prints_an_r_block() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "stage1-r"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(s.contains("\"r\":{"), "stage1-r run must carry an r block: {s}");
|
||||
assert!(s.contains("\"sqn\""), "the r block must carry sqn: {s}");
|
||||
}
|
||||
|
||||
/// Property: `aura run --harness sma` is the pip-only default — its `RunReport`
|
||||
/// OMITS the `r` key entirely (the `skip_serializing_if` keeps a pip run byte-clean).
|
||||
/// The companion of the stage1-r test: it proves the selector switches the surface,
|
||||
/// not that every run sprouts an `r` block.
|
||||
#[test]
|
||||
fn run_harness_sma_is_pip_only_no_r_block() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "sma"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}");
|
||||
}
|
||||
|
||||
/// Property: an unknown `--harness` name is a usage error at the binary boundary —
|
||||
/// exit 2 (never a silent default run), the `Err` arm wiring through to `exit(2)`.
|
||||
#[test]
|
||||
fn run_unknown_harness_exits_two() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "nope"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// Property: `--trace` on the stage1-r harness persists the THIRD `r_equity` tap
|
||||
/// beside equity/exposure, and that tap round-trips through `aura chart --tap
|
||||
/// r_equity` into a rendered series. Pins the full author→persist→chart loop for the
|
||||
/// R-equity series end to end (the pip harnesses write only the two taps).
|
||||
#[test]
|
||||
fn stage1_r_trace_persists_r_equity_and_charts_it() {
|
||||
// `temp_cwd` (cli_run.rs:13) gives a unique CWD with no external tempfile dep — the
|
||||
// pattern the existing trace tests use; it returns a `PathBuf`, so `.join` directly.
|
||||
let dir = temp_cwd("stage1-r-trace");
|
||||
let run = Command::new(BIN)
|
||||
.current_dir(&dir)
|
||||
.args(["run", "--harness", "stage1-r", "--trace", "q1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(run.status.success());
|
||||
// the third tap is persisted beside equity/exposure
|
||||
assert!(dir.join("runs/traces/q1/r_equity.json").exists(), "r_equity tap must persist");
|
||||
let chart = Command::new(BIN)
|
||||
.current_dir(&dir)
|
||||
.args(["chart", "q1", "--tap", "r_equity"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(chart.status.success());
|
||||
let html = String::from_utf8(chart.stdout).unwrap();
|
||||
assert!(!html.is_empty() && html.contains("r_equity"), "chart must render the r_equity series");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property (the spec's dual-yardstick headline): `aura run --harness stage1-r` scores
|
||||
/// ONE signal in BOTH yardsticks at once — the single emitted `RunReport` carries the pip
|
||||
/// `metrics.total_pips` (off the SimBroker branch) AND the nested `r` block (off the
|
||||
/// RiskExecutor branch), because one bias is fanned into both. The shipped selector test
|
||||
/// pins only that the `r` block appears; this pins that the pip branch SURVIVES alongside
|
||||
/// it. A regression that swapped the pip branch out for the R branch (dropping the dual
|
||||
/// tap) would still print an `r` block — and pass that test — while silently losing the
|
||||
/// pip yardstick; this fails it. Asserts on the structural co-presence of both keys, never
|
||||
/// the volatile metric floats, so it stays deterministic.
|
||||
#[test]
|
||||
fn run_harness_stage1_r_carries_both_pip_and_r_yardsticks() {
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args(["run", "--harness", "stage1-r"])
|
||||
.output()
|
||||
.expect("spawn aura run --harness stage1-r");
|
||||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
// exactly one report line.
|
||||
assert_eq!(s.lines().count(), 1, "stdout was: {s:?}");
|
||||
// the pip yardstick (SimBroker branch) is still present in the same report...
|
||||
assert!(s.contains("\"total_pips\":"), "stage1-r must keep the pip yardstick: {s}");
|
||||
// ...and the R yardstick (RiskExecutor branch) is folded in alongside it.
|
||||
assert!(s.contains("\"r\":{"), "stage1-r must carry the R yardstick too: {s}");
|
||||
}
|
||||
|
||||
/// Property (C1, the foundational determinism invariant at the new harness boundary):
|
||||
/// `aura run --harness stage1-r` is byte-deterministic — running it twice over the
|
||||
/// fixed synthetic stream yields BYTE-IDENTICAL stdout (modulo the build-constant git
|
||||
/// commit, which is equal within one build). The whole engine rests on a backtest being
|
||||
/// reproducible (C1); the sibling sweep/walkforward paths each have a determinism E2E,
|
||||
/// but the brand-new dual-tap stage1-r run-loop (the SMA→Bias fan into SimBroker + the
|
||||
/// RiskExecutor + the summarize_r fold) had none. A non-determinism in the new R branch
|
||||
/// (e.g. an unstable channel drain order or an allocator-ordered fold) would surface as a
|
||||
/// drift here. Compares the full stdout body, so it pins the metric floats too.
|
||||
#[test]
|
||||
fn run_harness_stage1_r_is_byte_deterministic_across_runs() {
|
||||
let run = || {
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args(["run", "--harness", "stage1-r"])
|
||||
.output()
|
||||
.expect("spawn aura run --harness stage1-r");
|
||||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||||
String::from_utf8(out.stdout).expect("utf-8 stdout")
|
||||
};
|
||||
assert_eq!(run(), run(), "the stage1-r run must be byte-identical across runs (C1)");
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ serde = { workspace = true }
|
||||
# serde_json renders RunReport JSON for both the registry (disk) and stdout
|
||||
# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16).
|
||||
serde_json = { workspace = true }
|
||||
# aura-std's standard nodes back the public composite-builders in src/composites.rs
|
||||
# (vol_stop, risk_executor) — the engine's convenience layer over the standard nodes.
|
||||
# aura-std depends only on aura-core, so this stays acyclic (aura-engine -> aura-std
|
||||
# -> aura-core).
|
||||
aura-std = { path = "../aura-std" }
|
||||
|
||||
[dev-dependencies]
|
||||
aura-std = { path = "../aura-std" }
|
||||
# chrono / chrono-tz build the Berlin-local-wall-clock epoch-ns timestamps the
|
||||
# GER40 session-breakout E2E fixture (tests/ger40_breakout.rs) feeds the engine,
|
||||
# exactly as `Session`'s own unit test does. Pinned to aura-std's versions (the
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Reusable Stage-1 composite-builders: the volatility stop and the per-symbol
|
||||
//! RiskExecutor, promoted from the iter-2 integration-test fixtures so the CLI
|
||||
//! harness (and any consumer) can wire them. Both are `GraphBuilder` compositions
|
||||
//! of `aura-std` primitives — the engine's convenience layer over the standard
|
||||
//! nodes (the sibling of `report::summarize_r`, which likewise lives in this crate
|
||||
//! and reads the `aura-std` PositionManagement record). The Veto is a documented
|
||||
//! seam, not a node (a pass-through identity is exactly what C19/C23 DCE deletes),
|
||||
//! so it appears nowhere here.
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{
|
||||
Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES,
|
||||
};
|
||||
|
||||
use crate::{Composite, GraphBuilder};
|
||||
|
||||
/// The volatility stop as a composition of primitives:
|
||||
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price − Delay(price, 1)`
|
||||
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
|
||||
pub fn vol_stop(length: i64, k: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("vol_stop");
|
||||
let price = g.input_role("price");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
|
||||
let sub = g.add(Sub::builder()); // Δ = price − prev
|
||||
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
|
||||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
g.connect(sub.output("value"), sq.input("lhs"));
|
||||
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
|
||||
g.connect(sq.output("value"), ema.input("series"));
|
||||
g.connect(ema.output("value"), sqrt.input("value"));
|
||||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||||
g.expose(scale.output("value"), "stop_distance");
|
||||
g.build().expect("vol_stop composite wires")
|
||||
}
|
||||
|
||||
/// The protective stop-rule axis (C11 structural): a fixed distance, or the
|
||||
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
|
||||
/// RiskExecutor embeds either by identical downstream wiring.
|
||||
///
|
||||
/// Mirrors the sibling axis enum [`RollMode`](crate::RollMode); `Eq` is omitted
|
||||
/// because the `f64` payloads forbid it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum StopRule {
|
||||
Fixed(f64),
|
||||
Vol { length: i64, k: f64 },
|
||||
}
|
||||
|
||||
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
|
||||
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
|
||||
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
|
||||
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
|
||||
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("risk_executor");
|
||||
let bias = g.input_role("bias");
|
||||
let price = g.input_role("price");
|
||||
let stop = match stop {
|
||||
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
|
||||
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
|
||||
};
|
||||
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
|
||||
let pm = g.add(PositionManagement::builder());
|
||||
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
|
||||
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
|
||||
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
|
||||
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
|
||||
g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
|
||||
for field in PM_FIELD_NAMES {
|
||||
g.expose(pm.output(field), field);
|
||||
}
|
||||
g.build().expect("risk_executor wires")
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
mod blueprint;
|
||||
mod builder;
|
||||
mod composites;
|
||||
mod graph_model;
|
||||
mod harness;
|
||||
mod mc;
|
||||
@@ -56,6 +57,7 @@ pub use blueprint::{
|
||||
Role, SweepBinder,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use composites::{risk_executor, vol_stop, StopRule};
|
||||
pub use graph_model::model_to_json;
|
||||
pub use harness::{
|
||||
window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target,
|
||||
|
||||
@@ -8,8 +8,8 @@ use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
|
||||
ScalarKind, Timestamp,
|
||||
};
|
||||
use aura_engine::{summarize_r, Composite, GraphBuilder, RunMetrics, VecSource};
|
||||
use aura_std::{FixedStop, PositionManagement, Recorder, Sizer, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use aura_engine::{risk_executor, summarize_r, GraphBuilder, RunMetrics, StopRule, VecSource};
|
||||
use aura_std::{Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
// The dense-record columns this fixture reads, named in lockstep with the sibling
|
||||
@@ -18,29 +18,6 @@ use std::sync::mpsc::channel;
|
||||
const REALIZED_R: usize = 1;
|
||||
const SIZE: usize = 10;
|
||||
|
||||
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
|
||||
/// `FixedStop(stop_distance) -> Sizer(risk_budget) -> PositionManagement`, exposing every
|
||||
/// field of PM's dense R-record. Price fans to BOTH the stop-rule and PM; bias fans to the
|
||||
/// Sizer and PM; the Sizer's `size` feeds PM's size slot. (Stage-1 ships `FixedStop` here;
|
||||
/// the volatility stop is a drop-in composite, see `vol_stop_composite.rs`.)
|
||||
fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("risk_executor");
|
||||
let bias = g.input_role("bias");
|
||||
let price = g.input_role("price");
|
||||
let stop = g.add(FixedStop::builder().bind("distance", Scalar::f64(stop_distance)));
|
||||
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
|
||||
let pm = g.add(PositionManagement::builder());
|
||||
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
|
||||
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
|
||||
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
|
||||
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
|
||||
g.connect(sizer.output("size"), pm.input("size")); // the flat-1R size into PM
|
||||
for field in PM_FIELD_NAMES {
|
||||
g.expose(pm.output(field), field);
|
||||
}
|
||||
g.build().expect("risk_executor wires")
|
||||
}
|
||||
|
||||
/// An always-long strategy stand-in: emits a constant `+1` bias once price is present. The
|
||||
/// strategy is upstream of the RiskExecutor; this is the minimal in-graph producer so the
|
||||
/// whole chain runs off the single price source (a second bias *source* would k-way-merge
|
||||
@@ -84,7 +61,7 @@ fn run_executor(prices: &[f64], stop_distance: f64, risk_budget: f64) -> Vec<(Ti
|
||||
let mut g = GraphBuilder::new("risk_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
let strat = g.add(ConstLongBias::builder());
|
||||
let exec = g.add(risk_executor(stop_distance, risk_budget));
|
||||
let exec = g.add(risk_executor(StopRule::Fixed(stop_distance), risk_budget));
|
||||
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
|
||||
g.feed(price, [strat.input("price"), exec.input("price")]);
|
||||
g.connect(strat.output("bias"), exec.input("bias"));
|
||||
@@ -168,3 +145,36 @@ fn folded_rmetrics_survives_runmetrics_serde_round_trip() {
|
||||
let back: RunMetrics = serde_json::from_str(&json).expect("deserialize round-trips");
|
||||
assert_eq!(back, m, "a live-folded RMetrics must round-trip byte-for-byte");
|
||||
}
|
||||
|
||||
/// Property: the RiskExecutor embeds the `vol_stop` composite (the new `StopRule::Vol`
|
||||
/// arm) and folds to a finite RMetric — the volatility-defined default the `stage1-r`
|
||||
/// harness uses. A short k·σ stop over a rising-then-falling path opens a trade and
|
||||
/// (stop or window-end) closes at least one, so the fold is non-empty and sane.
|
||||
#[test]
|
||||
fn risk_executor_vol_stop_arm_bootstraps_and_folds() {
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
let strat = g.add(ConstLongBias::builder());
|
||||
let exec = g.add(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0));
|
||||
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
|
||||
g.feed(price, [strat.input("price"), exec.input("price")]);
|
||||
g.connect(strat.output("bias"), exec.input("bias"));
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
let col: &'static str = format!("col[{i}]").leak();
|
||||
g.connect(exec.output(field), rec.input(col));
|
||||
}
|
||||
let mut h = g
|
||||
.build()
|
||||
.expect("vol_harness wires")
|
||||
.bootstrap_with_params(vec![])
|
||||
.expect("bootstraps");
|
||||
let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0];
|
||||
let stream: Vec<(Timestamp, Scalar)> =
|
||||
path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect();
|
||||
h.run(vec![Box::new(VecSource::new(stream))]);
|
||||
let ledger: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
let m = summarize_r(&ledger, 0.0);
|
||||
assert!(m.n_trades >= 1, "the vol-stop executor must fold at least one trade");
|
||||
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
|
||||
}
|
||||
|
||||
@@ -4,32 +4,10 @@
|
||||
//! `GraphBuilder`, bootstraps, and computes the right number end-to-end — the principle
|
||||
//! that a node expressible as a DAG of primitives is a composition, not a fused node.
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{Composite, GraphBuilder, VecSource};
|
||||
use aura_std::{Delay, Ema, LinComb, Mul, Recorder, Sqrt, Sub};
|
||||
use aura_engine::{vol_stop, GraphBuilder, VecSource};
|
||||
use aura_std::Recorder;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
/// Build the volatility-stop composite: `price -> k · rolling-EWMA-stddev`. The
|
||||
/// `length`, `k`, and the `lag=1` register are bound at construction (no free params).
|
||||
fn vol_stop(length: i64, k: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("vol_stop");
|
||||
let price = g.input_role("price");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
|
||||
let sub = g.add(Sub::builder()); // Δ = price - prev
|
||||
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
|
||||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance
|
||||
let sqrt = g.add(Sqrt::builder()); // -> σ (price units)
|
||||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
g.connect(sub.output("value"), sq.input("lhs"));
|
||||
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
|
||||
g.connect(sq.output("value"), ema.input("series"));
|
||||
g.connect(ema.output("value"), sqrt.input("value"));
|
||||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||||
g.expose(scale.output("value"), "stop_distance");
|
||||
g.build().expect("vol_stop composite wires")
|
||||
}
|
||||
|
||||
/// An alternating ±1 price path makes `Δ² ≡ 1`, so the EWMA variance is `1`, `σ = 1`,
|
||||
/// and `stop_distance = k·σ = k`. With `k = 2.0` the warmed-up output is exactly `2.0`.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user