diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 989b0d6..5b8637c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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, to_ms: Option, 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 = - 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)> = rx_eq.try_iter().collect(); @@ -607,7 +595,7 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option, 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, to_ms: Option, trace RunReport { manifest, metrics } } -/// Parse the `run --real` tail: a mandatory `` then zero-or-more -/// `--from ` / `--to ` 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, Option, Option), String> { - let usage = || "run --real [--from ] [--to ] [--trace ]".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 = None; - let mut to: Option = None; - let mut trace: Option = 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: ` [--tap ] [--panels]` in any order. fn parse_chart_args(rest: &[&str]) -> Result<(String, Option, ChartMode), String> { let mut name: Option = None; @@ -678,7 +636,8 @@ fn parse_chart_args(rest: &[&str]) -> Result<(String, Option, 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 { 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, + to_ms: Option, +) -> (Box, (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 = + 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, to: Option }, +} + +/// 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)>, + tx_ex: mpsc::Sender<(Timestamp, Vec)>, + tx_r: mpsc::Sender<(Timestamp, Vec)>, + tx_req: mpsc::Sender<(Timestamp, Vec)>, +) -> 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 [--from][--to]] [--trace ]`: 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>, _, f64) = match &data { + RunData::Synthetic => { + let sources: Vec> = + 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)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let req_rows: Vec<(Timestamp, Vec)> = 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)], + ex_rows: &[(Timestamp, Vec)], + req_rows: &[(Timestamp, Vec)], +) { + 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, +} + +/// Parse the `run` tail: `[--harness ] [--macd] [--real [--from ] [--to ]] +/// [--trace ]` 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 { + let usage = || { + "usage: aura run [--harness ] [--macd] [--real [--from ] [--to ]] [--trace ]" + .to_string() + }; + let mut harness: Option = None; + let mut macd_flag = false; + let mut symbol: Option = None; + let mut from: Option = None; + let mut to: Option = None; + let mut trace: Option = 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 { + 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 ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--macd] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; 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 = std::env::args().skip(1).collect(); match args.iter().map(String::as_str).collect::>().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 ``, `--tap `, 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 [--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 + } } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 5b60adf..97ae107 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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 ` 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)"); +} diff --git a/crates/aura-engine/Cargo.toml b/crates/aura-engine/Cargo.toml index 10e7e5b..da0bb63 100644 --- a/crates/aura-engine/Cargo.toml +++ b/crates/aura-engine/Cargo.toml @@ -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 diff --git a/crates/aura-engine/src/composites.rs b/crates/aura-engine/src/composites.rs new file mode 100644 index 0000000..3313255 --- /dev/null +++ b/crates/aura-engine/src/composites.rs @@ -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") +} diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 77f45bb..b1adb9f 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/tests/risk_executor.rs b/crates/aura-engine/tests/risk_executor.rs index a2b73d2..f467966 100644 --- a/crates/aura-engine/tests/risk_executor.rs +++ b/crates/aura-engine/tests/risk_executor.rs @@ -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)> = 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); +} diff --git a/crates/aura-engine/tests/vol_stop_composite.rs b/crates/aura-engine/tests/vol_stop_composite.rs index 9ac5e07..bc6837d 100644 --- a/crates/aura-engine/tests/vol_stop_composite.rs +++ b/crates/aura-engine/tests/vol_stop_composite.rs @@ -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]