plan: 0067 stage1-r iter-2 — Sizer + RiskExecutor + metric enrichment
Iteration-2 plan for cycle 0065 (Stage-1 R signal quality). Sub-splits the spec's iteration 2: this plan is the node + metric layer; the CLI/recording surface (#129) becomes a separate iteration 3. Five tasks, sequenced so each signature change threads all its callers before its build gate, and the two signature changes land before the composite that consumes both: 1. PositionManagement gains a 4th `size` input (col 10, from the Sizer); R stays size-INVARIANT (RED test pins it). Threads the PM unit tests + the stage1_r_e2e direct-eval column setups. 2. Sizer node (aura-std): `size = risk_budget / stop_distance`, flat-1R; the Stage-2 fixed-fractional seam slots in unchanged. 3. summarize_r enrichment: SQN, conviction terciles, net-of-cost; gains a `round_trip_cost` param (reads dense cols 6/7/9). Threads report.rs + the E2E callers and extends the cross-crate layout guard. 4. RunMetrics.r: Option<RMetrics> with serde(default, skip_serializing_if) for legacy runs.jsonl back-compat. Threads every RunMetrics literal (report.rs, aura-registry, aura-engine/mc). 5. RiskExecutor composite (aura-engine integration-test fixture, sibling of vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open bias+price roles, price fanned to both; bias produced in-graph (single source, no stale-price two-cycle merge). Veto is a documented seam, not a node. E2E equivalence + R-invariance under risk_budget. refs #117
This commit is contained in:
@@ -0,0 +1,986 @@
|
||||
# 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<RMetrics>`.
|
||||
|
||||
**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<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() }
|
||||
```
|
||||
|
||||
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<Cell> {
|
||||
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<Cell> {
|
||||
step_sized(n, cols, bias, price, dist, 1.0)
|
||||
}
|
||||
fn cols() -> Vec<AnyColumn> { (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<usize> {
|
||||
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<AnyColumn> =
|
||||
(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<usize> {
|
||||
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<f64> {
|
||||
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<Scalar>)], 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<Scalar>) {
|
||||
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<Trade> = 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<f64> = trades.iter().map(|t| t.r).collect();
|
||||
let sum: f64 = rs.iter().sum();
|
||||
let mean = sum / n as f64;
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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::<f64>() / (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::<f64>() / 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<RMetrics>,
|
||||
```
|
||||
|
||||
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<usize> {
|
||||
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<Scalar>)> {
|
||||
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<Scalar>)]| rows.iter().map(|(_, r)| r[1].as_f64()).collect::<Vec<_>>();
|
||||
let size = |rows: &[(Timestamp, Vec<Scalar>)]| rows.iter().map(|(_, r)| r[10].as_f64()).collect::<Vec<_>>();
|
||||
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).
|
||||
Reference in New Issue
Block a user