feat(stage1-r): operate the R layer from the CLI (iter 3)

`aura run --harness stage1-r [--real <SYM>] [--trace <n>]` now runs a Stage-1 R
backtest and prints its signal-quality metrics (the R block) in the RunReport
JSON - one shell call, the research loop's primary verb, ergonomic for Claude
to drive.

What shipped:
- vol_stop + risk_executor promoted from test fixtures to public aura-engine
  composite-builders, with a StopRule{Fixed,Vol} axis (C11). aura-std becomes a
  normal dependency of aura-engine (the composites are the engine's convenience
  layer over the standard nodes - the sibling tier of summarize_r; the graph
  stays acyclic: aura-engine -> aura-std -> aura-core). Both fixtures call the
  public fns; a Vol-backed RED test pins the new match arm.
- stage1_r_blueprint fans one bias into BOTH a SimBroker (pip) and the
  RiskExecutor (R), so one report carries both yardsticks honestly. run_stage1_r
  folds summarize (pip) + summarize_r (R, round_trip_cost 0.0 - Stage-1 is
  frictionless signal quality) and sets RunMetrics.r = Some(..). An r_equity tap
  (cum_realized_r + unrealized_r via LinComb) persists through a separate 3-tap
  persist_traces_r and renders via the existing `aura chart --tap r_equity`.
- `aura run --harness <sma|macd|stage1-r>`: a parse_run_args tokenizer replaces
  the five `run` literal-slice arms so --harness/--real/--from/--to/--trace
  compose in any order; --macd kept as a back-compat alias; --harness sma the
  default. A compile-time match over Rust-authored built-ins - not a runtime node
  registry, not a DSL (C9/C17): the CLI runs an authored harness, it does not
  wire one.

Back-compat: --harness sma/macd leave RunMetrics.r = None, so skip_serializing_if
keeps pip-only and legacy runs.jsonl JSON byte-unchanged; the plain-run tap list
stays ["equity","exposure"].

Refactor beyond the plan (verified behavior-equivalent): the old standalone
parse_real_args was orphaned by the new tokenizer, so it and its now-redundant
unit test were removed and run's --real parsing inlined into parse_run_args. The
--real strictness (empty-symbol, non-i64 ms, repeated-flag, window-requires-real
-> exit 2) is preserved and now pinned by
parse_run_args_rejects_malformed_real_and_repeated_flags (added to restore the
deleted coverage). All five run_real integration tests stay green.

Verification: cargo build/test --workspace green (every crate, 0 failed); clippy
--workspace --all-targets -D warnings clean. The full diff was adversarially
reviewed (run-arg refactor equivalence against the removed function, harness
wiring, C1/C2/C9/C17, the serde back-compat pin) - verdict SOUND; the one flagged
coverage gap is closed here.

Follow-on (noted, not done): the stage1-r manifest reuses the
"sim-optimal(pip_size=...)" broker label; a dedicated "risk-executor" label would
read more honestly for a dual-tap harness that runs a RiskExecutor branch.

closes #129
refs #117 #128
This commit is contained in:
2026-06-24 12:09:58 +02:00
parent 13086b8590
commit a6fc48daa7
7 changed files with 638 additions and 144 deletions
+74
View File
@@ -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")
}
+2
View File
@@ -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,