From c5f5e17297e6e8a01f760fe5ae4f93c396c79c93 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 23 Jun 2026 17:51:47 +0200 Subject: [PATCH] plan: 0065 stage1-r-signal-quality --- docs/plans/0065-stage1-r-signal-quality.md | 867 +++++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 docs/plans/0065-stage1-r-signal-quality.md diff --git a/docs/plans/0065-stage1-r-signal-quality.md b/docs/plans/0065-stage1-r-signal-quality.md new file mode 100644 index 0000000..a89941a --- /dev/null +++ b/docs/plans/0065-stage1-r-signal-quality.md @@ -0,0 +1,867 @@ +# 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).