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:
@@ -0,0 +1,74 @@
|
||||
//! Reusable Stage-1 composite-builders: the volatility stop and the per-symbol
|
||||
//! RiskExecutor, promoted from the iter-2 integration-test fixtures so the CLI
|
||||
//! harness (and any consumer) can wire them. Both are `GraphBuilder` compositions
|
||||
//! of `aura-std` primitives — the engine's convenience layer over the standard
|
||||
//! nodes (the sibling of `report::summarize_r`, which likewise lives in this crate
|
||||
//! and reads the `aura-std` PositionManagement record). The Veto is a documented
|
||||
//! seam, not a node (a pass-through identity is exactly what C19/C23 DCE deletes),
|
||||
//! so it appears nowhere here.
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{
|
||||
Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES,
|
||||
};
|
||||
|
||||
use crate::{Composite, GraphBuilder};
|
||||
|
||||
/// The volatility stop as a composition of primitives:
|
||||
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price − Delay(price, 1)`
|
||||
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
|
||||
pub fn vol_stop(length: i64, k: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("vol_stop");
|
||||
let price = g.input_role("price");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
|
||||
let sub = g.add(Sub::builder()); // Δ = price − prev
|
||||
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
|
||||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
g.connect(sub.output("value"), sq.input("lhs"));
|
||||
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
|
||||
g.connect(sq.output("value"), ema.input("series"));
|
||||
g.connect(ema.output("value"), sqrt.input("value"));
|
||||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||||
g.expose(scale.output("value"), "stop_distance");
|
||||
g.build().expect("vol_stop composite wires")
|
||||
}
|
||||
|
||||
/// The protective stop-rule axis (C11 structural): a fixed distance, or the
|
||||
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
|
||||
/// RiskExecutor embeds either by identical downstream wiring.
|
||||
///
|
||||
/// Mirrors the sibling axis enum [`RollMode`](crate::RollMode); `Eq` is omitted
|
||||
/// because the `f64` payloads forbid it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum StopRule {
|
||||
Fixed(f64),
|
||||
Vol { length: i64, k: f64 },
|
||||
}
|
||||
|
||||
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
|
||||
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
|
||||
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
|
||||
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
|
||||
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("risk_executor");
|
||||
let bias = g.input_role("bias");
|
||||
let price = g.input_role("price");
|
||||
let stop = match stop {
|
||||
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
|
||||
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
|
||||
};
|
||||
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
|
||||
let pm = g.add(PositionManagement::builder());
|
||||
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
|
||||
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
|
||||
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
|
||||
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
|
||||
g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
|
||||
for field in PM_FIELD_NAMES {
|
||||
g.expose(pm.output(field), field);
|
||||
}
|
||||
g.build().expect("risk_executor wires")
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
mod blueprint;
|
||||
mod builder;
|
||||
mod composites;
|
||||
mod graph_model;
|
||||
mod harness;
|
||||
mod mc;
|
||||
@@ -56,6 +57,7 @@ pub use blueprint::{
|
||||
Role, SweepBinder,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use composites::{risk_executor, vol_stop, StopRule};
|
||||
pub use graph_model::model_to_json;
|
||||
pub use harness::{
|
||||
window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target,
|
||||
|
||||
Reference in New Issue
Block a user