plan: 0065 stage1-r-signal-quality

This commit is contained in:
2026-06-23 17:51:47 +02:00
parent 51c87e5a91
commit c5f5e17297
+867
View File
@@ -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<usize> { 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<usize> { 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<f64>,
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<usize> { 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<Option<f64>> {
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<Open>,
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<Cell> {
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<AnyColumn> { (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<usize> { 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<Scalar>)]) -> RMetrics {
let mut rs: Vec<f64> = 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<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = 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::<f64>() / 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<Scalar>) {
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<AnyColumn> = (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = 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).