diff --git a/docs/plans/0065-stage1-r-signal-quality.md b/docs/plans/0065-stage1-r-signal-quality.md deleted file mode 100644 index a89941a..0000000 --- a/docs/plans/0065-stage1-r-signal-quality.md +++ /dev/null @@ -1,867 +0,0 @@ -# Stage-1 R-based signal quality — Iteration 1 — Implementation Plan - -> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Land the Stage-1 R-machinery — a strategy's signal quality measured in R -on a `bias` stream, feed-forward — as runnable, tested `aura-std` nodes plus a -post-run `summarize_r` fold. - -**Architecture:** `bias → stop-rule (VolStop/FixedStop, emits stop_distance) → -position-management (dense per-cycle R-record) → Recorder → summarize_r → RMetrics`. -position-management is the stateful heart (latch the entry stop distance as the -frozen R-denominator; mark/exit no-look-ahead like `SimBroker`; stop-outs not capped -at −1R). The R-evaluator is the post-run fold `summarize_r`, **not** an in-graph node. - -**Tech Stack:** Rust, `aura-core` (Node/Cell/Scalar), `aura-std` (the new nodes), -`aura-engine/src/report.rs` (the fold). `aura-std` is a **dev-dependency** of -`aura-engine`, so `summarize_r` (non-test) reads the dense record by **column index + -convention** (a guard test asserts the layout matches). - ---- - -## Scope decisions (resolving plan-recon's open questions — orchestrator's call) - -The `exposure → bias` rename radius is large (319 `exposure` + 83 `Exposure` lines / -37 files). commit-0 is scoped to the **semantic core of #126** and defers the rest as -behaviour-preserving cosmetics (recorded on #117): - -- **IN commit-0:** the `Exposure` *type* → `Bias` (struct/impl/`new`/builder-name/ - closure/`label`/file `exposure.rs`→`bias.rs`/`lib.rs` mod+use), every importer's - `Exposure::new`/`Exposure::builder`/`use …Exposure` (compiler-enumerated), and the - **output FieldSpec name `"exposure"` → `"bias"`** with its three content-pins - (`graph_model.rs` expected JSON, `fixtures/sample-model.json`, the `sma.rs` label - pin `"Exposure(0.5)"`→`"Bias(0.5)"`). -- **DEFERRED (kept green, NOT this iteration):** `SimBroker`'s slot-0 port name - `"exposure"` (renaming it churns all `broker.input("exposure")` wiring + its test; - `SimBroker` is the pre-reframe node, retained unchanged); `RunMetrics.exposure_sign_flips` - (a pip-side turnover proxy orthogonal to R — renaming touches a `registry` enum - variant + 9 files for zero R-value); the node *instance* label `.named("exposure")` - and everything derived from it (`exposure.scale` param-path, `"exposure_scale"` - manifests, member-dir names, the recorded tap name `"exposure"`); `summarize`'s 2nd - param + local `exposure` vars; `LongOnly`'s field; `Latch` prose. These are - behaviour-preserving to leave and are a separate cosmetic-modernization follow-up. -- **File layout:** `VolStop` + `FixedStop` share one file `stop_rule.rs`. -- **`ExitReason`** lives in `aura-std` (`position_management.rs`); `summarize_r` - (in `aura-engine`, which only dev-depends on `aura-std`) matches the **raw i64** - (`0=stop, 1=bias-flip, 2=reversal-leg, 3=window-end`) by convention, guarded by a test. - -**Timing convention (the keystone — SimBroker mirror, pins spec §4).** A trade opens -at the observed close of the cycle its bias first goes nonzero (`entry_price = -price_t`); it earns from the **next** cycle (its mark at the entry cycle is 0 — the -analogue of `SimBroker` setting `prev_exposure` *after* taking PnL, so a new position -never earns the pre-entry move). An exit (stop / bias-flip / window-end) realises -against the **current** close `price_t` (a stop fills no better than the stop level). -This is no-look-ahead: a position held into cycle t earns t's move and then closes, exactly -as `SimBroker`'s held exposure earns t's move before the flip takes effect. No -`prev_price` is needed — the position's own `entry_price` is the memory. - ---- - -**Files this plan creates or modifies:** - -- Modify→rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs` — `Bias` node (was `Exposure`); output field `"exposure"`→`"bias"`; `label` `"Bias(..)"`. -- Modify: `crates/aura-std/src/lib.rs:23,39` — `mod bias; pub use bias::Bias;` (+ new `mod stop_rule; mod position_management;` + their `pub use`). -- Modify: `crates/aura-engine/src/graph_model.rs:497` — expected-JSON literal `"type":"Exposure"`→`"Bias"`, `["exposure","f64"]`→`["bias","f64"]` (only if this fixture uses the Bias node; verify by build/test). -- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — same `Exposure`/`exposure`→`Bias`/`bias` (verify usage). -- Modify: `crates/aura-std/src/sma.rs:205,212` — cross-node label pin `"Exposure(0.5)"`→`"Bias(0.5)"`, `Exposure::new`/`builder`→`Bias`. -- Modify (compiler-enumerated `Exposure`→`Bias` ctor/builder/use): `crates/aura-engine/src/{report.rs,blueprint.rs,builder.rs,harness.rs,graph_model.rs,test_fixtures.rs}`, `crates/aura-cli/src/main.rs`, `crates/aura-ingest/tests/{real_bars.rs,streaming_seam.rs}`, `crates/aura-engine/tests/random_sweep_e2e.rs`. -- Create: `crates/aura-std/src/stop_rule.rs` — `VolStop` + `FixedStop` (+ inline tests). -- Create: `crates/aura-std/src/position_management.rs` — `PositionManagement` + `ExitReason` + layout consts (+ inline tests, incl. the no-look-ahead keystone). -- Modify: `crates/aura-engine/src/report.rs` — add `RMetrics` (near `RunMetrics` ~16) + `summarize_r` (near `summarize` ~122) + inline tests. -- Create: `crates/aura-engine/tests/stage1_r_e2e.rs` — the synthetic bias+price→stop→position-management→Recorder→summarize_r E2E + the dense-record layout guard test (dev-context, imports `aura-std`). - ---- - -## Task 1: `Exposure` → `Bias` rename (compiler-driven, behaviour-preserving) - -**Files:** -- Rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs` -- Modify: `crates/aura-std/src/lib.rs`, `crates/aura-std/src/sma.rs`, the importer files above, the 2 content-pin fixtures. - -- [ ] **Step 1: Rename the file + the type/field inside it** - -`git mv crates/aura-std/src/exposure.rs crates/aura-std/src/bias.rs`, then in `bias.rs` -apply (computation UNCHANGED — `clamp(signal/scale,-1,+1)`; only names + the doc semantics change): - -```rust -//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional -//! bias (C10). The strategy's primary output: one f64 input, one f64 output -//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional) -//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer). -//! `scale` sets which signal magnitude maps to full conviction. -use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, - ScalarKind, -}; - -/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`. -/// Emits `None` until its input is present (warm-up filter, C8). -pub struct Bias { - scale: f64, - out: [Cell; 1], -} - -impl Bias { - /// Build a bias node with saturation magnitude `scale` (must be > 0). - pub fn new(scale: f64) -> Self { - assert!(scale > 0.0, "Bias scale must be > 0"); - Self { scale, out: [Cell::from_f64(0.0)] } - } - - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Bias", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }], - output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], - }, - |p| Box::new(Bias::new(p[0].f64())), - ) - } -} - -impl Node for Bias { - fn lookbacks(&self) -> Vec { vec![1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let w = ctx.f64_in(0); - if w.is_empty() { return None; } - self.out[0] = Cell::from_f64((w[0] / self.scale).clamp(-1.0, 1.0)); - Some(&self.out) - } - fn label(&self) -> String { format!("Bias({})", self.scale) } -} -``` - -Rename the inline tests: `exposure_clamps_to_unit_band`→`bias_clamps_to_unit_band`, -`exposure_is_none_until_input_present`→`bias_is_none_until_input_present`, -`input_slot_is_named_signal` keeps its name but assert stays `"signal"`. Update their -bodies `Exposure`→`Bias` and any `"exposure"` output-name assert → `"bias"`. - -- [ ] **Step 2: Update `lib.rs` exports** - -In `crates/aura-std/src/lib.rs`: `mod exposure;`→`mod bias;`; `pub use exposure::Exposure;`→`pub use bias::Bias;`. -(The new-node mod/use lines are added in Tasks 2 & 3.) - -- [ ] **Step 3: Update the cross-node label pin in `sma.rs`** - -`crates/aura-std/src/sma.rs:205,212` — in `labels_carry_identifying_params` and -`nodes_declare_expected_params`: `use …Exposure`→`Bias`, `Exposure::new(0.5)`→`Bias::new(0.5)`, -`Exposure::builder()`→`Bias::builder()`, and the label assert `"Exposure(0.5)"`→`"Bias(0.5)"`. - -- [ ] **Step 4: Build — let the compiler enumerate the importer call sites** - -Run: `cargo build --workspace 2>&1 | grep -E "error|cannot find" | head -40` -Expected: a finite list of `cannot find … Exposure` errors across `aura-engine` -(`report.rs`, `blueprint.rs`, `builder.rs`, `harness.rs`, `graph_model.rs`, -`test_fixtures.rs`), `aura-cli` (`main.rs`), `aura-ingest` tests, `random_sweep_e2e.rs`. - -- [ ] **Step 5: Replace each `Exposure` ctor/builder/use site with `Bias`** - -At every site the build reported: `use aura_std::Exposure`→`use aura_std::Bias`, -`Exposure::new(`→`Bias::new(`, `Exposure::builder()`→`Bias::builder()`. Do NOT touch -the node *instance* label `.named("exposure")`, the `exposure.scale` param-paths, the -`"exposure"` tap names, `RunMetrics.exposure_sign_flips`, or `SimBroker`'s port (all -DEFERRED). Re-run `cargo build --workspace` until clean. - -- [ ] **Step 6: Fix the two output-field content-pins** - -Run: `cargo test --workspace 2>&1 | grep -E "FAILED|panicked|assertion" | head -30` -For each failure asserting the rendered output field/type (expected -`graph_model.rs:497` literal + `fixtures/sample-model.json`): update `"Exposure"`→`"Bias"` -and the output field `["exposure","f64"]`→`["bias","f64"]` **only where it is the Bias -node's own type/output** (a port named "exposure" on another node, e.g. SimBroker's -input, stays). Re-run until green. - -- [ ] **Step 7: Full suite green-unchanged (the behaviour-preserving gate)** - -Run: `cargo test --workspace 2>&1 | tail -15` -Expected: all green (same count as before the rename ± the renamed test names). No new -failures. Then `cargo clippy --workspace --all-targets -- -D warnings` clean. - ---- - -## Task 2: `stop_rule.rs` — `FixedStop` + `VolStop` - -**Files:** -- Create: `crates/aura-std/src/stop_rule.rs` -- Modify: `crates/aura-std/src/lib.rs` (mod + use) - -- [ ] **Step 1: Write the file with both nodes + their RED tests** - -```rust -//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0, -//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped); -//! position-management latches the entry-cycle distance as the frozen R-denominator. -//! `FixedStop` is a constant distance (test fixture / structural-axis sibling); -//! `VolStop` is a close-to-close volatility stop `k * EMA_length(|price - prev_price|)` -//! (true-range ATR is deferred — it needs OHLC). One fused node each (no Abs/Mul -//! primitive exists, so abs+delta+EMA fuse inside VolStop). -use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, - ScalarKind, -}; - -/// Constant stop distance. `distance` must be > 0. -pub struct FixedStop { distance: f64, out: [Cell; 1] } -impl FixedStop { - pub fn new(distance: f64) -> Self { - assert!(distance > 0.0, "FixedStop distance must be > 0"); - Self { distance, out: [Cell::from_f64(distance)] } - } - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "FixedStop", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }], - output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "distance".into(), kind: ScalarKind::F64 }], - }, - |p| Box::new(FixedStop::new(p[0].f64())), - ) - } -} -impl Node for FixedStop { - fn lookbacks(&self) -> Vec { vec![1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - if ctx.f64_in(0).is_empty() { return None; } // fire with price (warm-up filter) - self.out[0] = Cell::from_f64(self.distance); - Some(&self.out) - } - fn label(&self) -> String { format!("FixedStop({})", self.distance) } -} - -/// Volatility stop distance: `k * EMA_length(|price - prev_price|)`. EMA is the -/// standard `alpha = 2/(length+1)` recursion, seeded by the first abs-return; `None` -/// until `length` abs-returns have been seen (warm-up). `prev_price` updated AFTER use -/// (intra-node z⁻¹, C2-clean). `length >= 1`, `k > 0`. -pub struct VolStop { - length: usize, - k: f64, - prev_price: Option, - ema: f64, - count: usize, - out: [Cell; 1], -} -impl VolStop { - pub fn new(length: usize, k: f64) -> Self { - assert!(length >= 1, "VolStop length must be >= 1"); - assert!(k > 0.0, "VolStop k must be > 0"); - Self { length, k, prev_price: None, ema: 0.0, count: 0, out: [Cell::from_f64(0.0)] } - } - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "VolStop", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }], - output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }], - params: vec![ - ParamSpec { name: "length".into(), kind: ScalarKind::I64 }, - ParamSpec { name: "k".into(), kind: ScalarKind::F64 }, - ], - }, - |p| Box::new(VolStop::new(p[0].i64() as usize, p[1].f64())), - ) - } -} -impl Node for VolStop { - fn lookbacks(&self) -> Vec { vec![1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let w = ctx.f64_in(0); - if w.is_empty() { return None; } - let price = w[0]; - let Some(pp) = self.prev_price else { - self.prev_price = Some(price); - return None; // need a prior price to form the first abs-return - }; - let d = (price - pp).abs(); - let alpha = 2.0 / (self.length as f64 + 1.0); - if self.count == 0 { self.ema = d; } else { self.ema += alpha * (d - self.ema); } - self.count += 1; - self.prev_price = Some(price); // update AFTER use (C2) - if self.count < self.length { return None; } // warm-up - self.out[0] = Cell::from_f64(self.k * self.ema); - Some(&self.out) - } - fn label(&self) -> String { format!("VolStop({},{})", self.length, self.k) } -} - -#[cfg(test)] -mod tests { - use super::*; - use aura_core::{AnyColumn, Scalar, Timestamp}; - fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec> { - let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; - let mut out = vec![]; - for &p in prices { - col[0].push(Scalar::f64(p)).unwrap(); - out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64())); - } - out - } - #[test] - fn fixed_stop_is_constant_after_first_price() { - let mut s = FixedStop::new(2.5); - assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]); - } - #[test] - fn vol_stop_is_none_until_warm() { - // length 3: needs a prior price (cycle 1 -> None) then 3 abs-returns. - let mut s = VolStop::new(3, 2.0); - let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]); - assert_eq!(got[0], None); // no prior price - assert_eq!(got[1], None); // 1 return - assert_eq!(got[2], None); // 2 returns - assert!(got[3].is_some()); // 3 returns -> warm - } - #[test] - fn vol_stop_tracks_k_times_ema_abs_return() { - // constant abs-return of 1.0 -> EMA = 1.0 -> distance = k*1.0 = 2.0. - let mut s = VolStop::new(2, 2.0); - let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]); - assert_eq!(got.last().unwrap().unwrap(), 2.0); - } -} -``` - -- [ ] **Step 2: Wire the module + run the tests** - -Add to `crates/aura-std/src/lib.rs`: `mod stop_rule;` and `pub use stop_rule::{FixedStop, VolStop};`. -Run: `cargo test -p aura-std stop` Expected: PASS (`fixed_stop_is_constant_after_first_price`, -`vol_stop_is_none_until_warm`, `vol_stop_tracks_k_times_ema_abs_return`). - ---- - -## Task 3: `position_management.rs` — `ExitReason`, layout, node skeleton - -**Files:** -- Create: `crates/aura-std/src/position_management.rs` -- Modify: `crates/aura-std/src/lib.rs` - -- [ ] **Step 1: Write the enum, layout constants, struct, schema, builder** - -```rust -//! `PositionManagement` — the stateful heart of Stage-1 risk-based execution. Turns a -//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes. -//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the -//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the -//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end -//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY: -//! `realized_r = direction * (exit - entry) / latched_distance`. -use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind, - Timestamp, -}; - -/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r` -/// matches the raw i64 (it lives across a crate boundary — aura-std is only a -/// dev-dependency of aura-engine), so the encoding is the load-bearing contract. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(i64)] -pub enum ExitReason { Stop = 0, BiasFlip = 1, ReversalLeg = 2, WindowEnd = 3 } - -/// Dense record column layout — the lockstep contract shared (by convention) with the -/// `Recorder` tap kinds and `summarize_r`'s column reads. -pub const WIDTH: usize = 14; -pub const FIELD_NAMES: [&str; WIDTH] = [ - "closed_this_cycle", "realized_r", "exit_reason", "was_stopped", "direction", - "entry_ts", "entry_price", "stop_price", "exit_price", "bias_at_entry_abs", - "size", "open", "unrealized_r", "cum_realized_r", -]; -pub const RECORD_KINDS: [ScalarKind; WIDTH] = { - use ScalarKind::*; - [Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64] -}; - -struct Open { dir: i64, entry: f64, latched_dist: f64, stop_level: f64, entry_ts: Timestamp, bias_abs: f64 } - -pub struct PositionManagement { - pos: Option, - cum_realized_r: f64, - out: [Cell; WIDTH], -} -impl PositionManagement { - pub fn new() -> Self { Self { pos: None, cum_realized_r: 0.0, out: [Cell::from_f64(0.0); WIDTH] } } - pub fn builder() -> PrimitiveBuilder { - let inputs = vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, - ]; - let output = FIELD_NAMES.iter().zip(RECORD_KINDS).map(|(n, k)| FieldSpec { name: (*n).into(), kind: k }).collect(); - PrimitiveBuilder::new( - "PositionManagement", - NodeSchema { inputs, output, params: vec![] }, - |_| Box::new(PositionManagement::new()), - ) - } -} -impl Default for PositionManagement { fn default() -> Self { Self::new() } } - -fn sign0(v: f64) -> f64 { if v > 0.0 { 1.0 } else if v < 0.0 { -1.0 } else { 0.0 } } -``` - -- [ ] **Step 2: Wire the module + a warm-up RED test** - -Add to `lib.rs`: `mod position_management;` and `pub use position_management::{PositionManagement, ExitReason};`. -Add this inline test (skeleton `eval` returning `None` makes it RED until Task 4): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use aura_core::{AnyColumn, Scalar}; - // Drive one cycle: push bias/price/stop into the three slots, eval, return the row. - fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { - cols[0].push(Scalar::f64(bias)).unwrap(); - cols[1].push(Scalar::f64(price)).unwrap(); - cols[2].push(Scalar::f64(dist)).unwrap(); - n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec() - } - fn cols() -> Vec { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } - - #[test] - fn emits_one_dense_record_per_cycle() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat - assert_eq!(row.len(), WIDTH); - assert!(!row[0].bool()); // closed_this_cycle = false - assert!(!row[11].bool()); // open = false - } -} -``` - -Run: `cargo test -p aura-std emits_one_dense_record` Expected: **FAIL** (skeleton `eval` -is `None` / unimplemented) — this is the RED gate for Task 4. - ---- - -## Task 4: `PositionManagement` lifecycle — the no-look-ahead keystone - -**Files:** Modify `crates/aura-std/src/position_management.rs` (add `Node` impl + tests). - -- [ ] **Step 1: Write the keystone RED tests** - -Add to the `#[cfg(test)] mod tests`: - -```rust - // (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance - // 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the - // move to 110; the flip closes it THERE, never retroactively flattening it). - #[test] - fn no_lookahead_bias_exit_realises_the_held_move() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 - let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit - assert!(row[0].bool()); // closed_this_cycle - assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0 - assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64); - assert!(!row[3].bool()); // not stopped - } - // A position opened this cycle never earns the pre-entry move: entered @110, its - // own-cycle unrealized R is 0. - #[test] - fn entry_does_not_earn_pre_entry_move() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat - let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110 - assert!(row[11].bool()); // open - assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle - } - // (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1. - #[test] - fn stop_out_is_not_capped_at_minus_one_r() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 - let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90) - assert!(row[0].bool()); - assert!(row[3].bool()); // was_stopped - assert_eq!(row[2].i64(), ExitReason::Stop as i64); - assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail - } - // A no-gap stop fills exactly at the stop -> exactly -1R. - #[test] - fn no_gap_stop_is_exactly_minus_one_r() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 - let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly - assert_eq!(row[1].f64(), -1.0); - } - // (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg - // (one record, ReversalLeg) and reopens short as state (open=true, dir=-1). - #[test] - fn reversal_closes_one_leg_and_reopens() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100 - let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110 - assert!(row[0].bool()); // one close - assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10 - assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64); - assert!(row[11].bool()); // reopened: open - assert_eq!(row[4].i64(), -1); // new direction short - } -``` - -- [ ] **Step 2: Run to confirm RED** - -Run: `cargo test -p aura-std position_management` -Expected: FAIL (the lifecycle is not implemented; `no_lookahead_…`, `stop_out_…`, etc. fail). - -- [ ] **Step 3: Implement the `Node` impl** - -```rust -impl Node for PositionManagement { - fn lookbacks(&self) -> Vec { vec![1, 1, 1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let pw = ctx.f64_in(1); - if pw.is_empty() { return None; } // no price yet — nothing to do - let price = pw[0]; - let bias = ctx.f64_in(0).get(0).unwrap_or(0.0); - let dist = ctx.f64_in(2).get(0).unwrap_or(0.0); - let now = ctx.now(); - - let mut closed = false; - let mut realized = 0.0; - let mut reason = ExitReason::Stop as i64; - let mut was_stopped = false; - let mut ex_dir = 0i64; - let mut ex_entry_ts = Timestamp(0); - let mut ex_entry = 0.0; - let mut ex_stop = 0.0; - let mut ex_exit = 0.0; - let mut ex_bias_abs = 0.0; - - // (A) Maintain/exit the position held into this cycle, marked to `price`. - if let Some(p) = &self.pos { - let stop_hit = (p.dir > 0 && price <= p.stop_level) || (p.dir < 0 && price >= p.stop_level); - let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0 - if stop_hit { - let fill = if p.dir > 0 { price.min(p.stop_level) } else { price.max(p.stop_level) }; - realized = p.dir as f64 * (fill - p.entry) / p.latched_dist; - was_stopped = true; - reason = ExitReason::Stop as i64; - ex_exit = fill; - (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs); - self.cum_realized_r += realized; - closed = true; - self.pos = None; - } else if bias_exit { - realized = p.dir as f64 * (price - p.entry) / p.latched_dist; - reason = if sign0(bias) != 0.0 { ExitReason::ReversalLeg as i64 } else { ExitReason::BiasFlip as i64 }; - ex_exit = price; - (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs); - self.cum_realized_r += realized; - closed = true; - self.pos = None; - } - } - // (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available. - if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 { - let dir = sign0(bias) as i64; - let stop_level = if dir > 0 { price - dist } else { price + dist }; - self.pos = Some(Open { dir, entry: price, latched_dist: dist, stop_level, entry_ts: now, bias_abs: bias.abs() }); - } - // (C) Open-position fields (carry for window-end) + unrealized mark. - let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos { - Some(p) => (true, p.dir as f64 * (price - p.entry) / p.latched_dist, p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs), - None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0), - }; - // Trade-detail columns describe the CLOSED trade if closed, else the OPEN one. - let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed { - (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs) - } else { - (o_dir, o_ets, o_entry, o_stop, 0.0, o_babs) - }; - self.out = [ - Cell::from_bool(closed), Cell::from_f64(realized), Cell::from_i64(reason), - Cell::from_bool(was_stopped), Cell::from_i64(d_dir), Cell::from_ts(d_ets), - Cell::from_f64(d_entry), Cell::from_f64(d_stop), Cell::from_f64(d_exit), - Cell::from_f64(d_babs), Cell::from_f64(1.0), // size: flat-1R placeholder (iter-2 Sizer) - Cell::from_bool(open), Cell::from_f64(unrealized), Cell::from_f64(self.cum_realized_r), - ]; - debug_assert!(!closed || (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs() < 1e-9); - Some(&self.out) - } - fn label(&self) -> String { "PositionManagement".to_string() } -} -``` - -- [ ] **Step 4: Run to GREEN + add the window-end test** - -Run: `cargo test -p aura-std position_management` Expected: PASS (all Task-3/4 tests). -Then add and pass the window-end test (open at end → last row carries open=true with the -unrealized R the fold force-closes): - -```rust - #[test] - fn open_at_window_end_is_carried_on_the_last_row() { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100 - let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close - assert!(!row[0].bool()); // not closed - assert!(row[11].bool()); // open - assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10 - assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis - } -``` - -Run: `cargo test -p aura-std position_management` Expected: PASS. - -- [ ] **Step 5: R-invariance is structural — assert it as a doc test note** - -R = `direction*(exit-entry)/latched_dist` reads no size, so size scaling cannot change -it (the size column is a constant 1.0 placeholder this iteration). Add a comment in the -`eval` above the `realized =` lines: `// size-invariant: R is a pure stop-distance ratio (Stage-1 feed-forward).` -(The runtime R-invariance-under-Sizer test lands in iteration 2 when the Sizer exists.) - ---- - -## Task 5: `summarize_r` + `RMetrics` (post-run fold) - -**Files:** Modify `crates/aura-engine/src/report.rs`. - -- [ ] **Step 1: Add `RMetrics` + `summarize_r` + column-index consts** - -Insert near `RunMetrics` (~line 30): - -```rust -/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense -/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The -/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RMetrics { - pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline) - pub n_trades: u64, - pub win_rate: f64, // fraction with R > 0 - pub avg_win_r: f64, - pub avg_loss_r: f64, - pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades - pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0 - pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden) -} - -// Dense `PositionManagement` record column indices — the lockstep contract with -// aura-std's FIELD_NAMES/RECORD_KINDS (aura-std is only a dev-dependency here, so the -// layout is shared by convention; `stage1_r_e2e.rs` guards that it matches). -mod r_col { - pub const CLOSED: usize = 0; - pub const REALIZED_R: usize = 1; - pub const OPEN: usize = 11; - pub const UNREALIZED_R: usize = 12; -} - -/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1). -/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the -/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently -/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`. -pub fn summarize_r(record: &[(Timestamp, Vec)]) -> RMetrics { - let mut rs: Vec = Vec::new(); - let mut n_open_at_end = 0u64; - for (_, row) in record { - if row.get(r_col::CLOSED).map(|s| s.as_bool()).unwrap_or(false) { - rs.push(row[r_col::REALIZED_R].as_f64()); - } - } - if let Some((_, last)) = record.last() { - if last.get(r_col::OPEN).map(|s| s.as_bool()).unwrap_or(false) { - rs.push(last[r_col::UNREALIZED_R].as_f64()); - n_open_at_end = 1; - } - } - let n = rs.len() as u64; - if n == 0 { - return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 }; - } - let sum: f64 = rs.iter().sum(); - let wins: Vec = rs.iter().copied().filter(|&r| r > 0.0).collect(); - let losses: Vec = rs.iter().copied().filter(|&r| r <= 0.0).collect(); - let sum_win: f64 = wins.iter().sum(); - let sum_loss: f64 = losses.iter().sum(); // <= 0 - let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; - // by-trade cumulative-R drawdown - let mut peak = f64::NEG_INFINITY; - let mut cum = 0.0; - let mut max_dd = 0.0_f64; - for &r in &rs { - cum += r; - if cum > peak { peak = cum; } - let dd = peak - cum; - if dd > max_dd { max_dd = dd; } - } - RMetrics { - expectancy_r: sum / n as f64, - n_trades: n, - win_rate: wins.len() as f64 / n as f64, - avg_win_r: avg(&wins), - avg_loss_r: avg(&losses), - profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, - max_r_drawdown: max_dd, - n_open_at_end, - } -} -``` - -- [ ] **Step 2: Add RED arithmetic tests** - -In `report.rs` `#[cfg(test)] mod tests` (alongside the `summarize_*` tests), add a -hand-built dense ledger helper + tests: - -```rust - // Build a minimal dense record row: only the columns summarize_r reads matter. - fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec) { - let mut v = vec![Scalar::f64(0.0); 14]; - v[0] = Scalar::bool(closed); - v[1] = Scalar::f64(realized); - v[11] = Scalar::bool(open); - v[12] = Scalar::f64(unreal); - (Timestamp(0), v) - } - #[test] - fn summarize_r_is_zero_on_empty() { - let m = summarize_r(&[]); - assert_eq!(m.n_trades, 0); - assert_eq!(m.expectancy_r, 0.0); - assert_eq!(m.max_r_drawdown, 0.0); - } - #[test] - fn summarize_r_expectancy_winrate_profit_factor() { - // three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3. - let rec = vec![ - r_row(true, 2.0, true, 0.0), - r_row(false, 0.0, true, 0.0), - r_row(true, -1.0, true, 0.0), - r_row(true, 1.0, false, 0.0), // last row not open - ]; - let m = summarize_r(&rec); - assert_eq!(m.n_trades, 3); - assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9); - assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9); - assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1 - assert_eq!(m.n_open_at_end, 0); - } - #[test] - fn summarize_r_force_closes_open_position_at_window_end() { - // one closed +1, then last row open with unrealized -0.5 -> a window-end trade. - let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)]; - let m = summarize_r(&rec); - assert_eq!(m.n_trades, 2); - assert_eq!(m.n_open_at_end, 1); - assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2 - } - #[test] - fn summarize_r_max_drawdown_on_by_trade_curve() { - // cum: +3, +1 (dd 2), +4 -> max dd = 2. - let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)]; - assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9); - } -``` - -Run: `cargo test -p aura-engine summarize_r` Expected: PASS. - ---- - -## Task 6: E2E synthetic chain + the layout guard test - -**Files:** Create `crates/aura-engine/tests/stage1_r_e2e.rs` (dev-context — imports `aura-std`). - -- [ ] **Step 1: The layout guard (lockstep across the crate boundary)** - -```rust -//! Stage-1 R E2E: a synthetic bias+price chain through stop-rule + position-management, -//! recorded and folded by summarize_r. Also guards the dense-record layout contract -//! (aura-std's FIELD_NAMES/RECORD_KINDS vs the column indices summarize_r reads). -use aura_std::position_management::{FIELD_NAMES, RECORD_KINDS, WIDTH}; -use aura_core::ScalarKind; - -#[test] -fn dense_record_layout_matches_summarize_r_contract() { - assert_eq!(WIDTH, 14); - assert_eq!(FIELD_NAMES.len(), WIDTH); - assert_eq!(RECORD_KINDS.len(), WIDTH); - // The columns summarize_r reads, by the convention in report.rs::r_col. - assert_eq!(FIELD_NAMES[0], "closed_this_cycle"); assert_eq!(RECORD_KINDS[0], ScalarKind::Bool); - assert_eq!(FIELD_NAMES[1], "realized_r"); assert_eq!(RECORD_KINDS[1], ScalarKind::F64); - assert_eq!(FIELD_NAMES[11], "open"); assert_eq!(RECORD_KINDS[11], ScalarKind::Bool); - assert_eq!(FIELD_NAMES[12], "unrealized_r"); assert_eq!(RECORD_KINDS[12], ScalarKind::F64); -} -``` - -(If `FIELD_NAMES`/`RECORD_KINDS`/`WIDTH` are not `pub` at the path used, expose them — -they ARE the public layout contract. Adjust the `use` path to match `lib.rs` re-exports.) - -- [ ] **Step 2: Drive the nodes directly end-to-end + fold** - -Rather than bootstrap a full graph, drive the chain node-by-node (the nodes' `eval` is -the contract; the E2E proves stop→position-management→summarize_r composes). A rising -then falling synthetic price with a constant long bias must produce ≥1 trade and a -finite `RMetrics`: - -```rust -use aura_std::{FixedStop, PositionManagement}; -use aura_core::{AnyColumn, Ctx, Node, Scalar, Timestamp}; -use aura_engine::report::summarize_r; - -#[test] -fn synthetic_long_then_stop_produces_a_sane_rmetric() { - let mut stop = FixedStop::new(5.0); - let mut pm = PositionManagement::new(); - let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price - let mut pc: Vec = (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); - let mut ledger: Vec<(Timestamp, Vec)> = vec![]; - // price path: up to 110 then down through the 95 stop. - let prices = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0]; - for (i, &p) in prices.iter().enumerate() { - sc[0].push(Scalar::f64(p)).unwrap(); - let dist = stop.eval(Ctx::new(&sc, Timestamp(i as i64))).map(|c| c[0].f64()).unwrap_or(0.0); - pc[0].push(Scalar::f64(1.0)).unwrap(); // constant long bias - pc[1].push(Scalar::f64(p)).unwrap(); - pc[2].push(Scalar::f64(dist)).unwrap(); - if let Some(row) = pm.eval(Ctx::new(&pc, Timestamp(i as i64))) { - ledger.push((Timestamp(i as i64), row.iter().enumerate().map(|(j, c)| Scalar::from_cell(RECORD_KINDS[j], *c)).collect())); - } - } - let m = summarize_r(&ledger); - assert!(m.n_trades >= 1, "expected at least one trade (entry then stop), got {m:?}"); - assert!(m.expectancy_r.is_finite()); - // entered ~100, stopped at the 95 level (price gapped to 94 through it) -> a loss. - assert!(m.expectancy_r < 0.0 || m.n_open_at_end == 1); -} -``` - -- [ ] **Step 3: Run the E2E + full suite + lint** - -Run: `cargo test -p aura-engine --test stage1_r_e2e` Expected: PASS (both tests). -Run: `cargo test --workspace 2>&1 | tail -15` Expected: all green. -Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. - ---- - -## Self-review - -- **Spec coverage:** #126 rename (Task 1, scoped), #119 stop-rule (Task 2), #127 core - position-management + dense R-record (Tasks 3-4), #129 core summarize_r/RMetrics - (Task 5), E2E + layout guard (Task 6). Iter-2 items (Sizer, RiskExecutor, SQN, - conviction, net-of-cost, RunMetrics.r, CLI) explicitly deferred — not in this plan. -- **Placeholder scan:** none (the `size = 1.0` is a pinned flat-1R placeholder, named). -- **Type consistency:** `Bias`, `VolStop`, `FixedStop`, `PositionManagement`, - `ExitReason`, `RMetrics`, `summarize_r`, `FIELD_NAMES`/`RECORD_KINDS`/`WIDTH`, - column indices (`r_col`) match across Tasks 3-6. -- **Step granularity:** each step 2-5 min; the rename (Task 1) is compiler-enumerated. -- **No commit steps:** none (orchestrator commits the iter). -- **Compile-gate ordering:** Task 1's signature/symbol rename threads all callers - inside Task 1 (Steps 4-5 loop to a clean workspace build before the green gate). -- **Verification filters resolve:** `cargo test -p aura-std stop` / - `position_management` / `-p aura-engine summarize_r` / `--test stage1_r_e2e` are - single substrings matching the new test names introduced in the same task; the - rename gate uses the unfiltered `cargo test --workspace` with a green-unchanged - assertion (no filter that could match nothing). diff --git a/docs/plans/0066-stage1-r-primitives-volstop.md b/docs/plans/0066-stage1-r-primitives-volstop.md deleted file mode 100644 index a128c4d..0000000 --- a/docs/plans/0066-stage1-r-primitives-volstop.md +++ /dev/null @@ -1,386 +0,0 @@ -# Stage-1 R — primitives Mul/Sqrt + VolStop as a composition — Implementation Plan - -> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (§(b) CORRECTION, 2026-06-24) -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Correct the VolStop design error — add the genuinely-missing primitives -`Mul` and `Sqrt` to `aura-std`, remove the fused `VolStop` node, and express the -volatility stop as a composition of primitives (rolling EWMA stddev). - -**Architecture:** A node is a primitive only if it is NOT DAG-expressible from other -primitives. The vol stop is pure feed-forward arithmetic, so it is a composition: -`stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`, -`k·σ` via `LinComb([σ],[k])`. `Mul` (two-stream product) and `Sqrt` are the missing -primitives; `Abs` is not needed (Δ² not |Δ|). `FixedStop` stays (a triggered-constant -primitive). The `vol_stop` composite lives in `aura-engine` (composites need -`GraphBuilder`, which `aura-std` lacks). - -**Tech Stack:** Rust, `aura-std` (new `Mul`, `Sqrt` primitives; `VolStop` removed), -`aura-engine` (the `vol_stop` composite test). - ---- - -**Files this plan creates or modifies:** - -- Create: `crates/aura-std/src/mul.rs` — `Mul` (two-input f64 product). -- Create: `crates/aura-std/src/sqrt.rs` — `Sqrt` (one-input f64 square root). -- Modify: `crates/aura-std/src/lib.rs` — add `mod mul; mod sqrt;` + `pub use`; drop `VolStop` from the `stop_rule` re-export. -- Modify: `crates/aura-std/src/stop_rule.rs` — remove the fused `VolStop` struct/impl/tests; keep `FixedStop`. -- Create: `crates/aura-engine/tests/vol_stop_composite.rs` — the `vol_stop(length,k)` composite-builder + a bootstrap test. - ---- - -## Task 1: `Mul` primitive (two-stream f64 product) - -**Files:** -- Create: `crates/aura-std/src/mul.rs` -- Modify: `crates/aura-std/src/lib.rs` - -- [ ] **Step 1: Write `mul.rs` (mirror `sub.rs`, with `*`) + RED tests** - -```rust -//! `Mul` — two-input f64 product (input 0 times input 1). The fundamental -//! multiplication primitive (e.g. squaring a return for a variance estimate: -//! `Mul(delta, delta)`). Emits `None` until both inputs have a value. - -use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; - -/// Two-input f64 product: input 0 times input 1. Emits `None` until both inputs -/// have a value. -pub struct Mul { - out: [Cell; 1], -} - -impl Mul { - pub fn new() -> Self { - Self { out: [Cell::from_f64(0.0)] } - } - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Mul", - NodeSchema { - inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, - ], - output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], - params: vec![], - }, - |_| Box::new(Mul::new()), - ) - } -} - -impl Default for Mul { - fn default() -> Self { Self::new() } -} - -impl Node for Mul { - fn lookbacks(&self) -> Vec { vec![1, 1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let a = ctx.f64_in(0); - let b = ctx.f64_in(1); - if a.is_empty() || b.is_empty() { - return None; - } - self.out[0] = Cell::from_f64(a[0] * b[0]); - Some(&self.out) - } - fn label(&self) -> String { "Mul".to_string() } -} - -#[cfg(test)] -mod tests { - use super::*; - use aura_core::{AnyColumn, Scalar, Timestamp}; - - #[test] - fn mul_is_product_once_both_inputs_present() { - let mut m = Mul::new(); - let mut inputs = vec![ - AnyColumn::with_capacity(ScalarKind::F64, 1), - AnyColumn::with_capacity(ScalarKind::F64, 1), - ]; - inputs[0].push(Scalar::f64(3.0)).unwrap(); - assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), None); // only one leg - inputs[1].push(Scalar::f64(4.0)).unwrap(); - assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(12.0)].as_slice())); - } - #[test] - fn input_slots_are_named_lhs_rhs() { - let names: Vec = Mul::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); - assert_eq!(names, ["lhs", "rhs"]); - } -} -``` - -- [ ] **Step 2: Wire `lib.rs` + run** - -Add `mod mul;` (alphabetical, after `mod longonly;` / before `mod position_management;`) and -`pub use mul::Mul;`. Run: `cargo test -p aura-std mul` Expected: PASS -(`mul_is_product_once_both_inputs_present`, `input_slots_are_named_lhs_rhs`). - ---- - -## Task 2: `Sqrt` primitive (one-input f64 square root) - -**Files:** -- Create: `crates/aura-std/src/sqrt.rs` -- Modify: `crates/aura-std/src/lib.rs` - -- [ ] **Step 1: Write `sqrt.rs` + RED tests** - -```rust -//! `Sqrt` — one-input f64 square root. Turns a variance estimate (price²) back -//! into a standard deviation (price), e.g. the last stage of a rolling-stddev -//! volatility. Negative inputs are clamped to `0.0` before the root (a variance -//! is non-negative; floating rounding can produce a tiny negative). Emits `None` -//! until its input has a value. - -use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; - -/// One-input f64 square root, `sqrt(max(input, 0.0))`. Emits `None` until its -/// input has a value (warm-up filter, C8). -pub struct Sqrt { - out: [Cell; 1], -} - -impl Sqrt { - pub fn new() -> Self { - Self { out: [Cell::from_f64(0.0)] } - } - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Sqrt", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }], - output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], - params: vec![], - }, - |_| Box::new(Sqrt::new()), - ) - } -} - -impl Default for Sqrt { - fn default() -> Self { Self::new() } -} - -impl Node for Sqrt { - fn lookbacks(&self) -> Vec { vec![1] } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let w = ctx.f64_in(0); - if w.is_empty() { - return None; - } - self.out[0] = Cell::from_f64(w[0].max(0.0).sqrt()); - Some(&self.out) - } - fn label(&self) -> String { "Sqrt".to_string() } -} - -#[cfg(test)] -mod tests { - use super::*; - use aura_core::{AnyColumn, Scalar, Timestamp}; - - #[test] - fn sqrt_of_nine_is_three_zero_is_zero() { - let mut s = Sqrt::new(); - let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; - inputs[0].push(Scalar::f64(9.0)).unwrap(); - assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(3.0)].as_slice())); - inputs[0].push(Scalar::f64(0.0)).unwrap(); - assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice())); - } - #[test] - fn sqrt_clamps_negative_to_zero() { - let mut s = Sqrt::new(); - let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; - inputs[0].push(Scalar::f64(-1e-12)).unwrap(); - assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice())); - } - #[test] - fn sqrt_is_none_until_input_present() { - let mut s = Sqrt::new(); - let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; - assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None); - } -} -``` - -- [ ] **Step 2: Wire `lib.rs` + run** - -Add `mod sqrt;` (alphabetical, after `mod sma;` / before `mod stop_rule;`) and -`pub use sqrt::Sqrt;`. Run: `cargo test -p aura-std sqrt` Expected: PASS (3 tests). - ---- - -## Task 3: Remove the fused `VolStop` node (keep `FixedStop`) + migrate its one caller - -`VolStop` IS referenced outside `stop_rule.rs`/`lib.rs`: `crates/aura-engine/tests/stage1_r_e2e.rs` -imports it (line 30) and constructs `VolStop::new(1, 1.0)` in -`r_is_stop_defined_two_stops_fold_to_different_expectancy`. That caller must be migrated -in this task or the workspace build breaks. - -**Files:** -- Modify: `crates/aura-std/src/stop_rule.rs` -- Modify: `crates/aura-std/src/lib.rs` -- Modify: `crates/aura-engine/tests/stage1_r_e2e.rs` - -- [ ] **Step 1: Migrate the `stage1_r_e2e.rs` caller off `VolStop` (FIRST)** - -`run_chain` drives a `&mut dyn Node` directly, so the vol stop (now a bootstrapped -composite, Task 4) cannot slot in there. The test's property — "different stop distances -fold to different R" — holds via two CONSTANT stops (R = (exit−entry)/distance, so a -10×-tighter stop folds to a ~10× deeper R-loss). Replace the `VolStop` arm with a tight -`FixedStop`: -- line 30 import → `use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};` (drop `VolStop`). -- in `r_is_stop_defined_two_stops_fold_to_different_expectancy`, replace the lines from - `let mut vol = VolStop::new(1, 1.0);` through the final `assert!` with: - -```rust - // A TIGHT FixedStop (distance 1.0) vs the wide one (10.0): R = (exit-entry)/distance, - // so the 10x-tighter stop folds to a ~10x deeper R-loss for a comparable drop. - let mut tight = FixedStop::new(1.0); - let tight_m = run_chain(&mut tight, &long_path(&[100.0, 100.0, 96.0])); - assert!(wide.n_trades >= 1 && tight_m.n_trades >= 1); - assert!( - tight_m.expectancy_r < wide.expectancy_r, - "stop choice must change folded R: tight={:?} wide={:?}", - tight_m.expectancy_r, - wide.expectancy_r, - ); -``` -Update the test's doc-comment "tight VolStop" → "tight FixedStop". (The vol stop's -varying-distance behaviour is covered by `vol_stop_composite.rs`, Task 4.) - -- [ ] **Step 2: Delete `VolStop` from `stop_rule.rs`** - -Remove the entire `pub struct VolStop`, its `impl VolStop`, its `impl Node for VolStop`, -and the two inline tests `vol_stop_is_none_until_warm` and -`vol_stop_tracks_k_times_ema_abs_return`. Keep `FixedStop` (struct, impl, builder, -`Node`, and its test `fixed_stop_is_constant_after_first_price`) and the `feed` test -helper if `FixedStop`'s test still uses it (else remove the now-unused helper). Update -the module doc-comment: the volatility stop is now a composition (see -`crates/aura-engine/tests/vol_stop_composite.rs`), `FixedStop` the only stop-rule -primitive. - -- [ ] **Step 3: Drop the `VolStop` re-export** - -In `crates/aura-std/src/lib.rs`: change `pub use stop_rule::{FixedStop, VolStop};` to -`pub use stop_rule::FixedStop;`. - -- [ ] **Step 4: Build the workspace green** - -Run: `cargo build --workspace 2>&1 | grep -E "error" | head` -Expected: empty. Then `cargo test --workspace 2>&1 | tail -8` -Expected: all green (incl. the migrated -`r_is_stop_defined_two_stops_fold_to_different_expectancy`). - ---- - -## Task 4: The `vol_stop` composite (rolling EWMA stddev) - -**Files:** -- Create: `crates/aura-engine/tests/vol_stop_composite.rs` - -- [ ] **Step 1: Write the composite-builder + a bootstrap RED test** - -The `vol_stop` builder wires the primitives; the test bootstraps it over an -alternating ±1 price series (`Δ² ≡ 1` ⇒ EWMA-variance `1` ⇒ `σ = 1` ⇒ -`stop_distance = k·1 = k`). NOTE: verify each node's exact port names against its -`builder()` (`Delay` input "series"/output "value"/param "lag"; `Sub`/`Mul` -"lhs"/"rhs"→"value"; `Ema` input/output + param "length"; `Sqrt` "value"→"value"; -`LinComb::builder(1)` input "term[0]"→"value", weight param "weights[0]") and adjust the -wiring if a name differs. - -```rust -//! The volatility stop as a COMPOSITION of primitives (the post-correction design): -//! stop_distance = k * Sqrt(Ema(Mul(Δ,Δ), length)), Δ = Sub(price, Delay(price,1)). -//! Proves the decomposition wires + computes the rolling EWMA stddev. -use aura_core::{Scalar, ScalarKind, Timestamp}; -use aura_engine::{GraphBuilder, Harness, VecSource}; -use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub, Recorder}; -use std::sync::mpsc::channel; - -/// Build the volatility-stop composite: price -> k * rolling-EWMA-stddev. -fn vol_stop(length: i64, k: f64) -> aura_engine::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))); - let sub = g.add(Sub::builder()); - let sq = g.add(Mul::builder()); - let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); - let sqrt = g.add(Sqrt::builder()); - let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(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: Δ·Δ - 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") -} - -#[test] -fn vol_stop_emits_k_times_rolling_stddev() { - let (tx, rx) = channel(); - let mut g = GraphBuilder::new("vol_stop_harness"); - let price = g.source_role("price", ScalarKind::F64); - let vs = g.add(vol_stop(/*length*/ 3, /*k*/ 2.0)); - let rec = g.add(Recorder::builder(vec![ScalarKind::F64], aura_core::Firing::Any, tx)); - g.feed(price, [vs.input("price")]); - g.connect(vs.output("stop_distance"), rec.input("col[0]")); - let composite = g.build().expect("harness wires"); - let mut h: Harness = composite.bootstrap_with_params(vec![]).expect("bootstraps"); - - // alternating +/-1 moves -> Δ² ≡ 1 -> EWMA-variance 1 -> σ = 1 -> stop = k·1 = 2.0 - let prices = [100.0, 101.0, 100.0, 101.0, 100.0, 101.0, 100.0, 101.0]; - let src: Vec<(Timestamp, Scalar)> = prices.iter().enumerate() - .map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect(); - h.run(vec![VecSource::new(src)]); - - let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - let last = rows.last().expect("vol_stop produced output after warm-up"); - assert!((last.1[0].as_f64() - 2.0).abs() < 1e-9, "expected stop_distance = k·σ = 2.0, got {:?}", last.1[0]); -} -``` - -NOTE: the exact `Harness`/`Composite` bootstrap + `VecSource` + source-role wiring -mirrors `crates/aura-engine/tests/stage1_r_e2e.rs` and the `built_sma_cross` bootstrap -test (`crates/aura-engine/src/builder.rs` ~:234). Adjust the source/bootstrap calls and -imports to the real signatures (e.g. `source_role` vs `input_role`, `bootstrap` vs -`bootstrap_with_params`, `VecSource` construction) — the assertion (`stop_distance → 2.0`) -is the contract. - -- [ ] **Step 2: Run the composite test + full suite + lint** - -Run: `cargo test -p aura-engine --test vol_stop_composite` Expected: PASS -(`vol_stop_emits_k_times_rolling_stddev`). -Run: `cargo test --workspace 2>&1 | tail -10` Expected: all green. -Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. - ---- - -## Self-review - -- **Spec coverage:** the §(b) CORRECTION — add `Mul` (Task 1) + `Sqrt` (Task 2), remove - the fused `VolStop` (Task 3), express the vol stop as a composition (Task 4). `Abs` - deliberately not added (Δ² not |Δ|); `FixedStop` kept. -- **Placeholder scan:** none. -- **Type consistency:** `Mul`, `Sqrt`, `vol_stop`, the node port names referenced in - Task 4 match the builders the implementer verifies. -- **Step granularity:** each step 2-5 min; Tasks 1-2 are patterned mirrors of `sub.rs`. -- **No commit steps:** none. -- **Compile-gate ordering:** `VolStop` IS referenced by `stage1_r_e2e.rs` - (`VolStop::new(1, 1.0)`), so Task 3 threads all three references — `stop_rule.rs`, the - `lib.rs` re-export, AND the E2E caller (migrated to a tight `FixedStop` in Step 1, BEFORE - the deletion) — within Task 3; the workspace build gate at Step 4 is then satisfiable. -- **Verification filters resolve:** `cargo test -p aura-std mul` / `sqrt` / - `stop_rule` / `-p aura-engine --test vol_stop_composite` match the new/kept named - tests; the removal gate uses `cargo build --workspace` + the unfiltered suite. diff --git a/docs/plans/0067-stage1-r-sizer-riskexecutor-metrics.md b/docs/plans/0067-stage1-r-sizer-riskexecutor-metrics.md deleted file mode 100644 index b42f8bb..0000000 --- a/docs/plans/0067-stage1-r-sizer-riskexecutor-metrics.md +++ /dev/null @@ -1,986 +0,0 @@ -# Stage-1 R — Sizer + RiskExecutor + metric enrichment (Iteration 2) — Implementation Plan - -> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (§ Iteration scope → "Iteration 2") -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Ship the Stage-1 sizing seam and the per-symbol risk-based executor, and -enrich the post-run R fold — the `Sizer` node (`size = risk_budget / stop_distance`, -flat-1R), the `RiskExecutor` composite (`stop-rule → Sizer → position-management`, -exposing the dense R-record), `summarize_r`'s `SQN` / conviction-terciles / net-of-cost -metrics, and an optional `RunMetrics.r` block (back-compat for legacy `runs.jsonl`). - -**Architecture:** `PositionManagement` gains a 4th input slot `size` (col 10, from the -Sizer), R stays size-INVARIANT (a RED test pins it). The `Sizer` (aura-std) reads -`bias` + `stop_distance` and emits `size`. The `RiskExecutor` (an aura-engine integration- -test composite, sibling of `vol_stop_composite.rs`) wires `FixedStop → Sizer → -PositionManagement` behind open `bias` + `price` input roles, with price fanned to both -the stop and PM; the **Veto is a documented seam, not a runtime node**. `summarize_r` -reads three more dense-record columns (entry_price 6, stop_price 7, bias_at_entry_abs 9) -to compute net-of-cost and conviction terciles, and gains a `round_trip_cost` param. -`RunMetrics` gains `#[serde(default, skip_serializing_if = "Option::is_none")] r: -Option`. - -**Tech Stack:** `aura-std` nodes (`PositionManagement`, new `Sizer`), `aura-engine` -report fold + `GraphBuilder` composite, serde back-compat. - -**Scope note (sub-split):** the spec's iteration 2 also names the CLI/recording surface -(#129). That becomes a separate **iteration 3** and is **not** planned here. This plan is -the node + metric layer only (#127 / #128 / #129-metrics). - -**Sequencing rationale (compile-gates):** the two signature changes land before the -composite that consumes both, so the composite is authored once against final signatures: -Task 1 (PM 4th `size` input) → Task 2 (Sizer) → Task 3 (`summarize_r` enrichment + -`round_trip_cost` signature) → Task 4 (`RunMetrics.r`) → Task 5 (RiskExecutor composite, -uses Sizer + PM 4-input + the 2-arg `summarize_r`). Every signature change threads ALL its -callers inside its own task (no deferred caller past a build gate). - ---- - -### Task 1: `PositionManagement` 4th `size` input + R-invariance - -**Files:** -- Modify: `crates/aura-std/src/position_management.rs` (builder inputs ~77-81; `lookbacks` ~111-113; `eval` size read + col 10 ~120-226; test helpers ~245-251; new test) -- Modify: `crates/aura-engine/tests/stage1_r_e2e.rs` (the two direct-eval `pc` column setups ~52-63 and ~88-99) - -- [ ] **Step 1: RED — add the size-aware test helper, widen `cols()`, write the invariance test (production unchanged)** - -In `crates/aura-std/src/position_management.rs`, replace the test helpers block: - -```rust - fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { - cols[0].push(Scalar::f64(bias)).unwrap(); - cols[1].push(Scalar::f64(price)).unwrap(); - cols[2].push(Scalar::f64(dist)).unwrap(); - n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec() - } - fn cols() -> Vec { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } -``` - -with (adds `step_sized`, makes `step` delegate with size 1.0, widens `cols()` to 4): - -```rust - fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec { - cols[0].push(Scalar::f64(bias)).unwrap(); - cols[1].push(Scalar::f64(price)).unwrap(); - cols[2].push(Scalar::f64(dist)).unwrap(); - cols[3].push(Scalar::f64(size)).unwrap(); - n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec() - } - // size defaults to 1.0 (the iter-1 placeholder value), so every existing case is - // behaviour-identical under the new 4-input shape. - fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { - step_sized(n, cols, bias, price, dist, 1.0) - } - fn cols() -> Vec { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } -``` - -Then add this test inside the same `mod tests` (after `emits_one_dense_record_per_cycle`): - -```rust - // (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R. - // Scaling size leaves every realized_r identical (the property that keeps Stage 1 - // feed-forward); col 10 reflects the size so we know it actually flowed end-to-end. - #[test] - fn realized_r_is_invariant_under_size() { - let run = |size: f64| { - let mut n = PositionManagement::new(); - let mut c = cols(); - let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100 - step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110 - }; - let small = run(1.0); - let big = run(7.0); - assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant"); - assert_eq!(small[1].f64(), 1.0); // (110-100)/10 - assert_eq!(small[10].f64(), 1.0); // size column reflects the input - assert_eq!(big[10].f64(), 7.0); - } -``` - -- [ ] **Step 2: Run the invariance test — expect FAIL (col 10 is the hardcoded 1.0)** - -Run: `cargo test --workspace position_management` -Expected: FAIL — `realized_r_is_invariant_under_size` panics on `assert_eq!(big[10].f64(), 7.0)` (col 10 is still `Cell::from_f64(1.0)`); all other `position_management` tests PASS. - -- [ ] **Step 3: GREEN — read `size` from input slot 3 into col 10; widen the schema + lookbacks** - -In `crates/aura-std/src/position_management.rs`, in `builder()`, add a 4th input PortSpec after `stop_distance`: - -```rust - let inputs = vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() }, - ]; -``` - -Change `lookbacks` to four entries: - -```rust - fn lookbacks(&self) -> Vec { - vec![1, 1, 1, 1] - } -``` - -In `eval`, add the size read next to the other slot reads (after the `dist` line ~122): - -```rust - let dist = ctx.f64_in(2).get(0).unwrap_or(0.0); - let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired - let now = ctx.now(); -``` - -Change the col-10 cell in the `self.out = [...]` record from the placeholder to the input size: - -```rust - Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant -``` - -- [ ] **Step 4: Thread the two direct-eval callers in the E2E (the production eval now reads slot 3)** - -In `crates/aura-engine/tests/stage1_r_e2e.rs`, in `synthetic_long_then_stop_produces_a_sane_rmetric`, widen `pc` to 4 columns and push a size: - -```rust - let mut pc: Vec = - (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); -``` - -and after the `pc[2].push(...)` line in that test's loop, add: - -```rust - pc[2].push(Scalar::f64(dist)).unwrap(); - pc[3].push(Scalar::f64(1.0)).unwrap(); // flat-1R size; R is size-invariant -``` - -Apply the identical change in `run_chain`: widen its `pc` to `(0..4)` and add `pc[3].push(Scalar::f64(1.0)).unwrap();` after its `pc[2].push(...)` line. - -- [ ] **Step 5: Run PM + E2E + full suite — expect PASS (no panic, R unchanged)** - -Run: `cargo test --workspace position_management` -Expected: PASS (all PM tests incl. `realized_r_is_invariant_under_size`). - -Run: `cargo test --workspace stage1_r` -Expected: PASS (the E2E's `pc` now has slot 3; R-outcomes unchanged). - -Run: `cargo test --workspace` -Expected: PASS, same total count as before plus the one new test. - ---- - -### Task 2: `Sizer` node (flat-1R seam) - -**Files:** -- Create: `crates/aura-std/src/sizer.rs` — `size = risk_budget / stop_distance` -- Modify: `crates/aura-std/src/lib.rs` (`mod sizer;` + `pub use sizer::Sizer;`) - -- [ ] **Step 1: RED — create `sizer.rs` with the node skeleton and its tests, but a wrong `eval`** - -Create `crates/aura-std/src/sizer.rs`: - -```rust -//! `Sizer` — the flat-1R sizing seam (C10 Stage-1). Turns a protective-stop distance -//! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is -//! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the -//! stop — NOT a constant. The `bias` input gates firing (a size is only meaningful when a -//! strategy is taking a position) and is the Stage-2 seam: fixed-fractional sizing swaps -//! `risk_budget` for `risk_fraction · equity` (an added equity input), same node shape. -//! R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates signal -//! quality — it only scales the (Stage-2) currency exposure. -use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, - ScalarKind, -}; - -/// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None` -/// until both inputs are present (warm-up filter, C8). -pub struct Sizer { - risk_budget: f64, - out: [Cell; 1], -} - -impl Sizer { - /// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R). - pub fn new(risk_budget: f64) -> Self { - assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0"); - Self { risk_budget, out: [Cell::from_f64(0.0)] } - } - - /// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`. - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Sizer", - NodeSchema { - inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, - ], - output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }], - }, - |p| Box::new(Sizer::new(p[0].f64())), - ) - } -} - -impl Node for Sizer { - fn lookbacks(&self) -> Vec { - vec![1, 1] - } - - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - let bias = ctx.f64_in(0); - let dist = ctx.f64_in(1); - if bias.is_empty() || dist.is_empty() { - return None; // a size needs a (present) bias and a stop distance - } - let d = dist[0]; - // a zero/degenerate stop distance yields zero size (no division blow-up); a real - // stop rule emits a strictly positive distance, so this guards only warm-up edges. - let size = if d > 0.0 { self.risk_budget / d } else { 0.0 }; - self.out[0] = Cell::from_f64(size); - Some(&self.out) - } - - fn label(&self) -> String { - format!("Sizer({})", self.risk_budget) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use aura_core::{AnyColumn, Scalar, Timestamp}; - - fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option { - let mut c = vec![ - AnyColumn::with_capacity(ScalarKind::F64, 1), - AnyColumn::with_capacity(ScalarKind::F64, 1), - ]; - c[0].push(Scalar::f64(bias)).unwrap(); - c[1].push(Scalar::f64(dist)).unwrap(); - s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64()) - } - - // (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance. - #[test] - fn sizer_is_flat_1r_invariant() { - for budget in [1.0_f64, 2.5] { - let mut s = Sizer::new(budget); - for dist in [0.5_f64, 2.0, 10.0, 37.5] { - let size = eval_once(&mut s, 1.0, dist).expect("both inputs present"); - assert!( - (size * dist - budget).abs() < 1e-9, - "size*dist must equal risk_budget; budget={budget} dist={dist} size={size}" - ); - } - } - } - - #[test] - #[should_panic(expected = "risk_budget must be > 0")] - fn sizer_panics_on_zero_risk_budget() { - let _ = Sizer::new(0.0); - } - - #[test] - #[should_panic(expected = "risk_budget must be > 0")] - fn sizer_panics_on_negative_risk_budget() { - let _ = Sizer::new(-1.0); - } - - #[test] - fn sizer_is_none_until_both_inputs_present() { - let mut s = Sizer::new(1.0); - let mut c = vec![ - AnyColumn::with_capacity(ScalarKind::F64, 1), - AnyColumn::with_capacity(ScalarKind::F64, 1), - ]; - // only bias present -> None - c[0].push(Scalar::f64(1.0)).unwrap(); - assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None); - // both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1 - c[1].push(Scalar::f64(10.0)).unwrap(); - assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice())); - } - - #[test] - fn input_slots_are_named_bias_and_stop_distance() { - let names: Vec<&str> = Sizer::builder().schema().inputs.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, ["bias", "stop_distance"]); - } -} -``` - -To make the test RED first, write the `eval` body initially as the WRONG constant (so -`sizer_is_flat_1r_invariant` and `sizer_is_none_until_both_inputs_present` fail), then fix -it in Step 3. Initial wrong body for `eval` (replace the `let d = ...; let size = ...` -lines): - -```rust - let _d = dist[0]; - let size = self.risk_budget; // WRONG (constant, ignores the stop distance) — RED - self.out[0] = Cell::from_f64(size); - Some(&self.out) -``` - -Add the module wiring in `crates/aura-std/src/lib.rs`: add `mod sizer;` in the `mod` -block (alphabetical, after `mod sim_broker;` / before `mod sma;` — place as `mod sizer;` -near the other `s*` modules) and `pub use sizer::Sizer;` in the `pub use` block (after -`pub use sim_broker::SimBroker;`). - -- [ ] **Step 2: Run the sizer tests — expect FAIL (constant size breaks the flat-1R invariant)** - -Run: `cargo test --workspace sizer` -Expected: FAIL — `sizer_is_flat_1r_invariant` fails (`size*dist != budget` because size is the constant `risk_budget`) and `sizer_is_none_until_both_inputs_present` fails (`0.1` expected, `1.0` got); the two `#[should_panic]` tests PASS. - -- [ ] **Step 3: GREEN — implement `size = risk_budget / stop_distance`** - -In `crates/aura-std/src/sizer.rs`, replace the wrong `eval` body with the correct one: - -```rust - let d = dist[0]; - // a zero/degenerate stop distance yields zero size (no division blow-up); a real - // stop rule emits a strictly positive distance, so this guards only warm-up edges. - let size = if d > 0.0 { self.risk_budget / d } else { 0.0 }; - self.out[0] = Cell::from_f64(size); - Some(&self.out) -``` - -- [ ] **Step 4: Run the sizer tests + full suite — expect PASS** - -Run: `cargo test --workspace sizer` -Expected: PASS (all five sizer tests). - -Run: `cargo test --workspace` -Expected: PASS. - ---- - -### Task 3: `summarize_r` enrichment (SQN, conviction terciles, net-of-cost) + `round_trip_cost` - -**Files:** -- Modify: `crates/aura-engine/src/report.rs` (`RMetrics` struct ~36; `r_col` module ~50; `summarize_r` body ~61-109; the four test call sites + new tests + a richer row helper) -- Modify: `crates/aura-engine/tests/stage1_r_e2e.rs` (two `summarize_r` calls ~74, ~107; the layout-guard test + module consts) - -- [ ] **Step 1: RED — add the new `RMetrics` fields + the new metric tests + a richer row helper (leave `summarize_r` computing them as zero/placeholder)** - -In `crates/aura-engine/src/report.rs`, extend the `RMetrics` struct with three fields -(append after `n_open_at_end`): - -```rust - pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden) - pub sqn: f64, // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0 - pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest - pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile (asc); <3 trades -> [0,0,0] -``` - -Extend the `r_col` module with the three geometry indices the enrichment reads: - -```rust -mod r_col { - pub const CLOSED: usize = 0; - pub const REALIZED_R: usize = 1; - pub const ENTRY_PRICE: usize = 6; - pub const STOP_PRICE: usize = 7; - pub const BIAS_AT_ENTRY_ABS: usize = 9; - pub const OPEN: usize = 11; - pub const UNREALIZED_R: usize = 12; -} -``` - -Change the `summarize_r` signature to add the `round_trip_cost` param and update BOTH -`RMetrics` returns (the empty-path and the full-path) to include the three new fields set -to zero/placeholder for now (keeps it RED — the new tests will fail on the real values): - -- signature: `pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> RMetrics {` -- empty return: append `sqn: 0.0, net_expectancy_r: 0.0, conviction_terciles_r: [0.0; 3],` -- full return: append `sqn: 0.0, net_expectancy_r: 0.0, conviction_terciles_r: [0.0; 3],` -- silence the unused param for the RED step by prefixing: `let _ = round_trip_cost;` at the top of the body. - -Update the FOUR existing `summarize_r` call sites in `report.rs` tests to pass a cost: -- `summarize_r(&[])` → `summarize_r(&[], 0.0)` -- `summarize_r(&rec)` (in `summarize_r_expectancy_winrate_profit_factor`) → `summarize_r(&rec, 0.0)` -- `summarize_r(&rec)` (in `summarize_r_force_closes_open_position_at_window_end`) → `summarize_r(&rec, 0.0)` -- `summarize_r(&rec).max_r_drawdown` (in `summarize_r_max_drawdown_on_by_trade_curve`) → `summarize_r(&rec, 0.0).max_r_drawdown` - -Extend the empty-ledger test to assert the new zero fields: - -```rust - #[test] - fn summarize_r_is_zero_on_empty() { - let m = summarize_r(&[], 0.0); - assert_eq!(m.n_trades, 0); - assert_eq!(m.expectancy_r, 0.0); - assert_eq!(m.max_r_drawdown, 0.0); - assert_eq!(m.sqn, 0.0); - assert_eq!(m.net_expectancy_r, 0.0); - assert_eq!(m.conviction_terciles_r, [0.0; 3]); - } -``` - -Add a richer dense-row helper (next to the existing `r_row`), which also sets the geometry -columns the enrichment reads: - -```rust - // A fuller closed-trade dense row: also sets entry_price (6), stop_price (7) and - // bias_at_entry_abs (9) — the geometry summarize_r recovers latched_dist and - // conviction from. Width up to UNREALIZED_R+1 (summarize_r never reads col 13). - fn r_row_full(realized: f64, entry: f64, stop: f64, bias_abs: f64) -> (Timestamp, Vec) { - let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; - v[r_col::CLOSED] = Scalar::bool(true); - v[r_col::REALIZED_R] = Scalar::f64(realized); - v[r_col::ENTRY_PRICE] = Scalar::f64(entry); - v[r_col::STOP_PRICE] = Scalar::f64(stop); - v[r_col::BIAS_AT_ENTRY_ABS] = Scalar::f64(bias_abs); - v[r_col::OPEN] = Scalar::bool(false); - (Timestamp(0), v) - } -``` - -Add the new metric tests inside `report.rs`'s `mod tests`: - -```rust - #[test] - fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() { - // R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1; - // sqn = sqrt(4)*0.5/1 = 1.0. - let rec = vec![ - r_row(true, 1.0, false, 0.0), - r_row(true, 1.0, false, 0.0), - r_row(true, 1.0, false, 0.0), - r_row(true, -1.0, false, 0.0), - ]; - let m = summarize_r(&rec, 0.0); - assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn); - } - - #[test] - fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() { - // n < 2 -> 0 - assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0); - // identical R -> zero variance -> 0 - let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)]; - assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0); - } - - #[test] - fn summarize_r_conviction_terciles_order_by_bias() { - // Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three - // terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5. - let calibrated = vec![ - r_row_full(-2.0, 100.0, 99.0, 0.1), - r_row_full(-1.0, 100.0, 99.0, 0.2), - r_row_full(0.0, 100.0, 99.0, 0.5), - r_row_full(0.0, 100.0, 99.0, 0.6), - r_row_full(1.0, 100.0, 99.0, 0.9), - r_row_full(2.0, 100.0, 99.0, 1.0), - ]; - let m = summarize_r(&calibrated, 0.0); - assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r); - assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r); - assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated"); - - // Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the - // metric reads |bias|, not trade order. Same R multiset, |bias| reversed. - let anti = vec![ - r_row_full(2.0, 100.0, 99.0, 0.1), - r_row_full(1.0, 100.0, 99.0, 0.2), - r_row_full(0.0, 100.0, 99.0, 0.5), - r_row_full(0.0, 100.0, 99.0, 0.6), - r_row_full(-1.0, 100.0, 99.0, 0.9), - r_row_full(-2.0, 100.0, 99.0, 1.0), - ]; - let a = summarize_r(&anti, 0.0); - assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r); - } - - #[test] - fn summarize_r_conviction_terciles_zero_under_three_trades() { - let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)]; - assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]); - } - - #[test] - fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { - // one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price - // units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9. - let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; - let gross = summarize_r(&rec, 0.0); - let net = summarize_r(&rec, 1.0); - assert!((gross.expectancy_r - 2.0).abs() < 1e-9); - assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross"); - assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy"); - assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r); - } -``` - -- [ ] **Step 2: Run `summarize_r` tests — expect FAIL (new metrics still zero)** - -Run: `cargo test --workspace summarize_r` -Expected: FAIL — `summarize_r_sqn_is_sqrt_n_mean_over_stdev`, `summarize_r_conviction_terciles_order_by_bias`, `summarize_r_net_of_cost_subtracts_round_trip_per_trade` fail (computed fields are the `0.0` placeholders); the existing `summarize_r_*` tests and the two zero-case tests PASS. - -- [ ] **Step 3: GREEN — implement the enriched `summarize_r` body** - -In `crates/aura-engine/src/report.rs`, replace the whole `summarize_r` body (from `let mut rs` through the final `RMetrics { ... }`) with: - -```rust - // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the - // latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the - // record. The ledger is the closed rows; a position still open on the last row is - // force-closed at its unrealized R (a window-end trade). - struct Trade { - r: f64, - bias_abs: f64, - latched: f64, - } - let mut trades: Vec = Vec::new(); - for (_, row) in record { - if row[r_col::CLOSED].as_bool() { - trades.push(Trade { - r: row[r_col::REALIZED_R].as_f64(), - bias_abs: row[r_col::BIAS_AT_ENTRY_ABS].as_f64(), - latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(), - }); - } - } - let mut n_open_at_end = 0u64; - if let Some((_, last)) = record.last() - && last[r_col::OPEN].as_bool() - { - trades.push(Trade { - r: last[r_col::UNREALIZED_R].as_f64(), - bias_abs: last[r_col::BIAS_AT_ENTRY_ABS].as_f64(), - latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(), - }); - n_open_at_end = 1; - } - let n = trades.len() as u64; - if n == 0 { - return RMetrics { - expectancy_r: 0.0, - n_trades: 0, - win_rate: 0.0, - avg_win_r: 0.0, - avg_loss_r: 0.0, - profit_factor: 0.0, - max_r_drawdown: 0.0, - n_open_at_end: 0, - sqn: 0.0, - net_expectancy_r: 0.0, - conviction_terciles_r: [0.0; 3], - }; - } - let rs: Vec = trades.iter().map(|t| t.r).collect(); - let sum: f64 = rs.iter().sum(); - let mean = sum / n as f64; - let wins: Vec = rs.iter().copied().filter(|&r| r > 0.0).collect(); - let losses: Vec = rs.iter().copied().filter(|&r| r <= 0.0).collect(); - let sum_win: f64 = wins.iter().sum(); - let sum_loss: f64 = losses.iter().sum(); // <= 0 - let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; - // by-trade cumulative-R drawdown - let mut peak = f64::NEG_INFINITY; - let mut cum = 0.0; - let mut max_dd = 0.0_f64; - for &r in &rs { - cum += r; - if cum > peak { - peak = cum; - } - let dd = peak - cum; - if dd > max_dd { - max_dd = dd; - } - } - // SQN = √n · mean / sample-stdev. n < 2 or zero variance -> 0.0 (dispersion undefined). - let sqn = if n < 2 { - 0.0 - } else { - let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::() / (n as f64 - 1.0); - let sd = var.sqrt(); - if sd > 0.0 { (n as f64).sqrt() * mean / sd } else { 0.0 } - }; - // net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R - // by dividing by that trade's latched R-distance (a zero distance contributes no cost). - let net_sum: f64 = trades - .iter() - .map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 }) - .sum(); - let net_expectancy_r = net_sum / n as f64; - // conviction terciles: sort by |bias_at_entry| ascending, split into three contiguous - // near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s. - let conviction_terciles_r = if n < 3 { - [0.0; 3] - } else { - let mut by_conv: Vec<&Trade> = trades.iter().collect(); - by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs)); - let nn = by_conv.len(); - let mut out = [0.0; 3]; - for (i, slot) in out.iter_mut().enumerate() { - let lo = i * nn / 3; - let hi = (i + 1) * nn / 3; - let bucket = &by_conv[lo..hi]; - *slot = if bucket.is_empty() { - 0.0 - } else { - bucket.iter().map(|t| t.r).sum::() / bucket.len() as f64 - }; - } - out - }; - RMetrics { - expectancy_r: mean, - n_trades: n, - win_rate: wins.len() as f64 / n as f64, - avg_win_r: avg(&wins), - avg_loss_r: avg(&losses), - profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, - max_r_drawdown: max_dd, - n_open_at_end, - sqn, - net_expectancy_r, - conviction_terciles_r, - } -``` - -Remove the temporary `let _ = round_trip_cost;` line added in Step 1 (the param is now used). - -- [ ] **Step 4: Thread the E2E `summarize_r` calls + extend the layout-guard test** - -In `crates/aura-engine/tests/stage1_r_e2e.rs`, update the two `summarize_r` calls: -- `let m = summarize_r(&ledger);` (in `synthetic_long_then_stop_produces_a_sane_rmetric`) → `let m = summarize_r(&ledger, 0.0);` -- `summarize_r(&ledger)` (the tail of `run_chain`) → `summarize_r(&ledger, 0.0)` - -Add three module-level consts next to the existing ones (after `const UNREALIZED_R: usize = 12;`): - -```rust -const ENTRY_PRICE: usize = 6; -const STOP_PRICE: usize = 7; -const BIAS_AT_ENTRY_ABS: usize = 9; -``` - -Extend `r_col_indices_match_producer_field_layout` to pin the three new read indices -(append before the closing brace of the test): - -```rust - // iter-2 reads: entry_price (6), stop_price (7), bias_at_entry_abs (9) — the geometry - // summarize_r recovers latched_dist (net-of-cost) and conviction (terciles) from. - assert_eq!(PM_FIELD_NAMES[ENTRY_PRICE], "entry_price"); - assert_eq!(PM_FIELD_NAMES[STOP_PRICE], "stop_price"); - assert_eq!(PM_FIELD_NAMES[BIAS_AT_ENTRY_ABS], "bias_at_entry_abs"); - assert_eq!(PM_RECORD_KINDS[ENTRY_PRICE], ScalarKind::F64); - assert_eq!(PM_RECORD_KINDS[STOP_PRICE], ScalarKind::F64); - assert_eq!(PM_RECORD_KINDS[BIAS_AT_ENTRY_ABS], ScalarKind::F64); -``` - -- [ ] **Step 5: Run `summarize_r` + E2E + full suite — expect PASS** - -Run: `cargo test --workspace summarize_r` -Expected: PASS (all summarize_r tests incl. the three new metric tests). - -Run: `cargo test --workspace stage1_r` -Expected: PASS (E2E folds with the 2-arg signature; the guard pins the three new indices). - -Run: `cargo test --workspace` -Expected: PASS. - ---- - -### Task 4: `RunMetrics.r` optional block (legacy `runs.jsonl` back-compat) - -**Files:** -- Modify: `crates/aura-engine/src/report.rs` (`RunMetrics` struct ~17-30; the `summarize` constructor ~238; three test literals ~711, ~738, ~758; new serde tests) - -- [ ] **Step 1: RED — write the serde tests first** - -Add to `crates/aura-engine/src/report.rs`'s `mod tests`: - -```rust - #[test] - fn runmetrics_with_r_block_round_trips() { - let m = RunMetrics { - total_pips: 3.0, - max_drawdown: 1.0, - exposure_sign_flips: 2, - r: Some(RMetrics { - expectancy_r: 0.5, - n_trades: 4, - win_rate: 0.5, - avg_win_r: 1.5, - avg_loss_r: -0.5, - profit_factor: 3.0, - max_r_drawdown: 0.5, - n_open_at_end: 1, - sqn: 1.0, - net_expectancy_r: 0.4, - conviction_terciles_r: [-0.5, 0.5, 1.5], - }), - }; - let json = serde_json::to_string(&m).expect("serialize"); - assert!(json.contains("\"r\":{"), "r block present when Some: {json}"); - let back: RunMetrics = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back, m); - } - - #[test] - fn legacy_runmetrics_without_r_field_deserialises_to_none() { - // a pre-`r` runs.jsonl line: no `r` key at all. - let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#; - let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises"); - assert_eq!(m.r, None); - assert_eq!(m.total_pips, 12.0); - } - - #[test] - fn runmetrics_none_r_is_omitted_from_json() { - let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, exposure_sign_flips: 0, r: None }; - let json = serde_json::to_string(&m).expect("serialize"); - assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}"); - } -``` - -- [ ] **Step 2: Run — expect FAIL to COMPILE (`RunMetrics` has no field `r`)** - -Run: `cargo test --workspace runmetrics` -Expected: FAIL — compile error `struct RunMetrics has no field named r` (the three new tests reference `r`). This is the RED state. - -- [ ] **Step 3: GREEN — add the `r` field + thread the constructors** - -In `crates/aura-engine/src/report.rs`, append the field to `RunMetrics` (after -`exposure_sign_flips`): - -```rust - pub exposure_sign_flips: u64, - /// Optional Stage-1 R metrics block. `None` for a pip-only run (and for legacy - /// `runs.jsonl` written before this field existed — `serde(default)`); omitted from - /// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk - /// shape stays byte-unchanged (C14/C18 back-compat). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r: Option, -``` - -Thread the `summarize` constructor (the pip fold's return ~238): - -```rust - RunMetrics { total_pips, max_drawdown, exposure_sign_flips, r: None } -``` - -Thread the three test literals that build `RunMetrics` directly (in -`to_json_renders_the_canonical_form`, `to_json_equals_serde_disk_shape`, -`runreport_serde_round_trips`) — add `, r: None` to each: - -```rust - metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None }, -``` - -(For `to_json_renders_the_canonical_form` the literal spans multiple lines; add `r: None` -as the last field. The exact-string assertion is unaffected — a `None` `r` is skipped from -the JSON, so the canonical string is byte-identical.) - -Thread the two cross-crate `RunMetrics` literals (both `#[cfg(test)]` helpers in OTHER -crates — a Rust struct literal needs every field, so the new `r` breaks their compile and -the `cargo test --workspace` gate would catch it; they must be threaded in this same task): - -- `crates/aura-registry/src/lib.rs:214` — - `metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips },` - → `metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips, r: None },` -- `crates/aura-engine/src/mc.rs:208` — the multi-line literal; add `r: None,` after the - `exposure_sign_flips: v as u64,` line: - -```rust - metrics: RunMetrics { - total_pips: v, - max_drawdown: v, - exposure_sign_flips: v as u64, - r: None, - }, -``` - -(`crates/aura-registry/src/compat.rs:23` is a struct *field* declaration, not a literal, -and deserialises via `RunMetrics`'s derived `Deserialize` — `serde(default)` covers the -missing `r`, so it needs no change. `aura-ingest/examples/ger40_breakout_compare.rs` only -calls `summarize()` and reads fields — no literal to thread.) - -- [ ] **Step 4: Run the serde tests + the canonical-form test + full suite — expect PASS** - -Run: `cargo test --workspace runmetrics` -Expected: PASS (the three new serde tests). - -Run: `cargo test --workspace to_json` -Expected: PASS (the canonical-form + disk-shape tests — `None` `r` is omitted, JSON unchanged). - -Run: `cargo test --workspace` -Expected: PASS. - ---- - -### Task 5: `RiskExecutor` composite (integration-test fixture) + E2E - -**Files:** -- Create: `crates/aura-engine/tests/risk_executor.rs` — the `risk_executor(stop_distance, risk_budget) -> Composite` builder + an always-long strategy stand-in + two tests - -- [ ] **Step 1: RED — write the composite, the strategy stand-in, the harness driver, and the two tests** - -Create `crates/aura-engine/tests/risk_executor.rs`: - -```rust -//! The `RiskExecutor` composite (Stage-1, #128): a per-symbol risk-based executor over a -//! bias stream — `stop-rule -> Sizer -> position-management`, exposing the dense R-record. -//! Proves the composite bootstraps, runs end-to-end, and folds to the SAME R-outcomes as -//! the hand-wired iter-1 chain, and that R is invariant under the Sizer's `risk_budget` -//! (Stage-1 feed-forward). The Veto is a DOCUMENTED SEAM, not a runtime node (a -//! pass-through identity is exactly what C19/C23 DCE deletes), so it appears nowhere here. -use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, - ScalarKind, Timestamp, -}; -use aura_engine::{summarize_r, Composite, GraphBuilder, VecSource}; -use aura_std::{FixedStop, PositionManagement, Recorder, Sizer, PM_FIELD_NAMES, PM_RECORD_KINDS}; -use std::sync::mpsc::channel; - -/// 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 -/// into separate cycles and mark stale prices — see harness.rs C4 tie-breaking). -struct ConstLongBias { - out: [Cell; 1], -} -impl ConstLongBias { - fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "ConstLongBias", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }], - output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }], - params: vec![], - }, - |_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }), - ) - } -} -impl Node for ConstLongBias { - fn lookbacks(&self) -> Vec { - vec![1] - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { - if ctx.f64_in(0).is_empty() { - return None; - } - self.out[0] = Cell::from_f64(1.0); - Some(&self.out) - } - fn label(&self) -> String { - "ConstLongBias".into() - } -} - -/// Bootstrap a harness: one price source -> ConstLongBias (the strategy) + RiskExecutor; -/// the RiskExecutor's dense R-record into a Recorder. Returns the drained ledger. -fn run_executor(prices: &[f64], stop_distance: f64, risk_budget: f64) -> Vec<(Timestamp, Vec)> { - let (tx, rx) = channel(); - 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 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() { - g.connect(exec.output(field), rec.input(&format!("col[{i}]"))); - } - let mut h = g - .build() - .expect("risk_harness wires") - .bootstrap_with_params(vec![]) - .expect("bootstraps"); - let stream: Vec<(Timestamp, Scalar)> = prices - .iter() - .enumerate() - .map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))) - .collect(); - h.run(vec![Box::new(VecSource::new(stream))]); - rx.try_iter().collect() -} - -/// Property: the composite bootstraps, runs, and folds to the documented hand value. A -/// constant long over a monotonically rising price never stops or flips, so the position -/// is open at window end: entry @100 latched on FixedStop(10), last mark @105 -> window-end -/// R = (105-100)/10 = +0.5, the only trade -> expectancy 0.5 (the bootstrapped-composite -/// twin of the iter-1 hand-wired `open_at_window_end_is_folded_into_expectancy_not_dropped`). -#[test] -fn risk_executor_bootstraps_and_folds_to_expected_rmetric() { - let ledger = run_executor(&[100.0, 102.0, 105.0], 10.0, 1.0); - let m = summarize_r(&ledger, 0.0); - assert_eq!(m.n_open_at_end, 1, "the open position must be counted"); - assert_eq!(m.n_trades, 1); - assert!((m.expectancy_r - 0.5).abs() < 1e-9, "window-end R = (105-100)/10; got {}", m.expectancy_r); -} - -/// Property: R is invariant under the Sizer's `risk_budget` (Stage-1 feed-forward). The -/// same price path at two budgets yields a bit-identical realised-R ledger, while the -/// `size` column scales with the budget — proving size flows through the Sizer into PM yet -/// never touches R. -#[test] -fn risk_executor_r_invariant_under_risk_budget() { - let path = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0]; - let a = run_executor(&path, 5.0, 1.0); - let b = run_executor(&path, 5.0, 8.0); - assert_eq!(a.len(), b.len()); - assert!(!a.is_empty()); - // index realized_r (col 1) and size (col 10) by the producer's dense-record layout. - let realized = |rows: &[(Timestamp, Vec)]| rows.iter().map(|(_, r)| r[1].as_f64()).collect::>(); - let size = |rows: &[(Timestamp, Vec)]| rows.iter().map(|(_, r)| r[10].as_f64()).collect::>(); - assert_eq!(realized(&a), realized(&b), "realized_r must be invariant under risk_budget"); - // size scaled 8x: at least one cycle has a nonzero size that is exactly 8x a's (same - // stop distance per cycle, budget 1 -> 8). - let (sa, sb) = (size(&a), size(&b)); - assert!( - sa.iter().zip(&sb).any(|(x, y)| *x > 0.0 && (*y - 8.0 * *x).abs() < 1e-9), - "risk_budget must scale size 8x: a={sa:?} b={sb:?}" - ); -} -``` - -- [ ] **Step 2: Run — expect PASS (RED is not meaningful here: this is new E2E coverage over already-GREEN producers)** - -> This task adds an integration test over machinery that already exists and is unit-tested -> (Tasks 1–4). There is no separate production change to drive RED-first; the test itself -> is the deliverable. Run it and confirm it passes (a FAIL here means a real wiring/semantic -> bug in the composition, to be diagnosed, not a planned RED). - -Run: `cargo test --workspace risk_executor` -Expected: PASS — both `risk_executor_bootstraps_and_folds_to_expected_rmetric` and `risk_executor_r_invariant_under_risk_budget`. - -- [ ] **Step 3: Full suite + clippy gate** - -Run: `cargo test --workspace` -Expected: PASS (whole workspace). - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean (exit 0, no warnings). diff --git a/docs/plans/0068-stage1-r-cli-surface.md b/docs/plans/0068-stage1-r-cli-surface.md deleted file mode 100644 index d9e5eba..0000000 --- a/docs/plans/0068-stage1-r-cli-surface.md +++ /dev/null @@ -1,784 +0,0 @@ -# Stage-1 R CLI surface (iteration 3) — Implementation Plan - -> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (the -> "## Iteration 3 — the CLI surface (#129)" section). -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** make the Stage-1 R layer operable from the command line — `aura run ---harness stage1-r [--real ] [--trace ]` runs a backtest and prints its -signal-quality metrics (the R block) in the `RunReport` JSON, in one shell call. - -**Architecture:** promote `vol_stop` + `risk_executor` (+ a `StopRule` axis) from -test fixtures to public `aura-engine` composite-builders; add a `stage1_r_blueprint` -that fans one bias into both `SimBroker` (pip) and the `risk_executor` (R); add a -`parse_run_args` tokenizer + `--harness ` selector routing to the built-in -harnesses; fold `summarize_r → RunMetrics.r` in the run path; extend `persist_traces` -with a third `r_equity` tap for `aura chart --tap r_equity`. - -**Tech Stack:** `aura-engine` (GraphBuilder/Composite, report), `aura-std` nodes -(Sma/Sub/Bias/SimBroker/Recorder/LinComb/FixedStop/Sizer/PositionManagement + -vol_stop primitives), `aura-cli` (hand-rolled argv dispatch, trace store). - -**Files this plan creates or modifies:** - -- Modify: `crates/aura-engine/Cargo.toml` — move `aura-std` from `[dev-dependencies]` to `[dependencies]`. -- Create: `crates/aura-engine/src/composites.rs` — `vol_stop`, `StopRule`, `risk_executor` public builders. -- Modify: `crates/aura-engine/src/lib.rs:45-67` — `mod composites;` + `pub use composites::{...}`. -- Modify: `crates/aura-engine/tests/vol_stop_composite.rs` — delete local `fn vol_stop`, import the public one. -- Modify: `crates/aura-engine/tests/risk_executor.rs` — delete local `fn risk_executor`, call `risk_executor(StopRule::Fixed(d), b)`; add a Vol-backed RED test. -- Modify: `crates/aura-cli/src/main.rs` — `stage1_r_blueprint`, `run_stage1_r`, a new additive `persist_traces_r` 3-tap helper (the existing 2-tap `persist_traces` + its 7 pip callers stay byte-unchanged), `parse_run_args`/`RunArgs`/`HarnessKind`/`RunData`/`run_dispatch`, the `run` argv arm, `USAGE`. -- Test: `crates/aura-cli/tests/cli_run.rs` — stage1-r smoke, selector, flag-composition, r_equity round-trip. -- Test: `crates/aura-cli/src/main.rs` (inline `#[cfg(test)]`) — `parse_run_args` grammar unit tests. - -**Architecture decisions pinned (one sentence each, decided by the orchestrator):** - -- **Composites live in `aura-engine`** (not aura-std, not a new crate): they are the - engine's convenience/execution layer over the standard nodes — the sibling tier of - `report::summarize_r`, already in aura-engine — and this yields a fully acyclic - normal-dependency graph (`aura-engine → aura-std → aura-core`) where homing them in - aura-std would force a Cargo dev-dependency cycle. -- **The `r_equity` summing `LinComb` lives in the `stage1_r_blueprint`** (not inside - `risk_executor`): the R-equity series is a charting-specific derivation of the CLI - harness, so the reusable executor keeps exposing exactly the PM record. -- **`risk_executor`'s body is the fixture verbatim except the one stop `g.add` line**, - which becomes a `match StopRule { Fixed => g.add(FixedStop…), Vol => g.add(vol_stop…) }` - — every downstream edge is arm-independent because both stops expose `price → stop_distance`. -- **The synthetic stage1-r harness uses `StopRule::Vol { length: 3, k: 2.0 }`** over a - ~18-tick rise-fall-rise stream so the short vol stop warms up and the SMA-cross bias - flips at least once (≥1 closed trade for the smoke test). - ---- - -## Task 1: Promote `vol_stop` + `risk_executor` + `StopRule` to public `aura-engine` API - -**Files:** -- Modify: `crates/aura-engine/Cargo.toml` -- Create: `crates/aura-engine/src/composites.rs` -- Modify: `crates/aura-engine/src/lib.rs:45-67` -- Modify: `crates/aura-engine/tests/vol_stop_composite.rs` -- Modify: `crates/aura-engine/tests/risk_executor.rs` - -- [ ] **Step 1: Move `aura-std` to a normal dependency** - -In `crates/aura-engine/Cargo.toml`, add to the `[dependencies]` section (after the `serde_json` line): - -```toml -# 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" } -``` - -and DELETE the now-redundant line under `[dev-dependencies]`: - -```toml -aura-std = { path = "../aura-std" } -``` - -(The `[dev-dependencies]` section keeps `chrono` / `chrono-tz`.) - -- [ ] **Step 2: Create `crates/aura-engine/src/composites.rs`** - -```rust -//! 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. -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") -} -``` - -- [ ] **Step 3: Re-export from `crates/aura-engine/src/lib.rs`** - -Add `mod composites;` to the module block (alphabetical, after `mod builder;` at line 46): - -```rust -mod builder; -mod composites; -mod graph_model; -``` - -Add the public re-export after the `builder` re-export (line 58): - -```rust -pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle}; -pub use composites::{risk_executor, vol_stop, StopRule}; -``` - -- [ ] **Step 4: Verify the engine builds with the promoted API** - -Run: `cargo build -p aura-engine` -Expected: builds clean (composites.rs compiles against the new normal `aura-std` dep). - -- [ ] **Step 5: Rewire `crates/aura-engine/tests/vol_stop_composite.rs`** - -Delete the local `fn vol_stop(length: i64, k: f64) -> Composite { … }` (lines 13-31). Replace the imports at lines 6-9 with (pruning the now-unused composite-builder deps; the compiler under `-D warnings` enumerates any leftover): - -```rust -use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; -use aura_engine::{vol_stop, GraphBuilder, VecSource}; -use aura_std::Recorder; -use std::sync::mpsc::channel; -``` - -The test body (`vol_stop_emits_k_times_rolling_stddev`, which calls `g.add(vol_stop(3, 2.0))` at line 40) is unchanged — it now calls the promoted public `vol_stop`. - -- [ ] **Step 6: Rewire `crates/aura-engine/tests/risk_executor.rs` + add a Vol-backed RED test** - -Delete the local `fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite { … }` (lines 26-42). Update the imports at line 11-12 to import the promoted fn + `StopRule` and drop the now-unused composite-builder deps (compiler-pruned under `-D warnings`): - -```rust -use aura_engine::{risk_executor, summarize_r, GraphBuilder, RunMetrics, StopRule, VecSource}; -use aura_std::{PositionManagement, Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS}; -``` - -In `run_executor` (line 82), change the call at line 87 from `g.add(risk_executor(stop_distance, risk_budget))` to: - -```rust - let exec = g.add(risk_executor(StopRule::Fixed(stop_distance), risk_budget)); -``` - -Then ADD this RED test (new coverage: the Vol-backed executor, the match arm that did not exist before the promotion): - -```rust -/// 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); -} -``` - -(`ConstLongBias` stays the local producer defined at lines 48-78; `Scalar`, `ScalarKind`, `Firing`, `Timestamp`, `channel` are already imported by the file.) - -- [ ] **Step 7: Run the engine tests — fixtures green + the new Vol arm** - -Run: `cargo test -p aura-engine risk_executor` -Expected: PASS — `risk_executor_bootstraps_and_folds_to_expected_rmetric`, `risk_executor_r_invariant_under_risk_budget`, `folded_rmetrics_survives_runmetrics_serde_round_trip`, and the new `risk_executor_vol_stop_arm_bootstraps_and_folds` all pass. - -Run: `cargo test -p aura-engine vol_stop` -Expected: PASS — `vol_stop_emits_k_times_rolling_stddev` (now calling the promoted fn). - ---- - -## Task 2: The `stage1_r_blueprint` harness + the `run_stage1_r` R fold (`aura-cli`) - -**Files:** -- Modify: `crates/aura-cli/src/main.rs` (add `stage1_r_blueprint`, `stage1_r_prices`, `run_stage1_r`; extend the `use` of `aura_engine`/`aura_std`) - -- [ ] **Step 1: Write the failing smoke test** (inline `#[cfg(test)] mod tests` in `main.rs`) - -```rust -#[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); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` -Expected: FAIL — `run_stage1_r` / `RunData` do not exist yet (compile error). - -- [ ] **Step 3: Add the harness, the synthetic stream, and the run handler** - -Ensure `main.rs`'s imports bring in the promoted composites + the nodes the harness -needs (extend the existing `use aura_engine::{…}` and `use aura_std::{…}` lines): -`aura_engine::{risk_executor, vol_stop, StopRule, GraphBuilder, Composite, summarize_r}` -(several already imported) and `aura_std::{Sma, Sub, Bias, SimBroker, Recorder, LinComb, -PM_FIELD_NAMES, PM_RECORD_KINDS}` (most already imported — add the missing ones; the -compiler enumerates). - -Add the data selector enum near the other CLI types: - -```rust -/// 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 }, -} -``` - -Add the synthetic stream (a rise-fall-rise path so the SMA-cross bias flips and the -short vol stop warms up — reuses the `macd_prices` shape, kept local so a future -`macd_prices` edit cannot silently change this harness's trades): - -```rust -/// 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() -} -``` - -Add the dual-tap harness blueprint: - -```rust -/// 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: 3, k: 2.0 }, 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") -} -``` - -Add the run handler (synthetic + real in one fn, mirroring `run_sample` / `run_sample_real` -for the source and folding both yardsticks): - -```rust -/// `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 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); - } - let window = probe_window(&server, symbol, *from, *to); - let source: Box = match aura_ingest::M1FieldSource::open( - &server, symbol, *from, *to, aura_ingest::M1Field::Close, - ) { - Some(s) => Box::new(s), - None => no_real_data(symbol), - }; - (vec![source], window, spec.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".to_string(), Scalar::i64(3)), // vol_stop length - ], - 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 } -} -``` - -Also add the three-tap persister `run_stage1_r` calls — a SEPARATE helper, NOT a fourth -arg on the two-tap `persist_traces`: that keeps the seven pip callers (`:561, :613, :1024, -:1101, :1370, :1459, :1695`) byte-unchanged, so the `cli_run.rs` `"taps":["equity","exposure"]` -pin stays green. `reduce_for_tap` already decimates `r_equity` by its MinMax default (an -equity-like curve), so the chart viewer is unchanged. - -```rust -/// 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); - } -} -``` - -(The smoke test in Step 1 does not pass `--trace`, so `persist_traces_r` is compiled-but-not-run -here; the binary-driven r_equity round-trip in Task 3 exercises it once `--harness stage1-r` is routable.) - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` -Expected: PASS — `metrics.r` is `Some` with `n_trades >= 1` and a finite SQN. (If the -synthetic stream yields 0 closed trades, lengthen/steepen `stage1_r_prices` until ≥1 — the -test pins `n_trades >= 1`.) - ---- - -## Task 3: `parse_run_args` tokenizer + `--harness` selector + `run_dispatch` - -**Files:** -- Modify: `crates/aura-cli/src/main.rs` (add `RunArgs`/`HarnessKind`/`parse_run_args`/`run_dispatch`; replace the `run` argv arms; update `USAGE`) -- Test: `crates/aura-cli/tests/cli_run.rs` (selector + flag-composition integration tests) -- Test: `crates/aura-cli/src/main.rs` inline (`parse_run_args` grammar unit tests) - -- [ ] **Step 1: Write the failing tests** - -Inline parser unit tests (in `main.rs` `#[cfg(test)] mod tests`): - -```rust -#[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()); -} - -#[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")); -} - -#[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 -} -``` - -cli_run integration tests (in `crates/aura-cli/tests/cli_run.rs`, driving the binary via -`Command::new(BIN)` exactly like the existing tests): - -```rust -#[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}"); -} - -#[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}"); -} - -#[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)); -} - -#[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"); -} -``` - -> Uses `temp_cwd("stage1-r-trace")` (cli_run.rs:13) — the existing trace tests' pattern, no -> external `tempfile` dep — and `Command` (imported at cli_run.rs:4). The plain-`run` tap-order -> pin `run_trace_index_carries_manifest_and_ordered_tap_list` (cli_run.rs:329) must stay green: -> only the stage1-r harness writes a third tap. If the `html.contains("r_equity")` label differs, -> mirror the assertion shape of the existing `chart_emits_self_contained_html_for_a_persisted_run` -> (cli_run.rs:416). - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cargo test -p aura-cli parse_run_args` -Expected: FAIL — `parse_run_args` / `RunArgs` / `HarnessKind` do not exist (compile error). - -- [ ] **Step 3: Add the grammar types, the parser, and the dispatcher** - -```rust -/// 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 = || { - "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()) - } - }) -} -``` - -- [ ] **Step 4: Replace the `run` argv arms in `main()`** - -Replace the five `run` arms (lines 1710-1722) with a single tail-parser arm: - -```rust - ["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); - } - }, -``` - -(The `["chart", rest @ ..]` arm and all others below stay unchanged.) - -- [ ] **Step 5: Update the `USAGE` string** (line 1701-1702) - -Change the leading `run` clause from -`aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ]` -to -`aura run [--harness ] [--macd] [--real [--from ] [--to ]] [--trace ]`. - -- [ ] **Step 6: Run the parser + selector tests, then the full cli_run suite** - -Run: `cargo test -p aura-cli parse_run_args` -Expected: PASS — the three grammar tests. - -Run: `cargo test -p aura-cli run_harness` -Expected: PASS — `run_harness_stage1_r_prints_an_r_block`, `run_harness_sma_is_pip_only_no_r_block`, `run_unknown_harness_exits_two`. - -Run: `cargo test -p aura-cli --test cli_run` -Expected: PASS — every existing run/--macd/--real/--trace/chart/usage test stays green (the -tokenizer accepts every form the old literal arms accepted, including the strict -trailing-token and `run --real` paths), AND the new `stage1_r_trace_persists_r_equity_and_charts_it` -round-trip passes, AND `run_trace_index_carries_manifest_and_ordered_tap_list` still pins the -plain-`run` tap list to exactly `["equity","exposure"]` (pip runs write no third tap). - ---- - -## Final gates (run after all tasks) - -- [ ] **Whole-workspace build** - -Run: `cargo build --workspace` -Expected: clean. - -- [ ] **Whole-workspace test** - -Run: `cargo test --workspace` -Expected: all green (the prior 500+ tests plus the new engine + cli tests). - -- [ ] **Lint** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean (no unused imports left from the fixture rewires). diff --git a/docs/plans/0069-bias-sign-flips-rename.md b/docs/plans/0069-bias-sign-flips-rename.md deleted file mode 100644 index bd7d505..0000000 --- a/docs/plans/0069-bias-sign-flips-rename.md +++ /dev/null @@ -1,124 +0,0 @@ -# exposure→bias rename tail (cycle 0065, #126) — Implementation Plan - -> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (secondary change (a), -> deferred from iter-0; scope fork decided on #117). -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this -> plan. Steps use `- [ ]` checkboxes. - -**Goal:** complete the `exposure → bias` rename of the strategy-output residue — the -typed `exposure_sign_flips` metric (with serde back-compat), the registry rank vocab, -the `exposure_scale` manifest label, and the `.named("exposure")` Bias instances — -**without** touching SimBroker's deliberate pre-reframe exposure concept or the on-disk -`exposure` tap label (fork on #117). - -**Architecture:** `RunMetrics.exposure_sign_flips` is a serde struct field, so its -rename is one atomic compile-unit across all crates; a `#[serde(alias = -"exposure_sign_flips")]` keeps legacy `runs.jsonl` readable while new output uses -`bias_sign_flips`. The registry rank metric accepts BOTH strings (CLI back-compat). -The manifest param label and Bias instance names are plain string renames. - -**Test split rule (load-bearing — apply everywhere):** a test that pins **new output** -(serialize / byte-pin / a freshly-built `RunMetrics`) updates to `bias_sign_flips` / -`bias_scale`; a test that reads a **legacy** line (deserialize back-compat) KEEPS the -old `exposure_sign_flips` / `exposure_scale` key (it now pins the serde alias). - -**Files this plan modifies** (site map from grep — the compiler enumerates the field-rename sites): -- Modify: `crates/aura-engine/src/report.rs` — `RunMetrics.exposure_sign_flips` field (+ alias), the `summarize` fold, the constructor, tests. -- Modify: `crates/aura-engine/src/mc.rs:40,88,211` — the parallel `exposure_sign_flips` aggregate field. -- Modify: `crates/aura-registry/src/lib.rs` — `Metric::ExposureSignFlips` vocab, `metric_cmp`, the rank-string match (+both), the error message, constructor, tests. -- Modify: `crates/aura-cli/src/main.rs` — the `:1462` JSON output key, the `exposure_scale` manifest builders (`:557,594,1439,1675,1825,2400`), the `.named("exposure")` instances (`:950,1042,1601,1738`), tests (`:2473,2560,2728`). -- Modify: `crates/aura-cli/tests/cli_run.rs:39` — the byte-pin. -- Modify: `crates/aura-engine/src/test_fixtures.rs:65`, `crates/aura-engine/src/blueprint.rs:2566` — `.named("exposure")`. -- Modify: `crates/aura-engine/tests/risk_executor.rs:142`, `crates/aura-ingest/tests/{real_bars,streaming_seam}.rs`, `crates/aura-ingest/examples/ger40_breakout_{compare,real,sweep}.rs` — field/label references. - ---- - -## Task 1: Rename `exposure_sign_flips → bias_sign_flips` (typed field + serde alias + rank vocab) - -**Files:** `report.rs`, `mc.rs`, `aura-registry/src/lib.rs`, `main.rs`, `cli_run.rs`, `risk_executor.rs`, the three ger40 examples — every `exposure_sign_flips` site (the compiler flags them once the field is renamed). - -- [ ] **Step 1: Write the RED back-compat test** (in `crates/aura-engine/src/report.rs` tests) - -```rust -#[test] -fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() { - // a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it. - let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#; - let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises"); - assert_eq!(m.bias_sign_flips, 3); - // new output serialises the NEW key, and the old key is gone from output. - let json = serde_json::to_string(&m).unwrap(); - assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}"); - assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}"); -} -``` - -- [ ] **Step 2: Run it to confirm RED** - -Run: `cargo test -p aura-engine legacy_exposure_sign_flips_key_reads_via_alias` -Expected: FAIL — does not compile (`m.bias_sign_flips` does not exist yet). - -- [ ] **Step 3: Rename the field + add the serde alias** (`crates/aura-engine/src/report.rs`) - -In the `RunMetrics` struct (`:29`): -```rust - #[serde(alias = "exposure_sign_flips")] - pub bias_sign_flips: u64, -``` -Rename the fold local (`:310,317`) `exposure_sign_flips` → `bias_sign_flips` and the constructor (`:322`) `RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }`. Update the in-file tests per the **test split rule**: assertions on a freshly-summarised `m` (`:818,842,849`) and serialize round-trips (`:888,913,933,947,979`) → `bias_sign_flips`; the **legacy-deserialise** test (`:971`) KEEPS `exposure_sign_flips` in its JSON literal (it now pins the alias) and updates only its assertion field to `m.bias_sign_flips`. - -- [ ] **Step 4: Propagate the field rename across the workspace** - -Rename `exposure_sign_flips` → `bias_sign_flips` at every other site the compiler flags: -- `crates/aura-engine/src/mc.rs:40,88,211` — the aggregate field + its `pick`/constructor uses (if the MC aggregate serialises to `families.jsonl`, add the same `#[serde(alias = "exposure_sign_flips")]` on its field). -- `crates/aura-cli/src/main.rs:1462` (`"bias_sign_flips": agg.bias_sign_flips`), `:2560` (field ref); the byte-pin tests `:2473` (`"bias_sign_flips":`), `:2728` (assertion) → new key. -- `crates/aura-cli/tests/cli_run.rs:39` → `line.ends_with("\"bias_sign_flips\":1}}")` (new output). -- `crates/aura-engine/tests/risk_executor.rs:142`, the ger40 examples (`compare:130,154`, `real:89`, `sweep:149`) → field refs. - -- [ ] **Step 5: Registry rank vocab — rename the variant, accept BOTH strings** (`crates/aura-registry/src/lib.rs`) - -```rust - // the Metric enum variant (:103): - BiasSignFlips, - // rank-string match (:120-122) — accept the new name AND the old one (CLI back-compat): - "bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips, - // metric_cmp arm (:130-131): - Metric::BiasSignFlips => a.metrics.bias_sign_flips.cmp(&b.metrics.bias_sign_flips), - // error/listing (:185): "(known: total_pips, max_drawdown, bias_sign_flips)" - // constructor (:214): bias_sign_flips: flips -``` -Per the test split: the legacy-deserialise test (`:275`) KEEPS `exposure_sign_flips` in its JSON literal; the `rank_by` tests (`:296,297`) update the assertion field to `bias_sign_flips` (rank string may use either — keep `"exposure_sign_flips"` at `:296` to exercise the alias, or add a sibling asserting `"bias_sign_flips"` ranks identically). - -- [ ] **Step 6: Build + the RED test now GREEN + suite green** - -Run: `cargo test -p aura-engine legacy_exposure_sign_flips_key_reads_via_alias` -Expected: PASS. - -Run: `cargo test --workspace` -Expected: all green (the renamed field + alias; legacy-read tests pin the alias; new-output tests pin `bias_sign_flips`). - ---- - -## Task 2: Rename the `exposure_scale` manifest label + `.named("exposure")` Bias instances - -**Files:** `main.rs` (manifest builders + Bias instances), `report.rs` (manifest-builder tests), `test_fixtures.rs`, `blueprint.rs`, `real_bars.rs`, `streaming_seam.rs`. - -- [ ] **Step 1: Rename the `.named("exposure")` Bias instances → `.named("bias")`** - -At `crates/aura-cli/src/main.rs:950,1042,1601,1738`, `crates/aura-engine/src/test_fixtures.rs:65`, `crates/aura-engine/src/blueprint.rs:2566`: `Bias::builder().named("exposure")` → `Bias::builder().named("bias")`. (Pure debug symbols — no behaviour change. If any test asserts the node label/name `"exposure"`, update that assertion to `"bias"`; if a graph-render fixture pins the name, update it.) - -- [ ] **Step 2: Rename the `exposure_scale` manifest param label → `bias_scale`** - -In every manifest **builder** (new output): `crates/aura-cli/src/main.rs:557,594,1439,1675,1825,2400`, `crates/aura-engine/src/report.rs:637,879,907,927`, `crates/aura-ingest/tests/real_bars.rs:87`, `crates/aura-ingest/tests/streaming_seam.rs:87` → `("bias_scale".to_string(), Scalar::f64(...))`. Per the test split: the **legacy-deserialise** JSON literal at `report.rs:894` KEEPS `exposure_scale` (it reads an old manifest; manifest params are an untyped `Vec<(String, Scalar)>`, so no serde alias is needed — old data just carries the old label). - -- [ ] **Step 3: Build + suite green + lint** - -Run: `cargo build --workspace` -Expected: clean. - -Run: `cargo test --workspace` -Expected: all green. - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean. diff --git a/docs/specs/0065-stage1-r-signal-quality.md b/docs/specs/0065-stage1-r-signal-quality.md deleted file mode 100644 index 33e0cc0..0000000 --- a/docs/specs/0065-stage1-r-signal-quality.md +++ /dev/null @@ -1,535 +0,0 @@ -# Stage-1 R-based signal quality — Design Spec - -**Date:** 2026-06-23 -**Status:** Draft — awaiting user spec review -**Authors:** orchestrator + Claude -**Cycle:** 0065 — milestone "R-based signal quality (Stage 1)" -**Issues:** #119 (stop-rule / R), #126 (exposure → bias), #127 (Sizer + position-management), #128 (RiskExecutor + Veto seam), #129 (R-evaluator). Reference / decision log: #117. -**Contract:** ledger C10 (reframed, commit `f040b66`). - -## Goal - -Make a strategy's **signal quality measurable in R** — the account- and -instrument-agnostic Van-Tharp yardstick *"how much R out per 1R risked?"* — as a -**feed-forward** (no equity feedback) downstream layer on a strategy's **bias** -stream. This is Stage 1 of C10: the primary research loop, maximally parallel and -deterministic (C1). Currency P&L, compounding, and realistic brokers are Stage 2 -(a later milestone), entered only once `E[R] > 0`. - -Concretely the cycle delivers: the `exposure → bias` rename (the unsized strategy -output); a **stop-rule** node family that emits a protective-stop *distance* -(which **defines 1R**); a **position-management** node that turns bias + stop into -a stream of realised **per-trade R-outcomes**; a flat-1R **Sizer** seam; a -per-symbol **RiskExecutor** composite; and a post-run **`summarize_r`** fold that -reduces the trade ledger into R-metrics (E[R], SQN, win-rate, …) plus a by-trade -R-equity curve. - -This is **new discrete-trade machinery**, not an extension of `SimBroker`. -`SimBroker` integrates a *continuous* `exposure · price-return` into a pip curve — -no discrete trade, no stop, no risk unit; it is the ancestor *in spirit only* (a -downstream node that produces a quality curve) and is retained unchanged as the -pre-reframe pip-quality node. - -## Architecture - -The Stage-1 chain, all ordinary downstream nodes (C8/C9), feed-forward: - -``` -bias (f64 ∈ [-1,+1], unsized) # renamed from exposure; sign=direction, |·|=conviction - │ price (f64) - ▼ │ -stop-rule ──► stop_distance (f64 ≥ 0) # VolStop = k·EMA(|Δprice|), or FixedStop(d) - │ │ - │ ┌───────┴──────────────┐ - ▼ ▼ ▼ -[Sizer] ──► size (f64) position-management ──► dense per-cycle R-record - (iter-2 seam) │ (the trade ledger + R-equity) - ▼ - Recorder (tap) ──drain post-run──► summarize_r ──► RMetrics + R-curve -``` - -- **stop-rule** is a pure per-cycle function of price (and its own volatility - state). It emits a **direction-agnostic stop *distance*** in price units, never - an absolute level — so it is reusable long or short and `R` is a dimensionless - ratio. Two concrete nodes ship: `VolStop` (volatility-scaled, the meaningful - default) and `FixedStop` (constant, the test fixture / structural-axis sibling). -- **position-management** is the stateful heart. It opens on a nonzero bias, - **latches the entry-cycle stop distance once as the immutable R-denominator**, - tracks the stop level, marks the position against the **one-cycle-lagged fill** - (the `SimBroker` no-look-ahead discipline), detects exits (stop / bias-flip / - reversal), and emits a **dense per-cycle R-record** carrying both the running - R-equity and, when a trade closes that cycle, its realised R-outcome. -- **Sizer** (iter-2) is a load-bearing seam: `size = risk_budget / stop_distance` - (flat-1R), reading bias + stop_distance so the interface is genuinely exercised; - Stage 2 swaps `risk_budget` for `risk_fraction · equity`, same shape. The - R-outcome is computed **size-invariantly**, so the Sizer never contaminates R. -- **RiskExecutor** (iter-2) is a per-symbol `Composite` bundling - `stop-rule → [Sizer] → position-management` with `bias` + `price` as input roles - (price fanned to both stop-rule and position-management). The **Veto** is a - *documented seam only* (a named position in this spec, **not** a runtime node) — - a pass-through identity is exactly what C19/C23 DCE is licensed to delete. -- **`summarize_r`** is a **post-run fold** (sibling of `summarize`), **not** an - in-graph node: recorder taps are drained post-run (`rx.try_iter().collect()`; - the playground is web-from-disk), so an in-graph R-curve node would buy nothing, - double-maintain state, and carry a broken sparse time axis. The in-graph - running-equity node is reserved for Stage 2 (compounding through the z⁻¹ - fill-edge register). - -**Why a dense R-record (not a sparse `Some`-on-close / `None` stream).** A single -net position closes ≤1 trade per cycle, so a sparse stream would be C8-legal — but -it cannot express the **R-equity curve / max-drawdown-in-R** (no intra-trade mark) -and cannot reveal a position **still open at window end** (it only carries closes). -A dense record (one row per cycle, always `Some`, exactly like `SimBroker`) carries -a `closed_this_cycle` flag: the **trade ledger** is the subset of rows where it is -set; the **R-equity** is the dense `cum_realized_r + unrealized_r`; the -**window-end** open trade is the last row with `open = true`. ≤1 record per eval -still holds (C8), and the asymmetry with the position-event table is intact (a -reversal is *two* book events at one ts but only *one* close, so the table needs ->1/instant while this record needs only one). - -The dense **multi-field** output follows the `Resample` precedent -(`crates/aura-std/src/resample.rs`, `out: [Cell; 4]`, four `FieldSpec`s — an OHLC -bundle consumed and recorded end-to-end in the GER40 breakout tests), so it is on a -**tested runtime path**. Issue #47's untested-ness concerns only the CLI graph -*re-export rendering* of such a producer inside a composite — orthogonal to the -recording path this cycle uses. - -## Concrete code shapes - -### The user-facing program (the acceptance evidence) - -What a strategy author writes to measure a bias strategy's signal quality in R -(mirrors the existing `ger40_breakout_real` harness shape — author a composite, -bootstrap, run, drain, fold): - -```rust -use aura_std::{Bias, VolStop, PositionManagement, Recorder}; -use aura_engine::{GraphBuilder, report::summarize_r}; - -// A per-symbol risk-based executor over a bias stream (iter-2 ships this as the -// reusable `RiskExecutor` composite; shown here wired by hand for clarity). -let mut g = GraphBuilder::new("breakout_R"); -let bias = g.input_role("bias"); // the strategy's unsized output, f64 ∈ [-1,+1] -let price = g.input_role("price"); // the instrument price - -let stop = g.add(VolStop::builder(/*length*/ 20, /*k*/ 2.0)); // k·EMA(|Δprice|) → stop_distance -let pm = g.add(PositionManagement::builder()); // bias + price + stop_distance → R-record -let rtap = g.add(Recorder::builder(PositionManagement::RECORD_KINDS.into(), Firing::Any, tx)); - -g.feed(price, [stop.input("price"), pm.input("price")]); // price fans out -g.feed(bias, [pm.input("bias")]); -g.connect(stop.output("stop_distance"), pm.input("stop_distance")); -for (i, field) in PositionManagement::FIELD_NAMES.iter().enumerate() { - g.connect(pm.output(field), rtap.input(&format!("col[{i}]"))); // tap each field of the dense R-record -} -let exec = g.build().expect("RiskExecutor wires"); - -// ... bootstrap `exec` with the bias-producing strategy + a price source, run ... -let ledger = drain(&rtap); // Vec<(Timestamp, Vec)> -let r: RMetrics = summarize_r(&ledger); // the post-run fold - -println!("E[R] = {:+.3}", r.expectancy_r); // how much R out per 1R risked -println!("SQN = {:.2}", r.sqn); // dispersion-adjusted quality (sweep objective) -println!("trades = {} (win {:.0}%, {} open@end)", r.n_trades, 100.0*r.win_rate, r.n_open_at_end); -``` - -The criterion's evidence: a trader-author reaches for exactly this — "score my -strategy in R" — and gets a risk-normalised, account-agnostic number that pips -cannot give. It introduces no look-ahead (the lagged-fill discipline + a RED test -enforce C2) and no feedback (flat-1R is feed-forward, C1). - -### Before → after: the load-bearing changes (secondary) - -**(a) `exposure → bias` rename (#126, commit-0, behaviour-preserving).** - -```rust -// before: crates/aura-std/src/exposure.rs -pub struct Exposure { scale: f64, out: [Cell; 1] } // node "Exposure", output field "exposure" -// after: crates/aura-std/src/bias.rs -pub struct Bias { scale: f64, out: [Cell; 1] } // node "Bias", output field "bias" -// computation UNCHANGED: clamp(signal / scale, -1, +1). SEMANTICS change: the output is the -// UNSIZED strategy bias; sizing leaves the strategy (moves downstream to the Sizer). -``` -Scope: the `Bias` node (type, file, node-name string, output field `exposure`→`bias`, -`label()`), `SimBroker`'s slot-0 port name + doc, the `aura-std` re-export, and all -example/test wiring. **Data back-compat:** the persisted `RunMetrics.exposure_sign_flips` -field is renamed to `bias_sign_flips` **with `#[serde(alias = "exposure_sign_flips")]`** so -historical `runs.jsonl` still deserialises (the planner pins the exact sites; ~311 -`exposure` / 82 `Exposure` references across `crates/`). - -**(b) Stop-rule (#119).** - -> **CORRECTION (2026-06-24, #117).** The "fused `VolStop` node (no Abs/Mul -> primitive exists, so fuse)" below was a design error. A node is a PRIMITIVE only -> if it is **not** DAG-expressible from other primitives; a missing primitive is -> **added**, not fused away. The vol stop is pure feed-forward arithmetic → a -> **composition**, not a primitive. Corrected design: -> - add primitives `Mul` (two-stream f64 product) + `Sqrt` to `aura-std`; -> - the vol stop is a **rolling stddev (EWMA volatility)**: `stop_distance = -> k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`, scaled -> `k·σ` via the existing `LinComb` (k a param weight). `Sqrt` is mandatory -> (variance is price², `stop_distance` must be price so R is dimensionless); -> `Abs` is **not** added (Δ² not |Δ|); -> - this `vol_stop(length, k) → Composite` lives in `aura-engine` (composites need -> `GraphBuilder`); the fused `VolStop` node is removed; -> - `FixedStop` is **kept** as a legitimate primitive (a param constant gated on -> its price input — a source-less `Const` has no firing trigger in the push model). -> -> The block below is the superseded pre-correction shape, retained for ancestry. - -```rust -// SUPERSEDED (see CORRECTION above): the fused-node shape, not the shipped design. -pub struct VolStop { length: usize, k: f64, prev_price: Option, ema: f64, comp: f64, count: usize, out: [Cell; 1] } -// eval: d = |price - prev_price|; ema = EMA_length(d) (Kahan, like Sma); out = k * ema. -pub struct FixedStop { distance: f64, out: [Cell; 1] } // KEPT: out = distance, constant (a triggered-constant primitive) -``` - -**(c) `PositionManagement` node (#127) — new, `aura-std`. The dense R-record.** - -```rust -pub struct PositionManagement { - // open-position state (None = flat): - pos: Option, // { dir: i64, entry_price, latched_dist, stop_level, entry_ts, bias_abs } - prev_price: Option, // the fill held INTO this cycle (SimBroker discipline) - cum_realized_r: f64, // running closed-trade R - out: [Cell; PositionManagement::WIDTH], -} -// Inputs (order load-bearing, all f64/Any): 0 bias, 1 price, 2 stop_distance. -// Output: ONE dense record per cycle (C8), columns (RECORD_KINDS): -// 0 closed_this_cycle : Bool -// 1 realized_r : F64 // valid iff closed_this_cycle; else 0.0 -// 2 exit_reason : I64 // ExitReason enum: 0=stop 1=bias-flip 2=reversal-leg 3=window-end -// 3 was_stopped : Bool -// 4 direction : I64 // -1 / +1 of the closed (or open) trade -// 5 entry_ts : Timestamp -// 6 entry_price : F64 -// 7 stop_price : F64 -// 8 exit_price : F64 -// 9 bias_at_entry_abs : F64 // conviction, for the calibration diagnostic -// 10 size : F64 // from the Sizer (iter-2); 1.0 placeholder in iter-1 -// 11 open : Bool // a position is open AFTER this cycle (window-end detection) -// 12 unrealized_r : F64 // mark of the open position (0.0 when flat) → dense R-equity -// 13 cum_realized_r : F64 // running closed R → by-trade R-equity = cum_realized + unrealized -// r_multiple is debug-asserted == direction * (exit - entry) / latched_dist. -``` - -`ExitReason` mirrors `PositionAction`'s i64-tagged-enum pattern (`#[serde(into="i64", -try_from="i64")]`, checked `TryFrom`). - -**(d) `summarize_r` fold + `RMetrics` (#129) — new, `aura-engine/src/report.rs`.** - -```rust -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RMetrics { - pub expectancy_r: f64, // mean realised R over closed trades (equal-weighted; headline) - pub n_trades: u64, - pub win_rate: f64, - pub avg_win_r: f64, - pub avg_loss_r: f64, - pub profit_factor: f64, // sum(win R) / |sum(loss R)| - pub sqn: f64, // √n · mean_R / stdev_R — dispersion-adjusted quality, the sweep objective - pub max_r_drawdown: f64, // max peak-to-trough on the by-trade R-equity (cum_realized_r), ≥ 0 - pub n_open_at_end: u64, // positions force-closed at window-end (counted, not hidden) - pub net_expectancy_r: f64, // E[R] minus one round-trip spread cost (price-unit param) — churn-honest - pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile — conviction calibration -} -// Reduces the dense R-record stream: filters closed_this_cycle rows for the ledger; the last row's -// `open`/`unrealized_r` synthesises a window-end forced close (exit_reason=window-end). Pure (C1). -pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> RMetrics { /* … */ } - -// RunMetrics gains an optional R block, back-compat for old runs.jsonl: -pub struct RunMetrics { /* … existing pip fields … */ #[serde(default)] pub r: Option } -``` - -**(e) `Sizer` seam + `RiskExecutor` composite (#127/#128, iter-2).** - -```rust -pub struct Sizer { risk_budget: f64, out: [Cell; 1] } -// eval: out = risk_budget / stop_distance (risk_budget=1 ⇒ flat-1R; Stage-2: risk_fraction·equity) -// Inputs: bias (direction), stop_distance. Output "size" (f64). -// RiskExecutor: a GraphBuilder Composite, per symbol, input roles bias+price, exposes the R-record. -// Veto: NOT a node — a documented seam (added as a real gating edge the cycle it first vetoes). -``` - -## Components - -| Component | Crate / file | Iter | -|-----------|--------------|------| -| `Bias` (rename of `Exposure`) | `aura-std/src/bias.rs` | 0 | -| `VolStop`, `FixedStop` | `aura-std/src/stop_rule.rs` (or `vol_stop.rs`/`fixed_stop.rs`) | 1 | -| `PositionManagement` + `ExitReason` | `aura-std/src/position_management.rs` | 1 | -| `summarize_r` + `RMetrics` (core: E[R], n_trades, win_rate, max_r_drawdown, n_open_at_end) | `aura-engine/src/report.rs` | 1 | -| `Sizer` | `aura-std/src/sizer.rs` | 2 | -| `RiskExecutor` composite builder | `aura-engine` (or an `aura-std` composite helper) | 2 | -| `summarize_r` enrichment (SQN, conviction terciles, net-of-cost) + `RunMetrics.r` + CLI/recording | `aura-engine`, `aura-cli` | 2 | - -## Data flow (one cycle, position-management) - -1. Read current `price` (slot 1), `bias` (slot 0), `stop_distance` (slot 2). -2. **If a position is open:** mark P&L against `prev_price` (the fill held *into* - this cycle — never the same-cycle price the stop is tested against). Test the - stop: did price reach the latched `stop_level`? (close-only: tested at the - close, a **documented optimistic bias**; with a resampled high/low, tested - against the adverse extreme). If stopped → exit fill = **no better than the - stop level** (gap-through ⇒ `R < -1`, the honest loss tail — never capped at - −1). Else if bias exited (sign → 0 or flipped) → exit at the lagged fill. - Compute `realized_r = dir·(exit − entry)/latched_dist`, set the close columns. -3. **Reversal:** an open long with bias flipping short (or vice-versa) closes the - current leg (one R-outcome, `exit_reason = reversal-leg`) **and** reopens the - opposite side as state (the reopen is not an outcome — ≤1 close/cycle holds). -4. **If flat and bias nonzero:** open — latch `entry_price` (the lagged fill), - `dir = sign(bias)`, `latched_dist = stop_distance` (frozen for the trade — the - R-unit is **never re-latched mid-trade**), `stop_level = entry ∓ latched_dist`, - `bias_at_entry_abs = |bias|`. -5. Emit the dense record (open/unrealized/cum fields always; close fields when - `closed_this_cycle`). Update `prev_price` **after** taking the outcome (C2). - -Post-run: drain the Recorder tap → `summarize_r(ledger, round_trip_cost)` → -`RMetrics`; the last row's `open`/`unrealized_r` is force-closed as a window-end -trade. The whole chain is feed-forward (position state is **intra-node** struct -state like `Latch.held` / `SimBroker.prev_*` — not a graph edge, so it needs no -back-edge support and does not trip the topo-sort cycle rejection). - -## Error handling - -- Builder asserts mirror the existing nodes: `VolStop` `length ≥ 1` & `k > 0`, - `FixedStop` `distance > 0`, `Sizer` `risk_budget > 0` (panic at construction — - these are authoring bugs, surfaced like `Exposure scale must be > 0`). -- Input-order is load-bearing and all-`f64`/`i64` (a swapped wiring is not - kind-caught) — documented per slot, exactly as `SimBroker` documents its - `exposure`/`price` order. -- `summarize_r` over an empty ledger returns a well-defined zero `RMetrics` - (`expectancy_r = 0`, `n_trades = 0`, `sqn = 0`, …), never a NaN/panic; - `profit_factor` with no losses is reported as a sentinel (e.g. `f64::INFINITY` - documented, or `0.0` when no trades). -- A `debug_assert!` ties `realized_r` to `dir·(exit−entry)/latched_dist` - (the record's one redundancy, guarded — mirrors `PositionEvent`'s checked - round-trip). - -## Testing strategy - -RED-first where behaviour is test-specifiable. Mandatory tests: - -1. **No look-ahead (the keystone RED test), parallel to `sim_broker_no_lookahead`:** - a bias-flip-exit trade's realised R equals the **lagged-fill** R; a stopped - trade is exactly −1R **in the no-gap case** independent of the fill price. -2. **Stop tail is not capped:** a gap-through-stop trade realises `R < -1` - (fill no better than the stop), not −1 — the loss-tail-truncation guard. -3. **R-invariance under size:** scaling the Sizer's `size` leaves every - `realized_r` unchanged (the property that keeps Stage 1 feed-forward). -4. **flat-1R invariant:** `Sizer` output satisfies `size · stop_distance ≡ risk_budget`. -5. **C8 / one-close-per-cycle:** a reversal cycle sets `closed_this_cycle` once - (one R-outcome) and reopens as state; the record is one row. -6. **Window-end:** a position open at the last cycle is force-closed - (`exit_reason = window-end`), counted in `n_open_at_end`, and **not** silently - folded as unrealised MtM. -7. **`VolStop`** warm-up (`None` until `length`), Kahan stability, `= k·EMA(|Δ|)`; - **`FixedStop`** constant. -8. **`summarize_r`** arithmetic: E[R], win-rate, profit-factor, **SQN** - (`√n·meanR/stdevR`), `max_r_drawdown` on a hand-built ledger; conviction - terciles separate a conviction-calibrated synthetic strategy from a flat one; - net-of-cost subtracts one round-trip spread; empty-ledger zero case. -9. **Bias rename:** the existing `Exposure` tests carry over green as `Bias` - (behaviour-preserving); old `runs.jsonl` with `exposure_sign_flips` still - deserialises via the serde alias. -10. **E2E:** a synthetic bias + price chain through the RiskExecutor produces a - non-empty trade ledger and a sane `RMetrics` (a known-edge synthetic strategy - yields `E[R] > 0`, a coin-flip yields `E[R] ≈ 0`). - -## Acceptance criteria - -- A strategy author can wire `bias → stop-rule → position-management`, run, and - fold an `RMetrics` reporting **E[R] and SQN** — the worked example above runs. -- R is **stop-defined and size-invariant**: `realized_r = dir·(exit−entry)/latched_dist`, - the latched distance frozen at entry; scaling size does not change any R. -- **No look-ahead, no feedback:** the keystone RED test passes; the chain is - feed-forward (no graph cycle, no equity read). -- **Honest loss tail:** stop-outs are not capped at −1R; gap-through realises `R < -1`. -- **Volatility-defined R, not pips:** the default stop is `VolStop` (per-trade - varying R-denominator), so E[R] is not a mere rescaling of pip-expectancy. -- **No silent MtM:** window-end open trades are explicit, flagged, and counted. -- `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean. -- Old `runs.jsonl` still deserialise (`#[serde(default)]` `r`, `serde(alias)` on the renamed flip field). - -## Iteration scope - -- **Commit 0 (head):** `exposure → bias` rename (#126) — behaviour-preserving, - compiler-driven, its own commit. -- **Iteration 1 (this iteration):** `VolStop` + `FixedStop` (#119) + - `PositionManagement` (the dense R-record, latch + lagged-fill discipline, - size-invariant R) + core `summarize_r`/`RMetrics` (E[R], n_trades, win_rate, - max_r_drawdown, n_open_at_end) + the mandatory RED tests (1–7, 9). A complete, - runnable, testable R-producing vertical slice - (`source → bias → stop-rule → position-management → Recorder`, folded by - `summarize_r`). **R-invariance is the load-bearing property pinned here.** -- **Iteration 2:** the `Sizer` seam + the `RiskExecutor` composite (#128, Veto - documented-not-built) + `summarize_r` enrichment (SQN, conviction terciles, - net-of-cost) + `RunMetrics.r` + tests 8, 10. (The `RiskExecutor`/`vol_stop` - composites land here as **integration-test fixtures**; the CLI/recording surface - originally lumped here is split to Iteration 3.) -- **Iteration 3 — the CLI surface (#129):** promote `vol_stop` + `risk_executor` - to public `aura-engine` composite-builders; add an `aura run --harness ` - selector and a `stage1-r` dual-tap harness; fold `summarize_r → RunMetrics.r` in - the run path; tap the R-equity series for `aura chart`. Full design below. - -## Iteration 3 — the CLI surface (#129) - -Iteration 2 shipped the node + metric layer (the `Sizer`, the -`RiskExecutor`/`vol_stop` composites as **integration-test fixtures**, -`summarize_r`, `RunMetrics.r`). Iteration 3 makes that layer **operable from the -command line** — the research loop's primary verb, ergonomic to drive from a shell -(decision log: #117, user-delegated). Authoring stays in Rust (C9/C17/C22): the CLI -*runs and inspects* a Rust-authored harness — it does **not** wire topology. The -broad meta-level CLI (#109: project-as-crate, composable orchestration) is a -separate future milestone, deliberately out of scope here. - -### The user-facing program (the acceptance evidence) - -What Claude (or a trader) types to score a strategy's signal quality in R: - -```console -$ aura run --harness stage1-r --real GER40 --from 1700000000000 --trace q1 -{"manifest":{ ... ,"broker":"risk-executor"}, - "metrics":{"total_pips":123.4,"max_drawdown": ... ,"exposure_sign_flips":7, - "r":{"expectancy_r":0.42,"sqn":1.85,"n_trades":31,"win_rate":0.55, - "avg_win_r":1.9,"avg_loss_r":-0.8,"profit_factor":1.7, - "max_r_drawdown":3.2,"n_open_at_end":1,"net_expectancy_r":0.42, - "conviction_terciles_r":[0.1,0.4,0.7]}}} - -$ aura chart q1 --tap r_equity > r_curve.html # by-trade R-equity curve, existing renderer -``` - -The R block rides in the existing `RunReport` JSON via `RunMetrics.r` -(`#[serde(skip_serializing_if = "Option::is_none")]`), so it appears exactly when -an R-producing harness ran and is absent otherwise — old `runs.jsonl` and pip-only -runs stay byte-unchanged. JSON (not a pretty text block) is the surface: it is the -most machine-ergonomic for Claude and is consumed unchanged by `runs family` and -`chart`. `--real` / `--trace` compose orthogonally with `--harness`. - -Two example details pinned: the pip key is `exposure_sign_flips` — the secondary -change (a) renaming it to `bias_sign_flips` was **not** carried out (the field is -still `exposure_sign_flips` in `report.rs`; the rename is deferred as a follow-on, -out of iter-3 scope), so the example uses the as-built key. And -`net_expectancy_r == expectancy_r` here because the run path folds with -`round_trip_cost = 0.0` — Stage-1 R is frictionless signal quality (costs are the -Stage-2 realistic-broker concern, C10 A-side); a `--cost ` knob that -threads a nonzero cost into the existing `summarize_r` param is a deliberate -follow-on, not iter-3. - -The `stage1-r` harness wires the **same bias stream into both** a `SimBroker` (the -existing pip curve) **and** the `RiskExecutor` (the new R outcomes), so one report -carries both yardsticks honestly — a direct pip-vs-R comparison of one signal, no -meaningless zeros — and the diff from the shipped pip `sample_harness` is exactly -the added risk branch. - -### Before → after: the load-bearing changes (secondary) - -**(f) `aura run --harness ` selector (`aura-cli/src/main.rs`).** Today `run` -is dispatched by an **exhaustive literal-slice match** (`["run"]`, -`["run","--macd"]`, `["run","--trace",n]`, `["run","--macd","--trace",n]`, -`["run","--real", rest@..]`) — there is no `run` tail parser, so the orthogonal -flag composition the worked example needs (`--harness` + `--real` + `--from` + -`--trace` in one call) is unreachable as-is. iter-3 therefore **replaces the `run` -literal arms with a `parse_run_args` tokenizer** (the same shape as the existing -`parse_real_args` / `parse_chart_args` helpers): it accepts `--harness `, -`--macd`, `--real `, `--from `, `--to `, `--trace ` **in any -order**, each at most once. Semantics: `--harness ` resolves a -**compile-time** `match name { "sma" => …, "macd" => …, "stage1-r" => … }` over -Rust-authored built-ins — a fixed enumeration, **not** a runtime node registry and -**not** a DSL (C9/C17); `--harness sma` is the default (today's bare `run` -behaviour); `--macd` is a back-compat alias for `--harness macd` (mutually -exclusive with `--harness`); `--from`/`--to` are legal only with `--real`. Every -invocation the current literal arms accept must still parse identically (the -existing `cli_run` tests stay green); the tokenizer only *adds* the -`--harness`×`--real`×`--trace` combinations. - -**(g) Promote `vol_stop` + `risk_executor` to shippable composite-builders -(`aura-engine`).** Both exist today only as integration-test fixtures -(`tests/vol_stop_composite.rs`, `tests/risk_executor.rs`); iter-3 lifts them to -public `aura-engine` functions (composites need `GraphBuilder`, which `aura-std` -lacks) so the CLI harness can wire them. Behaviour preserved: each public fn IS the -fixture's body verbatim (`vol_stop` as-is; `risk_executor`'s internal -`stop-rule → Sizer → PositionManagement` shape kept, the embedded stop generalized -from a hardcoded `FixedStop` to the `StopRule` match); the two test fixtures each -**delete their local `fn`** and import + call the `aura_engine::` public fn (the -`ConstLongBias` producer stays local to the test). A new `aura-engine` module -(e.g. `src/composites.rs`, re-exported from `lib.rs`) is their home. - -```rust -// aura-engine — new public API, promoted from the test fixtures: -pub fn vol_stop(length: i64, k: f64) -> Composite; // k·√EMA(Δ²); role: price → stop_distance -pub enum StopRule { Fixed(f64), Vol { length: i64, k: f64 } } // the stop axis (C11 structural) -pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite; -// roles: bias + price; embeds the chosen stop-rule (FixedStop and vol_stop BOTH expose -// price → stop_distance, so the `match` arm is the only difference); price fans to the -// stop-rule + position-management; exposes the dense PM R-record. The fixture's -// `risk_executor(d, b)` becomes `risk_executor(StopRule::Fixed(d), b)` — its tests carry -// over unchanged; the `stage1-r` harness passes `StopRule::Vol { length: 20, k: 2.0 }`, -// the volatility-defined default. -``` - -**(h) The `stage1-r` harness + the R fold in the run path (`aura-cli`).** A -`stage1_r_harness()` builder reuses the SMA-cross→`Bias` signal and fans the **one** -in-graph `bias` output to **three** consumers: `SimBroker` (pip equity), the -`risk_executor` (the dense R-record), and the existing exposure tap. **Pip metrics -are unchanged** — `summarize(equity, exposure)` reads the `SimBroker` equity tap and -the `Bias` exposure tap exactly as today's `sample_harness` does (this is the -already-tested output fan-out in `macd_strategy_blueprint`). **R metrics are the new -path** — the run handler drains the `risk_executor`'s dense R-record tap (a third, -independent tap), folds `summarize_r(&ledger, /*round_trip_cost*/ 0.0)` → `RMetrics`, -and sets `RunMetrics.r = Some(..)`. The fold runs only for an R-producing harness; -`--harness sma`/`macd` leave `RunMetrics.r = None` (the `skip_serializing_if` keeps -their JSON byte-unchanged). - -**(i) The R-equity tap + its persistence.** The harness sums the executor's -`cum_realized_r` + `unrealized_r` outputs through a `LinComb([1,1])` into an -`r_equity` series and taps it. Today `persist_traces(name, manifest, equity, exposure)` -is hardwired to exactly **two** taps; iter-3 extends it to thread the **third** -`r_equity` tap (a signature + drain-plumbing change — the one place this is *not* -free). The decimation needs no new arm: `reduce_for_tap` already defaults every -non-`exposure` tap to MinMax, which suits an equity-like curve (the same reducer -`equity` uses). The chart **viewer** (uPlot/HTML) is genuinely unchanged: `aura -chart --tap r_equity` resolves the named tap through the existing -`filter_to_tap` path. So "no new code" holds for the *renderer*; the persist helper -is the bounded plumbing change. - -### Components (iter-3) - -| Component | Crate / file | -|-----------|--------------| -| `vol_stop`, `risk_executor` + `StopRule` promoted to public composite-builders | `aura-engine` (a new `composites` module, re-exported from `lib.rs`; the two fixtures delete their local `fn` and call these) | -| `parse_run_args` tokenizer (replaces the `run` literal-slice arms) + `--harness ` selector + `stage1_r_harness()` + the R fold in the run path | `aura-cli/src/main.rs` | -| `r_equity` tap wiring + `persist_traces` extended to a third tap | `aura-cli/src/main.rs` (the harness builder + the persist helper) | - -### Testing strategy (iter-3) - -- **CLI smoke:** `aura run --harness stage1-r` (synthetic) emits a `RunReport` - whose `metrics.r` is `Some` with a finite `sqn` / `expectancy_r` and - `n_trades ≥ 1` — a `cli_run`-suite integration test (the existing home for CLI - tests). -- **Selector:** `--harness sma` ≡ today's default output; `--harness macd` ≡ the - `--macd` output (the alias holds); an unknown `--harness x` exits non-zero with a - usage error. -- **Flag composition (the new tokenizer):** every invocation the old literal arms - accepted (`run`, `run --macd`, `run --trace t`, `run --real `) still parses - identically; the new combination `run --harness stage1-r --real --trace t` - (which no literal arm could express) parses and runs; `--macd` together with - `--harness` is rejected, as is `--from` without `--real`. -- **Additive back-compat:** a pip run (`--harness sma`) emits **no** `r` key - (`skip_serializing_if`); the existing legacy-`runs.jsonl` deserialise test stays - green. -- **R-equity round-trip:** `aura run --harness stage1-r --trace t` then - `aura chart t --tap r_equity` produces non-empty HTML over the R-equity series. -- **Promotion non-regression:** the `vol_stop_composite` and `risk_executor` - fixture tests stay green after they are rewired to call the promoted public fns. - -### Acceptance criteria (iter-3) - -- Claude can run a Stage-1 R backtest and read its signal-quality metrics in **one - shell call** (`aura run --harness stage1-r [--real …]`) — the R block in the - `RunReport` JSON. -- The CLI **runs** a Rust-authored harness; it does not author or wire topology - (C9/C17/C22 intact — compile-time selector, no registry, no DSL). -- Pip-only and legacy runs are byte-unchanged on disk (`r` absent when no - R-record); determinism (C1) untouched. -- `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean.