feat(stage1-r-sweep): grid signal+stop timescales via CSV flags

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
This commit is contained in:
2026-06-25 00:43:56 +02:00
parent e94b79eb98
commit 54a5d68f51
3 changed files with 332 additions and 53 deletions
+184 -46
View File
@@ -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/<name>/<member_key>/` 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<i64>,
slow: Vec<i64>,
stop_length: Vec<i64>,
stop_k: Vec<f64>,
}
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<String> = 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<String> = 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<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();
// 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<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
let items: Vec<&str> = s.split(',').collect();
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(item.parse::<T>().map_err(|_| ())?);
}
Ok(out)
}
/// Parse the `sweep` tail:
/// `[--strategy <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
/// `[--strategy <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]`.
/// 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 <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".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 <sma|momentum|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]".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 <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
@@ -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/<n>/<member_key>/` (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<Vec<String>> =
/// 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<Scalar>)>,
@@ -1821,6 +1928,7 @@ fn stage1_r_graph(
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
fast_len: Option<i64>,
slow_len: Option<i64>,
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
+63 -7
View File
@@ -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<i64>`-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
/// `<stop> → 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<dyn FnOnce(&mut GraphBuilder) -> 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
/// `<path>.vol_stop.stop_length.length` (I64) and `<path>.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)
}
@@ -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<Scalar>) -> 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<Scalar>)> = 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"
);
}