From 54a5d68f51c757b48f4863b31a7b9e46387c74f4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 25 Jun 2026 00:43:56 +0200 Subject: [PATCH] feat(stage1-r-sweep): grid signal+stop timescales via CSV flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling change for the strategy-research run: `aura sweep --strategy stage1-r` gains four optional comma-separated grid flags `--fast`, `--slow`, `--stop-length`, `--stop-k` that become the sweep's grid axes over the four stage1_r_graph knobs (sma fast/slow length, vol-stop length, vol-stop k). The family is their cartesian product. This lets a slow time-series-momentum edge be screened across timeframes — Phase 0 showed fast (2/4-bar) signals are chop-dominated noise on M1, so any momentum edge lives at slower timescales, and the stop timescale must be tunable alongside the signal. Mechanism: - aura-composites: factor vol_stop and risk_executor into a single shared topology body each (vol_stop_inner / risk_executor_inner), with the vol-stop knobs either BOUND (the original constant-bind form, byte-identical) or left OPEN and named `stop_length` / `stop_k` so their slots surface in param_space as sweepable axes. New risk_executor_vol_open(risk_budget) is the gridding sibling of risk_executor(StopRule::Vol{..}, ..). One body, two arms → topology cannot drift; a member at any (length, k) matches the bound form byte-for-byte. - aura-cli: parse_csv_list (generic FromStr, rejects non-numeric/empty), pure unit-testable parse_sweep_args, all four knobs gridded via .axis(); absent flags fall back to today's exact defaults (fast {2,3}, slow {6,12}, stop_length {3}, stop_k {2.0}). Default behaviour byte-identical: all existing goldens/sweep tests stay green (541 passed, 0 failed; clippy -D warnings clean). Frictionless Stage-1 R unchanged. refs #137 --- crates/aura-cli/src/main.rs | 230 ++++++++++++++---- crates/aura-composites/src/lib.rs | 70 +++++- crates/aura-composites/tests/risk_executor.rs | 85 +++++++ 3 files changed, 332 insertions(+), 53 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 7cb7b8b..9480ed8 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -15,7 +15,7 @@ mod render; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp}; -use aura_composites::{risk_executor, StopRule}; +use aura_composites::{risk_executor, risk_executor_vol_open, StopRule}; use aura_engine::{ f64_field, join_on_ts, monte_carlo, param_stability, summarize, summarize_r, walk_forward, window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow, @@ -1114,28 +1114,102 @@ fn stage1_r_broker_label(pip_size: f64) -> String { /// member's equity / exposure / r_equity streams are persisted under /// `runs/traces///` via `persist_traces_r` (mirroring /// `momentum_sweep_family`), so a swept member is chartable. -fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { +/// The four griddable knobs of the stage1-r sweep as value lists (#137): the SMA fast +/// / slow lengths and the vol-stop length / `k`-multiplier. The family is the cartesian +/// product of the four lists; absent flags fall back to the historical defaults (fast +/// `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k `{2.0}`) so a no-flags sweep is +/// byte-identical to the pre-#137 family. +#[derive(Clone, Debug, PartialEq)] +struct Stage1RGrid { + fast: Vec, + slow: Vec, + stop_length: Vec, + stop_k: Vec, +} + +impl Default for Stage1RGrid { + fn default() -> Self { + Self { + fast: vec![2, 3], + slow: vec![6, 12], + stop_length: vec![STAGE1_R_STOP_LENGTH], + stop_k: vec![STAGE1_R_STOP_K], + } + } +} + +/// The path-qualified `param_space()` suffix of the open vol-stop's EWMA length knob +/// (the `Ema` named `stop_length` in `risk_executor_vol_open`). The full slot name is +/// resolved at runtime from the live param-space by this suffix, so the composite path +/// prefix is never hand-synced. +const STOP_LENGTH_SUFFIX: &str = ".vol_stop.stop_length.length"; + +/// The path-qualified `param_space()` suffix of the open vol-stop's `k`-multiplier knob +/// (the `LinComb` named `stop_k`, whose single weight is `weights[0]`). +const STOP_K_SUFFIX: &str = ".vol_stop.stop_k.weights[0]"; + +/// The friendly manifest name for an open vol-stop slot, given its path-qualified +/// `param_space()` name: the two stop knobs render as `stop_length` / `stop_k` (their +/// pre-#137 manual manifest names), every other slot unchanged. Decoupling the manifest +/// name from the deep param-space path keeps the family record auditable (C18) while the +/// values flow through the real `.axis(..)` grid. +fn stage1_r_friendly_name(space_name: &str) -> String { + if space_name.ends_with(STOP_LENGTH_SUFFIX) { + "stop_length".to_string() + } else if space_name.ends_with(STOP_K_SUFFIX) { + "stop_k".to_string() + } else { + space_name.to_string() + } +} + +fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily { let pip = data.pip_size(); let window = data.full_window(); // a single throwaway floated build, only to resolve param_space (borrow) then // seed the named axes (move) — its taps are never drained, the per-point run_one // rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self, - // .axis() consumes self, so one build suffices. + // .axis() consumes self, so one build suffices. The vol-stop knobs are left OPEN + // (`stop_open = true`) so all four knobs — signal AND stop — grid through the one + // `.axis(..)` mechanism (#137). let (tx_eq, _) = mpsc::channel(); let (tx_ex, _) = mpsc::channel(); let (tx_r, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel(); - let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None); + let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true); let space = bp.param_space(); - let binder = bp.axis("fast.length", [2, 3]).axis("slow.length", [6, 12]); - let varying: HashSet = binder.varying_axes().into_iter().collect(); + // resolve the open stop slots' exact path-qualified names from the live param-space + // (the composite prefix is never hand-synced — match by the stable suffix). + let stop_length_axis = space + .iter() + .map(|p| p.name.clone()) + .find(|n| n.ends_with(STOP_LENGTH_SUFFIX)) + .expect("open stage1-r vol-stop exposes a stop_length axis"); + let stop_k_axis = space + .iter() + .map(|p| p.name.clone()) + .find(|n| n.ends_with(STOP_K_SUFFIX)) + .expect("open stage1-r vol-stop exposes a stop_k axis"); + let binder = bp + .axis("fast.length", grid.fast.clone()) + .axis("slow.length", grid.slow.clone()) + .axis(&stop_length_axis, grid.stop_length.clone()) + .axis(&stop_k_axis, grid.stop_k.clone()); + // the varying axes drive the member key; translate the deep stop-axis names to the + // friendly `stop_length` / `stop_k` the manifest uses, so the key reads the same + // names whether a signal or a stop axis is what varies. + let varying: HashSet = binder + .varying_axes() + .into_iter() + .map(|n| stage1_r_friendly_name(&n)) + .collect(); binder .sweep(|point| { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, rx_req) = mpsc::channel(); - let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None) + let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true) .bootstrap_with_cells(point) .expect("stage1-r grid points are kind-checked against param_space"); h.run(data.run_sources()); @@ -1143,15 +1217,17 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily 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(); - // record the swept knobs PLUS the fixed R-defining params (the stop and - // bias scale), matching the single run: a family member must be - // reproducible from its own manifest (C18), and the constant stop — which - // defines 1R — must be visible so cross-member SQN comparability (C10) is - // auditable from the family record, not just the floated fast/slow knobs. - let mut named = zip_params(&space, point); + // record the swept knobs PLUS the fixed R-defining bias scale, matching the + // single run: a family member must be reproducible from its own manifest + // (C18). The stop knobs are now real swept axes (they appear in `point`), so + // they reach the manifest via `zip_params` — under their friendly + // `stop_length` / `stop_k` names so cross-member SQN comparability (C10) is + // auditable from the family record by the same names as before. + let mut named: Vec<(String, Scalar)> = zip_params(&space, point) + .into_iter() + .map(|(n, v)| (stage1_r_friendly_name(&n), v)) + .collect(); named.push(("bias_scale".to_string(), Scalar::f64(0.5))); - named.push(("stop_length".to_string(), Scalar::i64(STAGE1_R_STOP_LENGTH))); - named.push(("stop_k".to_string(), Scalar::f64(STAGE1_R_STOP_K))); let key = member_key(&named, &varying); let mut manifest = sim_optimal_manifest(named, window, 0, pip); manifest.broker = stage1_r_broker_label(pip); @@ -1193,19 +1269,41 @@ enum Strategy { Stage1R, } +/// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting +/// any item that fails to parse — the shared validator for the stage1-r grid flags +/// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed +/// `--fast 2,x` is the strict usage path, not a downstream panic. `str::split(',')` +/// always yields at least one item, and an empty item (`""`, or a trailing comma) +/// fails its per-item `parse`, so a non-empty result needs no separate empty-list +/// check — the empty/blank inputs are rejected by the per-item parse itself. +fn parse_csv_list(s: &str) -> Result, ()> { + let items: Vec<&str> = s.split(',').collect(); + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(item.parse::().map_err(|_| ())?); + } + Ok(out) +} + /// Parse the `sweep` tail: -/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ]`. +/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]`. /// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura /// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms, -/// `i64`) require `--real` (there is no synthetic window knob). Pure (no I/O / -/// exit) so the grammar is unit-testable; `main` does the side effects. An unknown -/// token, a flag without its value, an unknown strategy, both name flags, or a -/// window flag without `--real` rejects. -fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice), String> { - let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ]".to_string(); +/// `i64`) require `--real` (there is no synthetic window knob). The four optional grid +/// flags (#137) carry comma-separated value lists that become the stage1-r sweep's +/// grid axes (sma fast/slow length, vol-stop length/`k`); an absent flag keeps the +/// historical default list. Pure (no I/O / exit) so the grammar is unit-testable; +/// `main` does the side effects. An unknown token, a flag without its value, an +/// unknown strategy, both name flags, a window flag without `--real`, or a malformed +/// grid list rejects. +fn parse_sweep_args( + rest: &[&str], +) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> { + let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]".to_string(); let mut strategy = Strategy::SmaCross; let mut name: Option<(String, bool)> = None; // (name, persist) let mut real = RealWindowGrammar::default(); + let mut grid = Stage1RGrid::default(); let mut tail = rest; while let Some((flag, t)) = tail.split_first() { let (value, t) = t.split_first().ok_or_else(usage)?; @@ -1224,12 +1322,16 @@ fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice } "--name" if name.is_none() => name = Some(((*value).to_string(), false)), "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), + "--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?, + "--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?, + "--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?, + "--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?, _ => return Err(usage()), } tail = t; } let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false)); - Ok((strategy, name, persist, real.finish(&usage)?)) + Ok((strategy, name, persist, real.finish(&usage)?, grid)) } /// Parse the `walkforward` tail: `[--real [--from ] [--to ]] [--name | --trace ]`. @@ -1289,7 +1391,7 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { /// (`sma`/`momentum`/`stage1-r`) persists each member's streams under /// `runs/traces///` (opt-in); the `stage1-r` member also carries /// the `r_equity` tap (via `persist_traces_r`). -fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { +fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &Stage1RGrid) { if persist && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) { @@ -1300,7 +1402,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { let family = match strategy { Strategy::SmaCross => sweep_family(persist.then_some(name), &data), Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data), - Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data), + Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data, grid), }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, @@ -1803,16 +1905,21 @@ static COL_PORTS: LazyLock> = /// The Stage-1 R harness topology, shared by the single run and the sweep. The two /// signal knobs are bound when `Some` (single run — identical to the old /// build-time bind, output byte-unchanged) and left free when `None` (sweep — they -/// land in `param_space` as `fast.length` / `slow.length`). Four taps: equity -/// (SimBroker), exposure (Bias), the 14-column R-record (→ summarize_r), and -/// r_equity = cum_realized_r + unrealized_r. +/// land in `param_space` as `fast.length` / `slow.length`). The vol-stop knobs are +/// bound to the pinned constants when `stop_open` is `false` (single run + the +/// default sweep, output byte-unchanged) and left free when `true` (a gridded sweep +/// — they land in `param_space` under the `.vol_stop.stop_length.length` / +/// `.vol_stop.stop_k.weights[0]` suffixes). Four taps: equity (SimBroker), exposure +/// (Bias), the 14-column R-record (→ summarize_r), and r_equity = cum_realized_r + +/// unrealized_r. /// -/// The six-arg signature is a conscious keep, not an oversight: the four `tx_*` +/// The seven-arg signature is a conscious keep, not an oversight: the four `tx_*` /// are the per-tap recorder channels (one Recorder edge each — equity, exposure, -/// the R-record, r_equity), and `fast_len`/`slow_len` are the two floatable signal -/// knobs. A sender bundle would only rename the same four channels into one struct -/// field without removing an edge, trading the lint for indirection; the -/// `too_many_arguments` allow is preferred over that churn. +/// the R-record, r_equity), `fast_len`/`slow_len` are the two floatable signal +/// knobs, and `stop_open` selects the bound-vs-open vol-stop arm. A sender bundle +/// would only rename the same four channels into one struct field without removing +/// an edge, trading the lint for indirection; the `too_many_arguments` allow is +/// preferred over that churn. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn stage1_r_graph( tx_eq: mpsc::Sender<(Timestamp, Vec)>, @@ -1821,6 +1928,7 @@ fn stage1_r_graph( tx_req: mpsc::Sender<(Timestamp, Vec)>, fast_len: Option, slow_len: Option, + stop_open: bool, ) -> Composite { let mut g = GraphBuilder::new("stage1_r"); // SMA-cross signal → Bias (the same signal the pip sample uses). @@ -1840,11 +1948,13 @@ fn stage1_r_graph( 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, - )); + // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop knobs + // are bound to the pinned constants (default) or left open as sweep axes (`stop_open`). + let exec = g.add(if stop_open { + risk_executor_vol_open(1.0) + } else { + risk_executor(StopRule::Vol { length: STAGE1_R_STOP_LENGTH, k: STAGE1_R_STOP_K }, 1.0) + }); let rrec = 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( @@ -1893,7 +2003,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport { 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_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4)) + let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4), false) .compile_with_params(&[]) .expect("valid stage1-r blueprint"); let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness"); @@ -2096,8 +2206,8 @@ fn main() { }, ["graph"] => print!("{}", render::render_html(&sample_blueprint())), ["sweep", rest @ ..] => match parse_sweep_args(rest) { - Ok((strategy, name, persist, choice)) => { - run_sweep(strategy, &name, persist, DataSource::from_choice(choice)) + Ok((strategy, name, persist, choice, grid)) => { + run_sweep(strategy, &name, persist, DataSource::from_choice(choice), &grid) } Err(msg) => { eprintln!("aura: {msg}"); @@ -2960,27 +3070,54 @@ mod tests { #[test] fn parse_sweep_args_defaults_selects_and_rejects() { + let g = Stage1RGrid::default(); assert_eq!( parse_sweep_args(&[]), - Ok((Strategy::SmaCross, "sweep".to_string(), false, DataChoice::Synthetic)) + Ok((Strategy::SmaCross, "sweep".to_string(), false, DataChoice::Synthetic, g.clone())) ); assert_eq!( parse_sweep_args(&["--trace", "swp"]), - Ok((Strategy::SmaCross, "swp".to_string(), true, DataChoice::Synthetic)) + Ok((Strategy::SmaCross, "swp".to_string(), true, DataChoice::Synthetic, g.clone())) ); assert_eq!( parse_sweep_args(&["--name", "s"]), - Ok((Strategy::SmaCross, "s".to_string(), false, DataChoice::Synthetic)) + Ok((Strategy::SmaCross, "s".to_string(), false, DataChoice::Synthetic, g.clone())) ); assert_eq!( parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]), - Ok((Strategy::Momentum, "mom".to_string(), true, DataChoice::Synthetic)) + Ok((Strategy::Momentum, "mom".to_string(), true, DataChoice::Synthetic, g.clone())) ); assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err()); assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value } + /// #137: the four optional grid flags parse comma-separated lists onto the + /// stage1-r grid axes; an absent flag keeps the historical default list, and a + /// malformed list (empty / non-numeric item) is the strict usage error. + #[test] + fn parse_sweep_args_parses_the_four_grid_flags() { + let parsed = parse_sweep_args(&[ + "--strategy", "stage1-r", + "--fast", "240", + "--slow", "960,1200", + "--stop-length", "240", + "--stop-k", "2.0,3.5", + ]) + .expect("the four grid flags parse"); + let grid = parsed.4; + assert_eq!(grid.fast, vec![240]); + assert_eq!(grid.slow, vec![960, 1200]); + assert_eq!(grid.stop_length, vec![240]); + assert_eq!(grid.stop_k, vec![2.0, 3.5]); + // absent flags fall back to the historical defaults. + assert_eq!(parse_sweep_args(&[]).unwrap().4, Stage1RGrid::default()); + // a malformed list is the strict usage error (not a downstream panic). + assert!(parse_sweep_args(&["--fast", "2,x"]).is_err()); // non-numeric item + assert!(parse_sweep_args(&["--fast", ""]).is_err()); // empty item + assert!(parse_sweep_args(&["--stop-k", "abc"]).is_err()); // non-float + } + /// `parse_sweep_args` admits a real symbol with an optional `--from`/`--to` /// window (yielding `DataChoice::Real`), still honouring `--strategy`/`--trace`; /// a `--real` without its symbol, or a window flag without `--real`, is rejected. @@ -2989,7 +3126,8 @@ mod tests { assert_eq!( parse_sweep_args(&["--real", "EURUSD", "--from", "100", "--to", "200", "--trace", "s"]), Ok((Strategy::SmaCross, "s".to_string(), true, - DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) })) + DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) }, + Stage1RGrid::default())) ); assert!(parse_sweep_args(&["--real"]).is_err()); // --real needs a symbol assert!(parse_sweep_args(&["--from", "100"]).is_err()); // --from without --real diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index dd10bbe..2a93626 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -16,7 +16,7 @@ //! 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_engine::{Composite, GraphBuilder}; +use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_std::{ Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES, }; @@ -25,14 +25,33 @@ use aura_std::{ /// `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 { + vol_stop_inner(Some((length, k))) +} + +/// The single `vol_stop` topology, with its two knobs either BOUND to constants +/// (`Some((length, k))` — the [`vol_stop`] form, byte-identical to the original +/// build-time bind) or left OPEN (`None` — the gridding form: the EWMA and scale nodes +/// are `named` `stop_length` / `stop_k` so their slots surface in `param_space` under +/// the `.stop_length.length` / `.stop_k.weights[0]` suffixes). One body, so the +/// topology — every node, edge, and exposed role — cannot drift between the two arms; +/// only the two knobs differ (the `Option`-knob idiom `stage1_r_graph` uses). +fn vol_stop_inner(knobs: Option<(i64, 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 + // EWMA variance: length bound (Some) or open and named `stop_length` (None). + let ema = g.add(match knobs { + Some((length, _)) => Ema::builder().bind("length", Scalar::i64(length)), + None => Ema::builder().named("stop_length"), + }); let sqrt = g.add(Sqrt::builder()); // → σ (price units) - let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ + // k·σ: weights[0] bound (Some) or open and named `stop_k` (None). + let scale = g.add(match knobs { + Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)), + None => LinComb::builder(1).named("stop_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")); @@ -61,13 +80,33 @@ pub enum StopRule { /// 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 { + // Add the chosen stop into the shared body's builder: a FixedStop node directly, + // or the BOUND `vol_stop` composite — byte-identical to the pre-dedup arms. + risk_executor_inner( + Box::new(move |g| match stop { + StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), + StopRule::Vol { length, k } => g.add(vol_stop(length, k)), + }), + risk_budget, + ) +} + +/// The shared `risk_executor` body, given a closure that adds the chosen stop node +/// (returning its handle). Open input roles `bias` + `price`, internal +/// ` → Sizer → PositionManagement`, exposing every field of PM's dense R-record; +/// price fans to BOTH the stop and PM, bias to the Sizer and PM, the Sizer's `size` +/// into PM. Every stop exposes `price → stop_distance`, so a fixed stop, a bound +/// vol-stop, or an OPEN vol-stop all embed by this one identical downstream wiring — +/// the single place the fan / Sizer / PM / exposed record live, so [`risk_executor`] +/// and [`risk_executor_vol_open`] cannot drift apart (one body, two stop arms). +fn risk_executor_inner( + add_stop: Box NodeHandle + '_>, + 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 stop = add_stop(&mut g); 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 @@ -80,3 +119,20 @@ pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite { } g.build().expect("risk_executor wires") } + +/// A `vol_stop`-armed RiskExecutor whose two stop knobs are left **open** (unbound) +/// so they enter `param_space` as sweepable axes — the gridding sibling of +/// [`risk_executor`]`(StopRule::Vol { .. }, ..)`, which binds them to constants. The +/// interior `vol_stop`'s EWMA length and `k`-multiplier nodes are named `stop_length` +/// / `stop_k` so their path-qualified slots are addressable as +/// `.vol_stop.stop_length.length` (I64) and `.vol_stop.stop_k.weights[0]` +/// (F64). Everything else (the price/bias fan, Sizer, PositionManagement, the exposed +/// dense R-record) is identical to [`risk_executor`], so a member built from any +/// `(length, k)` point matches the bound form at the same values byte-for-byte. +pub fn risk_executor_vol_open(risk_budget: f64) -> Composite { + // The same shared body as [`risk_executor`], with the OPEN vol-stop + // (`vol_stop_inner(None)`) as the stop arm — so the price/bias fan, Sizer, PM, + // and exposed dense R-record are guaranteed identical to the bound form, and a + // member built from any `(length, k)` point matches the bound form byte-for-byte. + risk_executor_inner(Box::new(|g| g.add(vol_stop_inner(None))), risk_budget) +} diff --git a/crates/aura-composites/tests/risk_executor.rs b/crates/aura-composites/tests/risk_executor.rs index d83bd89..d7538ff 100644 --- a/crates/aura-composites/tests/risk_executor.rs +++ b/crates/aura-composites/tests/risk_executor.rs @@ -179,3 +179,88 @@ fn risk_executor_vol_stop_arm_bootstraps_and_folds() { 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); } + +/// Drain one `risk_executor`-variant harness over the shared rising-then-falling path, +/// folding the dense R-record. `exec` is the variant under test (bound or open); `params` +/// is the positional point the open variant needs (empty for the bound one). Shared by the +/// open-vs-bound equivalence test below so the two arms run byte-identical inputs. +fn fold_executor(exec: aura_engine::Composite, params: Vec) -> aura_engine::RMetrics { + let (tx, rx) = channel(); + let mut g = GraphBuilder::new("vol_open_harness"); + let price = g.source_role("price", ScalarKind::F64); + let strat = g.add(ConstLongBias::builder()); + let exec = g.add(exec); + 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_open_harness wires") + .bootstrap_with_params(params) + .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(); + summarize_r(&ledger, 0.0) +} + +/// Property (#137): `risk_executor_vol_open` exposes the vol-stop's EWMA length and +/// `k`-multiplier as exactly two FREE `param_space` axes — the I64 `length` under the +/// `vol_stop.stop_length.length` suffix and the F64 `weights[0]` under +/// `vol_stop.stop_k.weights[0]` — so a sweep can grid the stop timescale through the +/// `.axis(..)` mechanism. (As the root composite here its names carry no outer prefix; +/// embedded in a harness they gain the enclosing node's path, hence the suffix match.) +/// The bound `risk_executor(StopRule::Vol { .. }, ..)` exposes none of these (its knobs +/// are constants), which is why the gridding variant exists. +#[test] +fn risk_executor_vol_open_exposes_the_two_stop_knobs_as_free_axes() { + let open = aura_composites::risk_executor_vol_open(1.0); + let space = open.param_space(); + let length = space + .iter() + .find(|p| p.name.ends_with("vol_stop.stop_length.length")) + .expect("open vol-stop exposes a length axis"); + assert_eq!(length.kind, ScalarKind::I64, "stop length axis is I64"); + let k = space + .iter() + .find(|p| p.name.ends_with("vol_stop.stop_k.weights[0]")) + .expect("open vol-stop exposes a k axis"); + assert_eq!(k.kind, ScalarKind::F64, "stop k axis is F64"); + // exactly the two stop knobs are open (the rest — Delay lag, Sizer risk_budget — are bound). + assert_eq!(space.len(), 2, "only the two stop knobs are free: {space:?}"); + + // the bound arm has no open knobs at all (its stop is a constant). + assert!( + aura_composites::risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0) + .param_space() + .is_empty(), + "the bound vol-stop arm exposes no sweepable knobs" + ); +} + +/// Property (#137): the OPEN vol-stop arm, bootstrapped at a given `(length, k)`, folds to +/// the SAME R-outcome as the BOUND arm at the same values — the byte-equivalence the +/// no-flags stage1-r sweep relies on to stay golden (the gridding variant only frees the +/// two knobs; topology and every other constant are unchanged). The open arm's positional +/// point is `[length, k]` in `param_space` slot order (length first, then k). +#[test] +fn risk_executor_vol_open_matches_the_bound_arm_at_the_same_point() { + let bound = fold_executor(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0), vec![]); + let open = fold_executor( + aura_composites::risk_executor_vol_open(1.0), + vec![Scalar::i64(3), Scalar::f64(2.0)], + ); + assert_eq!(open.n_trades, bound.n_trades, "trade count must match the bound arm"); + assert_eq!(open.sqn.to_bits(), bound.sqn.to_bits(), "SQN must be bit-identical to the bound arm"); + assert_eq!( + open.expectancy_r.to_bits(), + bound.expectancy_r.to_bits(), + "E[R] must be bit-identical to the bound arm" + ); +}