chore(stage1-r): finalize cycle 0065 — milestone closed, ephemeral spec/plans removed
The 'R-based signal quality (Stage 1)' milestone is complete and ratified (iter-1/2/3 + the corrective primitives mini, the tiny-pip fix, the metric rename; end-to-end fieldtest green). Closing the cycle: - Milestone container + issues #119/#126/#127/#128/#129 closed on the tracker. - The ephemeral per-cycle spec (0065) and plans (0065-0069) are git-rm'd per the cycle-close convention; the durable rationale lives in the design ledger (the C10 cycle-0065 realization note) plus git history. Follow-ons tracked: #130 (n-normalized SQN), #131 (CLI UX), #132 (harness polish), #133 (R-sweep + rank-by-sqn), #134 (strategy-output param-namespace rename). refs #117
This commit is contained in:
@@ -1,867 +0,0 @@
|
||||
# Stage-1 R-based signal quality — Iteration 1 — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Land the Stage-1 R-machinery — a strategy's signal quality measured in R
|
||||
on a `bias` stream, feed-forward — as runnable, tested `aura-std` nodes plus a
|
||||
post-run `summarize_r` fold.
|
||||
|
||||
**Architecture:** `bias → stop-rule (VolStop/FixedStop, emits stop_distance) →
|
||||
position-management (dense per-cycle R-record) → Recorder → summarize_r → RMetrics`.
|
||||
position-management is the stateful heart (latch the entry stop distance as the
|
||||
frozen R-denominator; mark/exit no-look-ahead like `SimBroker`; stop-outs not capped
|
||||
at −1R). The R-evaluator is the post-run fold `summarize_r`, **not** an in-graph node.
|
||||
|
||||
**Tech Stack:** Rust, `aura-core` (Node/Cell/Scalar), `aura-std` (the new nodes),
|
||||
`aura-engine/src/report.rs` (the fold). `aura-std` is a **dev-dependency** of
|
||||
`aura-engine`, so `summarize_r` (non-test) reads the dense record by **column index +
|
||||
convention** (a guard test asserts the layout matches).
|
||||
|
||||
---
|
||||
|
||||
## Scope decisions (resolving plan-recon's open questions — orchestrator's call)
|
||||
|
||||
The `exposure → bias` rename radius is large (319 `exposure` + 83 `Exposure` lines /
|
||||
37 files). commit-0 is scoped to the **semantic core of #126** and defers the rest as
|
||||
behaviour-preserving cosmetics (recorded on #117):
|
||||
|
||||
- **IN commit-0:** the `Exposure` *type* → `Bias` (struct/impl/`new`/builder-name/
|
||||
closure/`label`/file `exposure.rs`→`bias.rs`/`lib.rs` mod+use), every importer's
|
||||
`Exposure::new`/`Exposure::builder`/`use …Exposure` (compiler-enumerated), and the
|
||||
**output FieldSpec name `"exposure"` → `"bias"`** with its three content-pins
|
||||
(`graph_model.rs` expected JSON, `fixtures/sample-model.json`, the `sma.rs` label
|
||||
pin `"Exposure(0.5)"`→`"Bias(0.5)"`).
|
||||
- **DEFERRED (kept green, NOT this iteration):** `SimBroker`'s slot-0 port name
|
||||
`"exposure"` (renaming it churns all `broker.input("exposure")` wiring + its test;
|
||||
`SimBroker` is the pre-reframe node, retained unchanged); `RunMetrics.exposure_sign_flips`
|
||||
(a pip-side turnover proxy orthogonal to R — renaming touches a `registry` enum
|
||||
variant + 9 files for zero R-value); the node *instance* label `.named("exposure")`
|
||||
and everything derived from it (`exposure.scale` param-path, `"exposure_scale"`
|
||||
manifests, member-dir names, the recorded tap name `"exposure"`); `summarize`'s 2nd
|
||||
param + local `exposure` vars; `LongOnly`'s field; `Latch` prose. These are
|
||||
behaviour-preserving to leave and are a separate cosmetic-modernization follow-up.
|
||||
- **File layout:** `VolStop` + `FixedStop` share one file `stop_rule.rs`.
|
||||
- **`ExitReason`** lives in `aura-std` (`position_management.rs`); `summarize_r`
|
||||
(in `aura-engine`, which only dev-depends on `aura-std`) matches the **raw i64**
|
||||
(`0=stop, 1=bias-flip, 2=reversal-leg, 3=window-end`) by convention, guarded by a test.
|
||||
|
||||
**Timing convention (the keystone — SimBroker mirror, pins spec §4).** A trade opens
|
||||
at the observed close of the cycle its bias first goes nonzero (`entry_price =
|
||||
price_t`); it earns from the **next** cycle (its mark at the entry cycle is 0 — the
|
||||
analogue of `SimBroker` setting `prev_exposure` *after* taking PnL, so a new position
|
||||
never earns the pre-entry move). An exit (stop / bias-flip / window-end) realises
|
||||
against the **current** close `price_t` (a stop fills no better than the stop level).
|
||||
This is no-look-ahead: a position held into cycle t earns t's move and then closes, exactly
|
||||
as `SimBroker`'s held exposure earns t's move before the flip takes effect. No
|
||||
`prev_price` is needed — the position's own `entry_price` is the memory.
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify→rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs` — `Bias` node (was `Exposure`); output field `"exposure"`→`"bias"`; `label` `"Bias(..)"`.
|
||||
- Modify: `crates/aura-std/src/lib.rs:23,39` — `mod bias; pub use bias::Bias;` (+ new `mod stop_rule; mod position_management;` + their `pub use`).
|
||||
- Modify: `crates/aura-engine/src/graph_model.rs:497` — expected-JSON literal `"type":"Exposure"`→`"Bias"`, `["exposure","f64"]`→`["bias","f64"]` (only if this fixture uses the Bias node; verify by build/test).
|
||||
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — same `Exposure`/`exposure`→`Bias`/`bias` (verify usage).
|
||||
- Modify: `crates/aura-std/src/sma.rs:205,212` — cross-node label pin `"Exposure(0.5)"`→`"Bias(0.5)"`, `Exposure::new`/`builder`→`Bias`.
|
||||
- Modify (compiler-enumerated `Exposure`→`Bias` ctor/builder/use): `crates/aura-engine/src/{report.rs,blueprint.rs,builder.rs,harness.rs,graph_model.rs,test_fixtures.rs}`, `crates/aura-cli/src/main.rs`, `crates/aura-ingest/tests/{real_bars.rs,streaming_seam.rs}`, `crates/aura-engine/tests/random_sweep_e2e.rs`.
|
||||
- Create: `crates/aura-std/src/stop_rule.rs` — `VolStop` + `FixedStop` (+ inline tests).
|
||||
- Create: `crates/aura-std/src/position_management.rs` — `PositionManagement` + `ExitReason` + layout consts (+ inline tests, incl. the no-look-ahead keystone).
|
||||
- Modify: `crates/aura-engine/src/report.rs` — add `RMetrics` (near `RunMetrics` ~16) + `summarize_r` (near `summarize` ~122) + inline tests.
|
||||
- Create: `crates/aura-engine/tests/stage1_r_e2e.rs` — the synthetic bias+price→stop→position-management→Recorder→summarize_r E2E + the dense-record layout guard test (dev-context, imports `aura-std`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Exposure` → `Bias` rename (compiler-driven, behaviour-preserving)
|
||||
|
||||
**Files:**
|
||||
- Rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`, `crates/aura-std/src/sma.rs`, the importer files above, the 2 content-pin fixtures.
|
||||
|
||||
- [ ] **Step 1: Rename the file + the type/field inside it**
|
||||
|
||||
`git mv crates/aura-std/src/exposure.rs crates/aura-std/src/bias.rs`, then in `bias.rs`
|
||||
apply (computation UNCHANGED — `clamp(signal/scale,-1,+1)`; only names + the doc semantics change):
|
||||
|
||||
```rust
|
||||
//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional
|
||||
//! bias (C10). The strategy's primary output: one f64 input, one f64 output
|
||||
//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional)
|
||||
//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer).
|
||||
//! `scale` sets which signal magnitude maps to full conviction.
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
|
||||
/// Emits `None` until its input is present (warm-up filter, C8).
|
||||
pub struct Bias {
|
||||
scale: f64,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Bias {
|
||||
/// Build a bias node with saturation magnitude `scale` (must be > 0).
|
||||
pub fn new(scale: f64) -> Self {
|
||||
assert!(scale > 0.0, "Bias scale must be > 0");
|
||||
Self { scale, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Bias",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
|
||||
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(Bias::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Bias {
|
||||
fn lookbacks(&self) -> Vec<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).
|
||||
@@ -1,386 +0,0 @@
|
||||
# Stage-1 R — primitives Mul/Sqrt + VolStop as a composition — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (§(b) CORRECTION, 2026-06-24)
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Correct the VolStop design error — add the genuinely-missing primitives
|
||||
`Mul` and `Sqrt` to `aura-std`, remove the fused `VolStop` node, and express the
|
||||
volatility stop as a composition of primitives (rolling EWMA stddev).
|
||||
|
||||
**Architecture:** A node is a primitive only if it is NOT DAG-expressible from other
|
||||
primitives. The vol stop is pure feed-forward arithmetic, so it is a composition:
|
||||
`stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`,
|
||||
`k·σ` via `LinComb([σ],[k])`. `Mul` (two-stream product) and `Sqrt` are the missing
|
||||
primitives; `Abs` is not needed (Δ² not |Δ|). `FixedStop` stays (a triggered-constant
|
||||
primitive). The `vol_stop` composite lives in `aura-engine` (composites need
|
||||
`GraphBuilder`, which `aura-std` lacks).
|
||||
|
||||
**Tech Stack:** Rust, `aura-std` (new `Mul`, `Sqrt` primitives; `VolStop` removed),
|
||||
`aura-engine` (the `vol_stop` composite test).
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Create: `crates/aura-std/src/mul.rs` — `Mul` (two-input f64 product).
|
||||
- Create: `crates/aura-std/src/sqrt.rs` — `Sqrt` (one-input f64 square root).
|
||||
- Modify: `crates/aura-std/src/lib.rs` — add `mod mul; mod sqrt;` + `pub use`; drop `VolStop` from the `stop_rule` re-export.
|
||||
- Modify: `crates/aura-std/src/stop_rule.rs` — remove the fused `VolStop` struct/impl/tests; keep `FixedStop`.
|
||||
- Create: `crates/aura-engine/tests/vol_stop_composite.rs` — the `vol_stop(length,k)` composite-builder + a bootstrap test.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Mul` primitive (two-stream f64 product)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/mul.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write `mul.rs` (mirror `sub.rs`, with `*`) + RED tests**
|
||||
|
||||
```rust
|
||||
//! `Mul` — two-input f64 product (input 0 times input 1). The fundamental
|
||||
//! multiplication primitive (e.g. squaring a return for a variance estimate:
|
||||
//! `Mul(delta, delta)`). Emits `None` until both inputs have a value.
|
||||
|
||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
|
||||
|
||||
/// Two-input f64 product: input 0 times input 1. Emits `None` until both inputs
|
||||
/// have a value.
|
||||
pub struct Mul {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Mul {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Mul",
|
||||
NodeSchema {
|
||||
inputs: vec![
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() },
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
},
|
||||
|_| Box::new(Mul::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Mul {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl Node for Mul {
|
||||
fn lookbacks(&self) -> Vec<usize> { vec![1, 1] }
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let a = ctx.f64_in(0);
|
||||
let b = ctx.f64_in(1);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(a[0] * b[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { "Mul".to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
#[test]
|
||||
fn mul_is_product_once_both_inputs_present() {
|
||||
let mut m = Mul::new();
|
||||
let mut inputs = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
];
|
||||
inputs[0].push(Scalar::f64(3.0)).unwrap();
|
||||
assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), None); // only one leg
|
||||
inputs[1].push(Scalar::f64(4.0)).unwrap();
|
||||
assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(12.0)].as_slice()));
|
||||
}
|
||||
#[test]
|
||||
fn input_slots_are_named_lhs_rhs() {
|
||||
let names: Vec<String> = Mul::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["lhs", "rhs"]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `lib.rs` + run**
|
||||
|
||||
Add `mod mul;` (alphabetical, after `mod longonly;` / before `mod position_management;`) and
|
||||
`pub use mul::Mul;`. Run: `cargo test -p aura-std mul` Expected: PASS
|
||||
(`mul_is_product_once_both_inputs_present`, `input_slots_are_named_lhs_rhs`).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `Sqrt` primitive (one-input f64 square root)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/sqrt.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write `sqrt.rs` + RED tests**
|
||||
|
||||
```rust
|
||||
//! `Sqrt` — one-input f64 square root. Turns a variance estimate (price²) back
|
||||
//! into a standard deviation (price), e.g. the last stage of a rolling-stddev
|
||||
//! volatility. Negative inputs are clamped to `0.0` before the root (a variance
|
||||
//! is non-negative; floating rounding can produce a tiny negative). Emits `None`
|
||||
//! until its input has a value.
|
||||
|
||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
|
||||
|
||||
/// One-input f64 square root, `sqrt(max(input, 0.0))`. Emits `None` until its
|
||||
/// input has a value (warm-up filter, C8).
|
||||
pub struct Sqrt {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Sqrt {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Sqrt",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
},
|
||||
|_| Box::new(Sqrt::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Sqrt {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl Node for Sqrt {
|
||||
fn lookbacks(&self) -> Vec<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].max(0.0).sqrt());
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { "Sqrt".to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
#[test]
|
||||
fn sqrt_of_nine_is_three_zero_is_zero() {
|
||||
let mut s = Sqrt::new();
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
inputs[0].push(Scalar::f64(9.0)).unwrap();
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(3.0)].as_slice()));
|
||||
inputs[0].push(Scalar::f64(0.0)).unwrap();
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice()));
|
||||
}
|
||||
#[test]
|
||||
fn sqrt_clamps_negative_to_zero() {
|
||||
let mut s = Sqrt::new();
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
inputs[0].push(Scalar::f64(-1e-12)).unwrap();
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice()));
|
||||
}
|
||||
#[test]
|
||||
fn sqrt_is_none_until_input_present() {
|
||||
let mut s = Sqrt::new();
|
||||
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `lib.rs` + run**
|
||||
|
||||
Add `mod sqrt;` (alphabetical, after `mod sma;` / before `mod stop_rule;`) and
|
||||
`pub use sqrt::Sqrt;`. Run: `cargo test -p aura-std sqrt` Expected: PASS (3 tests).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Remove the fused `VolStop` node (keep `FixedStop`) + migrate its one caller
|
||||
|
||||
`VolStop` IS referenced outside `stop_rule.rs`/`lib.rs`: `crates/aura-engine/tests/stage1_r_e2e.rs`
|
||||
imports it (line 30) and constructs `VolStop::new(1, 1.0)` in
|
||||
`r_is_stop_defined_two_stops_fold_to_different_expectancy`. That caller must be migrated
|
||||
in this task or the workspace build breaks.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-std/src/stop_rule.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`
|
||||
- Modify: `crates/aura-engine/tests/stage1_r_e2e.rs`
|
||||
|
||||
- [ ] **Step 1: Migrate the `stage1_r_e2e.rs` caller off `VolStop` (FIRST)**
|
||||
|
||||
`run_chain` drives a `&mut dyn Node` directly, so the vol stop (now a bootstrapped
|
||||
composite, Task 4) cannot slot in there. The test's property — "different stop distances
|
||||
fold to different R" — holds via two CONSTANT stops (R = (exit−entry)/distance, so a
|
||||
10×-tighter stop folds to a ~10× deeper R-loss). Replace the `VolStop` arm with a tight
|
||||
`FixedStop`:
|
||||
- line 30 import → `use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};` (drop `VolStop`).
|
||||
- in `r_is_stop_defined_two_stops_fold_to_different_expectancy`, replace the lines from
|
||||
`let mut vol = VolStop::new(1, 1.0);` through the final `assert!` with:
|
||||
|
||||
```rust
|
||||
// A TIGHT FixedStop (distance 1.0) vs the wide one (10.0): R = (exit-entry)/distance,
|
||||
// so the 10x-tighter stop folds to a ~10x deeper R-loss for a comparable drop.
|
||||
let mut tight = FixedStop::new(1.0);
|
||||
let tight_m = run_chain(&mut tight, &long_path(&[100.0, 100.0, 96.0]));
|
||||
assert!(wide.n_trades >= 1 && tight_m.n_trades >= 1);
|
||||
assert!(
|
||||
tight_m.expectancy_r < wide.expectancy_r,
|
||||
"stop choice must change folded R: tight={:?} wide={:?}",
|
||||
tight_m.expectancy_r,
|
||||
wide.expectancy_r,
|
||||
);
|
||||
```
|
||||
Update the test's doc-comment "tight VolStop" → "tight FixedStop". (The vol stop's
|
||||
varying-distance behaviour is covered by `vol_stop_composite.rs`, Task 4.)
|
||||
|
||||
- [ ] **Step 2: Delete `VolStop` from `stop_rule.rs`**
|
||||
|
||||
Remove the entire `pub struct VolStop`, its `impl VolStop`, its `impl Node for VolStop`,
|
||||
and the two inline tests `vol_stop_is_none_until_warm` and
|
||||
`vol_stop_tracks_k_times_ema_abs_return`. Keep `FixedStop` (struct, impl, builder,
|
||||
`Node`, and its test `fixed_stop_is_constant_after_first_price`) and the `feed` test
|
||||
helper if `FixedStop`'s test still uses it (else remove the now-unused helper). Update
|
||||
the module doc-comment: the volatility stop is now a composition (see
|
||||
`crates/aura-engine/tests/vol_stop_composite.rs`), `FixedStop` the only stop-rule
|
||||
primitive.
|
||||
|
||||
- [ ] **Step 3: Drop the `VolStop` re-export**
|
||||
|
||||
In `crates/aura-std/src/lib.rs`: change `pub use stop_rule::{FixedStop, VolStop};` to
|
||||
`pub use stop_rule::FixedStop;`.
|
||||
|
||||
- [ ] **Step 4: Build the workspace green**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | grep -E "error" | head`
|
||||
Expected: empty. Then `cargo test --workspace 2>&1 | tail -8`
|
||||
Expected: all green (incl. the migrated
|
||||
`r_is_stop_defined_two_stops_fold_to_different_expectancy`).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: The `vol_stop` composite (rolling EWMA stddev)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-engine/tests/vol_stop_composite.rs`
|
||||
|
||||
- [ ] **Step 1: Write the composite-builder + a bootstrap RED test**
|
||||
|
||||
The `vol_stop` builder wires the primitives; the test bootstraps it over an
|
||||
alternating ±1 price series (`Δ² ≡ 1` ⇒ EWMA-variance `1` ⇒ `σ = 1` ⇒
|
||||
`stop_distance = k·1 = k`). NOTE: verify each node's exact port names against its
|
||||
`builder()` (`Delay` input "series"/output "value"/param "lag"; `Sub`/`Mul`
|
||||
"lhs"/"rhs"→"value"; `Ema` input/output + param "length"; `Sqrt` "value"→"value";
|
||||
`LinComb::builder(1)` input "term[0]"→"value", weight param "weights[0]") and adjust the
|
||||
wiring if a name differs.
|
||||
|
||||
```rust
|
||||
//! The volatility stop as a COMPOSITION of primitives (the post-correction design):
|
||||
//! stop_distance = k * Sqrt(Ema(Mul(Δ,Δ), length)), Δ = Sub(price, Delay(price,1)).
|
||||
//! Proves the decomposition wires + computes the rolling EWMA stddev.
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{GraphBuilder, Harness, VecSource};
|
||||
use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub, Recorder};
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
/// Build the volatility-stop composite: price -> k * rolling-EWMA-stddev.
|
||||
fn vol_stop(length: i64, k: f64) -> aura_engine::Composite {
|
||||
let mut g = GraphBuilder::new("vol_stop");
|
||||
let price = g.input_role("price");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||
let sub = g.add(Sub::builder());
|
||||
let sq = g.add(Mul::builder());
|
||||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length)));
|
||||
let sqrt = g.add(Sqrt::builder());
|
||||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k)));
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
g.connect(sub.output("value"), sq.input("lhs"));
|
||||
g.connect(sub.output("value"), sq.input("rhs")); // square: Δ·Δ
|
||||
g.connect(sq.output("value"), ema.input("series"));
|
||||
g.connect(ema.output("value"), sqrt.input("value"));
|
||||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||||
g.expose(scale.output("value"), "stop_distance");
|
||||
g.build().expect("vol_stop composite wires")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vol_stop_emits_k_times_rolling_stddev() {
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_stop_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
let vs = g.add(vol_stop(/*length*/ 3, /*k*/ 2.0));
|
||||
let rec = g.add(Recorder::builder(vec![ScalarKind::F64], aura_core::Firing::Any, tx));
|
||||
g.feed(price, [vs.input("price")]);
|
||||
g.connect(vs.output("stop_distance"), rec.input("col[0]"));
|
||||
let composite = g.build().expect("harness wires");
|
||||
let mut h: Harness = composite.bootstrap_with_params(vec![]).expect("bootstraps");
|
||||
|
||||
// alternating +/-1 moves -> Δ² ≡ 1 -> EWMA-variance 1 -> σ = 1 -> stop = k·1 = 2.0
|
||||
let prices = [100.0, 101.0, 100.0, 101.0, 100.0, 101.0, 100.0, 101.0];
|
||||
let src: Vec<(Timestamp, Scalar)> = prices.iter().enumerate()
|
||||
.map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect();
|
||||
h.run(vec![VecSource::new(src)]);
|
||||
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
let last = rows.last().expect("vol_stop produced output after warm-up");
|
||||
assert!((last.1[0].as_f64() - 2.0).abs() < 1e-9, "expected stop_distance = k·σ = 2.0, got {:?}", last.1[0]);
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: the exact `Harness`/`Composite` bootstrap + `VecSource` + source-role wiring
|
||||
mirrors `crates/aura-engine/tests/stage1_r_e2e.rs` and the `built_sma_cross` bootstrap
|
||||
test (`crates/aura-engine/src/builder.rs` ~:234). Adjust the source/bootstrap calls and
|
||||
imports to the real signatures (e.g. `source_role` vs `input_role`, `bootstrap` vs
|
||||
`bootstrap_with_params`, `VecSource` construction) — the assertion (`stop_distance → 2.0`)
|
||||
is the contract.
|
||||
|
||||
- [ ] **Step 2: Run the composite test + full suite + lint**
|
||||
|
||||
Run: `cargo test -p aura-engine --test vol_stop_composite` Expected: PASS
|
||||
(`vol_stop_emits_k_times_rolling_stddev`).
|
||||
Run: `cargo test --workspace 2>&1 | tail -10` Expected: all green.
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** the §(b) CORRECTION — add `Mul` (Task 1) + `Sqrt` (Task 2), remove
|
||||
the fused `VolStop` (Task 3), express the vol stop as a composition (Task 4). `Abs`
|
||||
deliberately not added (Δ² not |Δ|); `FixedStop` kept.
|
||||
- **Placeholder scan:** none.
|
||||
- **Type consistency:** `Mul`, `Sqrt`, `vol_stop`, the node port names referenced in
|
||||
Task 4 match the builders the implementer verifies.
|
||||
- **Step granularity:** each step 2-5 min; Tasks 1-2 are patterned mirrors of `sub.rs`.
|
||||
- **No commit steps:** none.
|
||||
- **Compile-gate ordering:** `VolStop` IS referenced by `stage1_r_e2e.rs`
|
||||
(`VolStop::new(1, 1.0)`), so Task 3 threads all three references — `stop_rule.rs`, the
|
||||
`lib.rs` re-export, AND the E2E caller (migrated to a tight `FixedStop` in Step 1, BEFORE
|
||||
the deletion) — within Task 3; the workspace build gate at Step 4 is then satisfiable.
|
||||
- **Verification filters resolve:** `cargo test -p aura-std mul` / `sqrt` /
|
||||
`stop_rule` / `-p aura-engine --test vol_stop_composite` match the new/kept named
|
||||
tests; the removal gate uses `cargo build --workspace` + the unfiltered suite.
|
||||
@@ -1,986 +0,0 @@
|
||||
# Stage-1 R — Sizer + RiskExecutor + metric enrichment (Iteration 2) — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (§ Iteration scope → "Iteration 2")
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Ship the Stage-1 sizing seam and the per-symbol risk-based executor, and
|
||||
enrich the post-run R fold — the `Sizer` node (`size = risk_budget / stop_distance`,
|
||||
flat-1R), the `RiskExecutor` composite (`stop-rule → Sizer → position-management`,
|
||||
exposing the dense R-record), `summarize_r`'s `SQN` / conviction-terciles / net-of-cost
|
||||
metrics, and an optional `RunMetrics.r` block (back-compat for legacy `runs.jsonl`).
|
||||
|
||||
**Architecture:** `PositionManagement` gains a 4th input slot `size` (col 10, from the
|
||||
Sizer), R stays size-INVARIANT (a RED test pins it). The `Sizer` (aura-std) reads
|
||||
`bias` + `stop_distance` and emits `size`. The `RiskExecutor` (an aura-engine integration-
|
||||
test composite, sibling of `vol_stop_composite.rs`) wires `FixedStop → Sizer →
|
||||
PositionManagement` behind open `bias` + `price` input roles, with price fanned to both
|
||||
the stop and PM; the **Veto is a documented seam, not a runtime node**. `summarize_r`
|
||||
reads three more dense-record columns (entry_price 6, stop_price 7, bias_at_entry_abs 9)
|
||||
to compute net-of-cost and conviction terciles, and gains a `round_trip_cost` param.
|
||||
`RunMetrics` gains `#[serde(default, skip_serializing_if = "Option::is_none")] r:
|
||||
Option<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).
|
||||
@@ -1,784 +0,0 @@
|
||||
# Stage-1 R CLI surface (iteration 3) — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (the
|
||||
> "## Iteration 3 — the CLI surface (#129)" section).
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** make the Stage-1 R layer operable from the command line — `aura run
|
||||
--harness stage1-r [--real <SYM>] [--trace <n>]` runs a backtest and prints its
|
||||
signal-quality metrics (the R block) in the `RunReport` JSON, in one shell call.
|
||||
|
||||
**Architecture:** promote `vol_stop` + `risk_executor` (+ a `StopRule` axis) from
|
||||
test fixtures to public `aura-engine` composite-builders; add a `stage1_r_blueprint`
|
||||
that fans one bias into both `SimBroker` (pip) and the `risk_executor` (R); add a
|
||||
`parse_run_args` tokenizer + `--harness <name>` selector routing to the built-in
|
||||
harnesses; fold `summarize_r → RunMetrics.r` in the run path; extend `persist_traces`
|
||||
with a third `r_equity` tap for `aura chart --tap r_equity`.
|
||||
|
||||
**Tech Stack:** `aura-engine` (GraphBuilder/Composite, report), `aura-std` nodes
|
||||
(Sma/Sub/Bias/SimBroker/Recorder/LinComb/FixedStop/Sizer/PositionManagement +
|
||||
vol_stop primitives), `aura-cli` (hand-rolled argv dispatch, trace store).
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify: `crates/aura-engine/Cargo.toml` — move `aura-std` from `[dev-dependencies]` to `[dependencies]`.
|
||||
- Create: `crates/aura-engine/src/composites.rs` — `vol_stop`, `StopRule`, `risk_executor` public builders.
|
||||
- Modify: `crates/aura-engine/src/lib.rs:45-67` — `mod composites;` + `pub use composites::{...}`.
|
||||
- Modify: `crates/aura-engine/tests/vol_stop_composite.rs` — delete local `fn vol_stop`, import the public one.
|
||||
- Modify: `crates/aura-engine/tests/risk_executor.rs` — delete local `fn risk_executor`, call `risk_executor(StopRule::Fixed(d), b)`; add a Vol-backed RED test.
|
||||
- Modify: `crates/aura-cli/src/main.rs` — `stage1_r_blueprint`, `run_stage1_r`, a new additive `persist_traces_r` 3-tap helper (the existing 2-tap `persist_traces` + its 7 pip callers stay byte-unchanged), `parse_run_args`/`RunArgs`/`HarnessKind`/`RunData`/`run_dispatch`, the `run` argv arm, `USAGE`.
|
||||
- Test: `crates/aura-cli/tests/cli_run.rs` — stage1-r smoke, selector, flag-composition, r_equity round-trip.
|
||||
- Test: `crates/aura-cli/src/main.rs` (inline `#[cfg(test)]`) — `parse_run_args` grammar unit tests.
|
||||
|
||||
**Architecture decisions pinned (one sentence each, decided by the orchestrator):**
|
||||
|
||||
- **Composites live in `aura-engine`** (not aura-std, not a new crate): they are the
|
||||
engine's convenience/execution layer over the standard nodes — the sibling tier of
|
||||
`report::summarize_r`, already in aura-engine — and this yields a fully acyclic
|
||||
normal-dependency graph (`aura-engine → aura-std → aura-core`) where homing them in
|
||||
aura-std would force a Cargo dev-dependency cycle.
|
||||
- **The `r_equity` summing `LinComb` lives in the `stage1_r_blueprint`** (not inside
|
||||
`risk_executor`): the R-equity series is a charting-specific derivation of the CLI
|
||||
harness, so the reusable executor keeps exposing exactly the PM record.
|
||||
- **`risk_executor`'s body is the fixture verbatim except the one stop `g.add` line**,
|
||||
which becomes a `match StopRule { Fixed => g.add(FixedStop…), Vol => g.add(vol_stop…) }`
|
||||
— every downstream edge is arm-independent because both stops expose `price → stop_distance`.
|
||||
- **The synthetic stage1-r harness uses `StopRule::Vol { length: 3, k: 2.0 }`** over a
|
||||
~18-tick rise-fall-rise stream so the short vol stop warms up and the SMA-cross bias
|
||||
flips at least once (≥1 closed trade for the smoke test).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Promote `vol_stop` + `risk_executor` + `StopRule` to public `aura-engine` API
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-engine/Cargo.toml`
|
||||
- Create: `crates/aura-engine/src/composites.rs`
|
||||
- Modify: `crates/aura-engine/src/lib.rs:45-67`
|
||||
- Modify: `crates/aura-engine/tests/vol_stop_composite.rs`
|
||||
- Modify: `crates/aura-engine/tests/risk_executor.rs`
|
||||
|
||||
- [ ] **Step 1: Move `aura-std` to a normal dependency**
|
||||
|
||||
In `crates/aura-engine/Cargo.toml`, add to the `[dependencies]` section (after the `serde_json` line):
|
||||
|
||||
```toml
|
||||
# aura-std's standard nodes back the public composite-builders in src/composites.rs
|
||||
# (vol_stop, risk_executor) — the engine's convenience layer over the standard nodes.
|
||||
# aura-std depends only on aura-core, so this stays acyclic (aura-engine -> aura-std
|
||||
# -> aura-core).
|
||||
aura-std = { path = "../aura-std" }
|
||||
```
|
||||
|
||||
and DELETE the now-redundant line under `[dev-dependencies]`:
|
||||
|
||||
```toml
|
||||
aura-std = { path = "../aura-std" }
|
||||
```
|
||||
|
||||
(The `[dev-dependencies]` section keeps `chrono` / `chrono-tz`.)
|
||||
|
||||
- [ ] **Step 2: Create `crates/aura-engine/src/composites.rs`**
|
||||
|
||||
```rust
|
||||
//! Reusable Stage-1 composite-builders: the volatility stop and the per-symbol
|
||||
//! RiskExecutor, promoted from the iter-2 integration-test fixtures so the CLI
|
||||
//! harness (and any consumer) can wire them. Both are `GraphBuilder` compositions
|
||||
//! of `aura-std` primitives — the engine's convenience layer over the standard
|
||||
//! nodes (the sibling of `report::summarize_r`, which likewise lives in this crate
|
||||
//! and reads the `aura-std` PositionManagement record). The Veto is a documented
|
||||
//! seam, not a node (a pass-through identity is exactly what C19/C23 DCE deletes),
|
||||
//! so it appears nowhere here.
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{
|
||||
Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES,
|
||||
};
|
||||
|
||||
use crate::{Composite, GraphBuilder};
|
||||
|
||||
/// The volatility stop as a composition of primitives:
|
||||
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price − Delay(price, 1)`
|
||||
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
|
||||
pub fn vol_stop(length: i64, k: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("vol_stop");
|
||||
let price = g.input_role("price");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
|
||||
let sub = g.add(Sub::builder()); // Δ = price − prev
|
||||
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
|
||||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
g.connect(sub.output("value"), sq.input("lhs"));
|
||||
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
|
||||
g.connect(sq.output("value"), ema.input("series"));
|
||||
g.connect(ema.output("value"), sqrt.input("value"));
|
||||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||||
g.expose(scale.output("value"), "stop_distance");
|
||||
g.build().expect("vol_stop composite wires")
|
||||
}
|
||||
|
||||
/// The protective stop-rule axis (C11 structural): a fixed distance, or the
|
||||
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
|
||||
/// RiskExecutor embeds either by identical downstream wiring.
|
||||
pub enum StopRule {
|
||||
Fixed(f64),
|
||||
Vol { length: i64, k: f64 },
|
||||
}
|
||||
|
||||
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
|
||||
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
|
||||
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
|
||||
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
|
||||
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
|
||||
let mut g = GraphBuilder::new("risk_executor");
|
||||
let bias = g.input_role("bias");
|
||||
let price = g.input_role("price");
|
||||
let stop = match stop {
|
||||
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
|
||||
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
|
||||
};
|
||||
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
|
||||
let pm = g.add(PositionManagement::builder());
|
||||
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
|
||||
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
|
||||
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
|
||||
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
|
||||
g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
|
||||
for field in PM_FIELD_NAMES {
|
||||
g.expose(pm.output(field), field);
|
||||
}
|
||||
g.build().expect("risk_executor wires")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Re-export from `crates/aura-engine/src/lib.rs`**
|
||||
|
||||
Add `mod composites;` to the module block (alphabetical, after `mod builder;` at line 46):
|
||||
|
||||
```rust
|
||||
mod builder;
|
||||
mod composites;
|
||||
mod graph_model;
|
||||
```
|
||||
|
||||
Add the public re-export after the `builder` re-export (line 58):
|
||||
|
||||
```rust
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use composites::{risk_executor, vol_stop, StopRule};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the engine builds with the promoted API**
|
||||
|
||||
Run: `cargo build -p aura-engine`
|
||||
Expected: builds clean (composites.rs compiles against the new normal `aura-std` dep).
|
||||
|
||||
- [ ] **Step 5: Rewire `crates/aura-engine/tests/vol_stop_composite.rs`**
|
||||
|
||||
Delete the local `fn vol_stop(length: i64, k: f64) -> Composite { … }` (lines 13-31). Replace the imports at lines 6-9 with (pruning the now-unused composite-builder deps; the compiler under `-D warnings` enumerates any leftover):
|
||||
|
||||
```rust
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{vol_stop, GraphBuilder, VecSource};
|
||||
use aura_std::Recorder;
|
||||
use std::sync::mpsc::channel;
|
||||
```
|
||||
|
||||
The test body (`vol_stop_emits_k_times_rolling_stddev`, which calls `g.add(vol_stop(3, 2.0))` at line 40) is unchanged — it now calls the promoted public `vol_stop`.
|
||||
|
||||
- [ ] **Step 6: Rewire `crates/aura-engine/tests/risk_executor.rs` + add a Vol-backed RED test**
|
||||
|
||||
Delete the local `fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite { … }` (lines 26-42). Update the imports at line 11-12 to import the promoted fn + `StopRule` and drop the now-unused composite-builder deps (compiler-pruned under `-D warnings`):
|
||||
|
||||
```rust
|
||||
use aura_engine::{risk_executor, summarize_r, GraphBuilder, RunMetrics, StopRule, VecSource};
|
||||
use aura_std::{PositionManagement, Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
```
|
||||
|
||||
In `run_executor` (line 82), change the call at line 87 from `g.add(risk_executor(stop_distance, risk_budget))` to:
|
||||
|
||||
```rust
|
||||
let exec = g.add(risk_executor(StopRule::Fixed(stop_distance), risk_budget));
|
||||
```
|
||||
|
||||
Then ADD this RED test (new coverage: the Vol-backed executor, the match arm that did not exist before the promotion):
|
||||
|
||||
```rust
|
||||
/// Property: the RiskExecutor embeds the `vol_stop` composite (the new `StopRule::Vol`
|
||||
/// arm) and folds to a finite RMetric — the volatility-defined default the `stage1-r`
|
||||
/// harness uses. A short k·σ stop over a rising-then-falling path opens a trade and
|
||||
/// (stop or window-end) closes at least one, so the fold is non-empty and sane.
|
||||
#[test]
|
||||
fn risk_executor_vol_stop_arm_bootstraps_and_folds() {
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
let strat = g.add(ConstLongBias::builder());
|
||||
let exec = g.add(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0));
|
||||
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
|
||||
g.feed(price, [strat.input("price"), exec.input("price")]);
|
||||
g.connect(strat.output("bias"), exec.input("bias"));
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
let col: &'static str = format!("col[{i}]").leak();
|
||||
g.connect(exec.output(field), rec.input(col));
|
||||
}
|
||||
let mut h = g
|
||||
.build()
|
||||
.expect("vol_harness wires")
|
||||
.bootstrap_with_params(vec![])
|
||||
.expect("bootstraps");
|
||||
let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0];
|
||||
let stream: Vec<(Timestamp, Scalar)> =
|
||||
path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect();
|
||||
h.run(vec![Box::new(VecSource::new(stream))]);
|
||||
let ledger: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
let m = summarize_r(&ledger, 0.0);
|
||||
assert!(m.n_trades >= 1, "the vol-stop executor must fold at least one trade");
|
||||
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
|
||||
}
|
||||
```
|
||||
|
||||
(`ConstLongBias` stays the local producer defined at lines 48-78; `Scalar`, `ScalarKind`, `Firing`, `Timestamp`, `channel` are already imported by the file.)
|
||||
|
||||
- [ ] **Step 7: Run the engine tests — fixtures green + the new Vol arm**
|
||||
|
||||
Run: `cargo test -p aura-engine risk_executor`
|
||||
Expected: PASS — `risk_executor_bootstraps_and_folds_to_expected_rmetric`, `risk_executor_r_invariant_under_risk_budget`, `folded_rmetrics_survives_runmetrics_serde_round_trip`, and the new `risk_executor_vol_stop_arm_bootstraps_and_folds` all pass.
|
||||
|
||||
Run: `cargo test -p aura-engine vol_stop`
|
||||
Expected: PASS — `vol_stop_emits_k_times_rolling_stddev` (now calling the promoted fn).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: The `stage1_r_blueprint` harness + the `run_stage1_r` R fold (`aura-cli`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/main.rs` (add `stage1_r_blueprint`, `stage1_r_prices`, `run_stage1_r`; extend the `use` of `aura_engine`/`aura_std`)
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test** (inline `#[cfg(test)] mod tests` in `main.rs`)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn run_stage1_r_synthetic_folds_an_r_block() {
|
||||
// the stage1-r harness scores the SMA-cross signal in R: one shell-callable run
|
||||
// yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade.
|
||||
let report = run_stage1_r(RunData::Synthetic, None);
|
||||
let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r");
|
||||
assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades);
|
||||
assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn);
|
||||
assert!(r.expectancy_r.is_finite(), "E[R] must be finite, got {}", r.expectancy_r);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block`
|
||||
Expected: FAIL — `run_stage1_r` / `RunData` do not exist yet (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the harness, the synthetic stream, and the run handler**
|
||||
|
||||
Ensure `main.rs`'s imports bring in the promoted composites + the nodes the harness
|
||||
needs (extend the existing `use aura_engine::{…}` and `use aura_std::{…}` lines):
|
||||
`aura_engine::{risk_executor, vol_stop, StopRule, GraphBuilder, Composite, summarize_r}`
|
||||
(several already imported) and `aura_std::{Sma, Sub, Bias, SimBroker, Recorder, LinComb,
|
||||
PM_FIELD_NAMES, PM_RECORD_KINDS}` (most already imported — add the missing ones; the
|
||||
compiler enumerates).
|
||||
|
||||
Add the data selector enum near the other CLI types:
|
||||
|
||||
```rust
|
||||
/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1
|
||||
/// close bars for a vetted symbol over an optional window.
|
||||
enum RunData {
|
||||
Synthetic,
|
||||
Real { symbol: String, from: Option<i64>, to: Option<i64> },
|
||||
}
|
||||
```
|
||||
|
||||
Add the synthetic stream (a rise-fall-rise path so the SMA-cross bias flips and the
|
||||
short vol stop warms up — reuses the `macd_prices` shape, kept local so a future
|
||||
`macd_prices` edit cannot silently change this harness's trades):
|
||||
|
||||
```rust
|
||||
/// A rise-fall-rise synthetic stream for the stage1-r smoke run: long enough to warm
|
||||
/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the
|
||||
/// RiskExecutor opens and closes at least one trade. Deterministic (C1).
|
||||
fn stage1_r_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
[
|
||||
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
|
||||
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
Add the dual-tap harness blueprint:
|
||||
|
||||
```rust
|
||||
/// The Stage-1 R harness: the SMA-cross → `Bias` signal fanned into BOTH a `SimBroker`
|
||||
/// (pip equity, the existing pip yardstick) AND the `risk_executor` (the dense R-record,
|
||||
/// the new R yardstick), so one run scores the same signal in pips and in R. Four taps:
|
||||
/// equity (SimBroker), exposure (Bias), the 14-column R-record (drained into
|
||||
/// `summarize_r`), and `r_equity = cum_realized_r + unrealized_r` (a single series for
|
||||
/// `aura chart --tap r_equity`). All params bound at build → compiles with an empty point.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn stage1_r_blueprint(
|
||||
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_r");
|
||||
// SMA-cross signal → Bias (the same signal the pip sample uses).
|
||||
let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2)));
|
||||
let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4)));
|
||||
let spread = g.add(Sub::builder());
|
||||
let exposure = g.add(Bias::builder().named("exposure").bind("scale", Scalar::f64(0.5)));
|
||||
// pip branch (verbatim from sample_blueprint_with_sinks).
|
||||
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
|
||||
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
|
||||
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
|
||||
// R branch: bias + price → RiskExecutor(vol_stop) → dense R-record.
|
||||
let exec = g.add(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0));
|
||||
let rrec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r));
|
||||
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
|
||||
let r_equity = g.add(
|
||||
LinComb::builder(2)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0)),
|
||||
);
|
||||
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(
|
||||
price,
|
||||
[fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")],
|
||||
);
|
||||
g.connect(fast.output("value"), spread.input("lhs"));
|
||||
g.connect(slow.output("value"), spread.input("rhs"));
|
||||
g.connect(spread.output("value"), exposure.input("signal"));
|
||||
// bias fans to: broker (pip), exposure sink, and the RiskExecutor (R).
|
||||
g.connect(exposure.output("bias"), broker.input("exposure"));
|
||||
g.connect(exposure.output("bias"), ex.input("col[0]"));
|
||||
g.connect(exposure.output("bias"), exec.input("bias"));
|
||||
g.connect(broker.output("equity"), eq.input("col[0]"));
|
||||
// tap the dense R-record (all PM fields) for summarize_r.
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
let col: &'static str = format!("col[{i}]").leak();
|
||||
g.connect(exec.output(field), rrec.input(col));
|
||||
}
|
||||
// r_equity sum + tap.
|
||||
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
|
||||
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
|
||||
g.connect(r_equity.output("value"), req.input("col[0]"));
|
||||
g.build().expect("stage1_r wiring resolves")
|
||||
}
|
||||
```
|
||||
|
||||
Add the run handler (synthetic + real in one fn, mirroring `run_sample` / `run_sample_real`
|
||||
for the source and folding both yardsticks):
|
||||
|
||||
```rust
|
||||
/// `aura run --harness stage1-r [--real <SYM> [--from][--to]] [--trace <n>]`: build the
|
||||
/// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via
|
||||
/// `summarize` and the dense R-record via `summarize_r`, and attach the R block as
|
||||
/// `RunMetrics.r = Some(..)`. Pure/deterministic (C1). round_trip_cost is 0.0 (Stage-1 is
|
||||
/// frictionless; a --cost knob is a follow-on).
|
||||
fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport {
|
||||
if let Some(n) = trace
|
||||
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||
{
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let flat = stage1_r_blueprint(tx_eq, tx_ex, tx_r, tx_req)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid stage1-r blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness");
|
||||
|
||||
let (sources, window, pip_size): (Vec<Box<dyn aura_engine::Source>>, _, f64) = match &data {
|
||||
RunData::Synthetic => {
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||
vec![Box::new(VecSource::new(stage1_r_prices()))];
|
||||
let window = window_of(&sources).expect("non-empty synthetic stream");
|
||||
(sources, window, SYNTHETIC_PIP_SIZE)
|
||||
}
|
||||
RunData::Real { symbol, from, to } => {
|
||||
let spec = instrument_spec_or_refuse(symbol);
|
||||
let server =
|
||||
std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
|
||||
if !server.has_symbol(symbol) {
|
||||
no_real_data(symbol);
|
||||
}
|
||||
let window = probe_window(&server, symbol, *from, *to);
|
||||
let source: Box<dyn aura_engine::Source> = match aura_ingest::M1FieldSource::open(
|
||||
&server, symbol, *from, *to, aura_ingest::M1Field::Close,
|
||||
) {
|
||||
Some(s) => Box::new(s),
|
||||
None => no_real_data(symbol),
|
||||
};
|
||||
(vec![source], window, spec.pip_size)
|
||||
}
|
||||
};
|
||||
h.run(sources);
|
||||
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
|
||||
|
||||
let manifest = sim_optimal_manifest(
|
||||
vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
("stop".to_string(), Scalar::i64(3)), // vol_stop length
|
||||
],
|
||||
window,
|
||||
0,
|
||||
pip_size,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows);
|
||||
}
|
||||
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
metrics.r = Some(summarize_r(&r_rows, 0.0));
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
```
|
||||
|
||||
Also add the three-tap persister `run_stage1_r` calls — a SEPARATE helper, NOT a fourth
|
||||
arg on the two-tap `persist_traces`: that keeps the seven pip callers (`:561, :613, :1024,
|
||||
:1101, :1370, :1459, :1695`) byte-unchanged, so the `cli_run.rs` `"taps":["equity","exposure"]`
|
||||
pin stays green. `reduce_for_tap` already decimates `r_equity` by its MinMax default (an
|
||||
equity-like curve), so the chart viewer is unchanged.
|
||||
|
||||
```rust
|
||||
/// Persist a stage1-r run's three taps: equity (off the SimBroker), exposure (off the
|
||||
/// Bias), and r_equity = cum_realized_r + unrealized_r (off the RiskExecutor). Separate
|
||||
/// from the two-tap `persist_traces` so the pip handlers stay byte-unchanged on disk.
|
||||
fn persist_traces_r(
|
||||
name: &str,
|
||||
manifest: &RunManifest,
|
||||
eq_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
ex_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
req_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
) {
|
||||
let taps = vec![
|
||||
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
|
||||
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
|
||||
ColumnarTrace::from_rows("r_equity", &[ScalarKind::F64], req_rows),
|
||||
];
|
||||
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
|
||||
eprintln!("aura: trace persist failed: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The smoke test in Step 1 does not pass `--trace`, so `persist_traces_r` is compiled-but-not-run
|
||||
here; the binary-driven r_equity round-trip in Task 3 exercises it once `--harness stage1-r` is routable.)
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block`
|
||||
Expected: PASS — `metrics.r` is `Some` with `n_trades >= 1` and a finite SQN. (If the
|
||||
synthetic stream yields 0 closed trades, lengthen/steepen `stage1_r_prices` until ≥1 — the
|
||||
test pins `n_trades >= 1`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `parse_run_args` tokenizer + `--harness` selector + `run_dispatch`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/main.rs` (add `RunArgs`/`HarnessKind`/`parse_run_args`/`run_dispatch`; replace the `run` argv arms; update `USAGE`)
|
||||
- Test: `crates/aura-cli/tests/cli_run.rs` (selector + flag-composition integration tests)
|
||||
- Test: `crates/aura-cli/src/main.rs` inline (`parse_run_args` grammar unit tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Inline parser unit tests (in `main.rs` `#[cfg(test)] mod tests`):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn parse_run_args_defaults_to_sma_synthetic() {
|
||||
let a = parse_run_args(&[]).expect("bare run");
|
||||
assert!(matches!(a.harness, HarnessKind::Sma));
|
||||
assert!(matches!(a.data, RunData::Synthetic));
|
||||
assert!(a.trace.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_run_args_composes_harness_real_and_trace_in_any_order() {
|
||||
let a = parse_run_args(&["--trace", "q1", "--harness", "stage1-r", "--real", "GER40", "--from", "100"])
|
||||
.expect("composed flags");
|
||||
assert!(matches!(a.harness, HarnessKind::Stage1R));
|
||||
match a.data {
|
||||
RunData::Real { symbol, from, to } => {
|
||||
assert_eq!(symbol, "GER40");
|
||||
assert_eq!(from, Some(100));
|
||||
assert_eq!(to, None);
|
||||
}
|
||||
_ => panic!("expected real data"),
|
||||
}
|
||||
assert_eq!(a.trace.as_deref(), Some("q1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_run_args_macd_is_an_alias_and_rejects_harness_conflict() {
|
||||
assert!(matches!(parse_run_args(&["--macd"]).unwrap().harness, HarnessKind::Macd));
|
||||
assert!(parse_run_args(&["--macd", "--harness", "sma"]).is_err());
|
||||
assert!(parse_run_args(&["--harness", "nope"]).is_err());
|
||||
assert!(parse_run_args(&["--from", "1"]).is_err()); // --from without --real
|
||||
}
|
||||
```
|
||||
|
||||
cli_run integration tests (in `crates/aura-cli/tests/cli_run.rs`, driving the binary via
|
||||
`Command::new(BIN)` exactly like the existing tests):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn run_harness_stage1_r_prints_an_r_block() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "stage1-r"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(s.contains("\"r\":{"), "stage1-r run must carry an r block: {s}");
|
||||
assert!(s.contains("\"sqn\""), "the r block must carry sqn: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_harness_sma_is_pip_only_no_r_block() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "sma"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unknown_harness_exits_two() {
|
||||
let out = std::process::Command::new(BIN).args(["run", "--harness", "nope"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage1_r_trace_persists_r_equity_and_charts_it() {
|
||||
// `temp_cwd` (cli_run.rs:13) gives a unique CWD with no external tempfile dep — the
|
||||
// pattern the existing trace tests use; it returns a `PathBuf`, so `.join` directly.
|
||||
let dir = temp_cwd("stage1-r-trace");
|
||||
let run = Command::new(BIN)
|
||||
.current_dir(&dir)
|
||||
.args(["run", "--harness", "stage1-r", "--trace", "q1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(run.status.success());
|
||||
// the third tap is persisted beside equity/exposure
|
||||
assert!(dir.join("runs/traces/q1/r_equity.json").exists(), "r_equity tap must persist");
|
||||
let chart = Command::new(BIN)
|
||||
.current_dir(&dir)
|
||||
.args(["chart", "q1", "--tap", "r_equity"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(chart.status.success());
|
||||
let html = String::from_utf8(chart.stdout).unwrap();
|
||||
assert!(!html.is_empty() && html.contains("r_equity"), "chart must render the r_equity series");
|
||||
}
|
||||
```
|
||||
|
||||
> Uses `temp_cwd("stage1-r-trace")` (cli_run.rs:13) — the existing trace tests' pattern, no
|
||||
> external `tempfile` dep — and `Command` (imported at cli_run.rs:4). The plain-`run` tap-order
|
||||
> pin `run_trace_index_carries_manifest_and_ordered_tap_list` (cli_run.rs:329) must stay green:
|
||||
> only the stage1-r harness writes a third tap. If the `html.contains("r_equity")` label differs,
|
||||
> mirror the assertion shape of the existing `chart_emits_self_contained_html_for_a_persisted_run`
|
||||
> (cli_run.rs:416).
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p aura-cli parse_run_args`
|
||||
Expected: FAIL — `parse_run_args` / `RunArgs` / `HarnessKind` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the grammar types, the parser, and the dispatcher**
|
||||
|
||||
```rust
|
||||
/// Which built-in harness `aura run` drives. A fixed compile-time enumeration over
|
||||
/// Rust-authored harnesses — NOT a runtime node registry and NOT a DSL (C9/C17): the
|
||||
/// CLI *runs* an authored harness, it does not wire one.
|
||||
enum HarnessKind {
|
||||
Sma,
|
||||
Macd,
|
||||
Stage1R,
|
||||
}
|
||||
|
||||
/// The parsed `aura run` invocation: a harness, a data source, and an optional trace name.
|
||||
struct RunArgs {
|
||||
harness: HarnessKind,
|
||||
data: RunData,
|
||||
trace: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the `run` tail: `[--harness <name>] [--macd] [--real <SYM> [--from <ms>] [--to <ms>]]
|
||||
/// [--trace <name>]` in any order, each flag at most once. `--harness sma` is the default;
|
||||
/// `--macd` is a back-compat alias for `--harness macd` and conflicts with `--harness`;
|
||||
/// `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the grammar is
|
||||
/// unit-testable; `main` does the side effects.
|
||||
fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let usage = || {
|
||||
"run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>]"
|
||||
.to_string()
|
||||
};
|
||||
let mut harness: Option<HarnessKind> = None;
|
||||
let mut macd_flag = false;
|
||||
let mut symbol: Option<String> = None;
|
||||
let mut from: Option<i64> = None;
|
||||
let mut to: Option<i64> = None;
|
||||
let mut trace: Option<String> = None;
|
||||
let mut tail = rest;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
match *flag {
|
||||
"--macd" if !macd_flag => {
|
||||
macd_flag = true;
|
||||
tail = t;
|
||||
}
|
||||
"--harness" if harness.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
harness = Some(match *value {
|
||||
"sma" => HarnessKind::Sma,
|
||||
"macd" => HarnessKind::Macd,
|
||||
"stage1-r" => HarnessKind::Stage1R,
|
||||
_ => return Err(usage()),
|
||||
});
|
||||
tail = t;
|
||||
}
|
||||
"--real" if symbol.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
if value.is_empty() {
|
||||
return Err(usage());
|
||||
}
|
||||
symbol = Some((*value).to_string());
|
||||
tail = t;
|
||||
}
|
||||
"--from" if from.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
from = Some(value.parse().map_err(|_| usage())?);
|
||||
tail = t;
|
||||
}
|
||||
"--to" if to.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
to = Some(value.parse().map_err(|_| usage())?);
|
||||
tail = t;
|
||||
}
|
||||
"--trace" if trace.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
trace = Some((*value).to_string());
|
||||
tail = t;
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
// --macd is the alias; it conflicts with an explicit --harness.
|
||||
let harness = match (harness, macd_flag) {
|
||||
(Some(_), true) => return Err(usage()),
|
||||
(Some(h), false) => h,
|
||||
(None, true) => HarnessKind::Macd,
|
||||
(None, false) => HarnessKind::Sma,
|
||||
};
|
||||
// --from/--to require --real.
|
||||
if symbol.is_none() && (from.is_some() || to.is_some()) {
|
||||
return Err(usage());
|
||||
}
|
||||
let data = match symbol {
|
||||
Some(s) => RunData::Real { symbol: s, from, to },
|
||||
None => RunData::Synthetic,
|
||||
};
|
||||
Ok(RunArgs { harness, data, trace })
|
||||
}
|
||||
|
||||
/// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS
|
||||
/// the selector — the harnesses are Rust-authored built-ins, picked by name (C9/C17).
|
||||
fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
let trace = args.trace.as_deref();
|
||||
Ok(match (args.harness, args.data) {
|
||||
(HarnessKind::Sma, RunData::Synthetic) => run_sample(trace),
|
||||
(HarnessKind::Macd, RunData::Synthetic) => run_macd(trace),
|
||||
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace),
|
||||
(HarnessKind::Sma, RunData::Real { symbol, from, to }) => {
|
||||
run_sample_real(&symbol, from, to, trace)
|
||||
}
|
||||
(HarnessKind::Macd, RunData::Real { .. }) => {
|
||||
return Err("the macd harness has no --real form".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the `run` argv arms in `main()`**
|
||||
|
||||
Replace the five `run` arms (lines 1710-1722) with a single tail-parser arm:
|
||||
|
||||
```rust
|
||||
["run", rest @ ..] => match parse_run_args(rest) {
|
||||
Ok(args) => match run_dispatch(args) {
|
||||
Ok(report) => println!("{}", report.to_json()),
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
(The `["chart", rest @ ..]` arm and all others below stay unchanged.)
|
||||
|
||||
- [ ] **Step 5: Update the `USAGE` string** (line 1701-1702)
|
||||
|
||||
Change the leading `run` clause from
|
||||
`aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>]`
|
||||
to
|
||||
`aura run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>]`.
|
||||
|
||||
- [ ] **Step 6: Run the parser + selector tests, then the full cli_run suite**
|
||||
|
||||
Run: `cargo test -p aura-cli parse_run_args`
|
||||
Expected: PASS — the three grammar tests.
|
||||
|
||||
Run: `cargo test -p aura-cli run_harness`
|
||||
Expected: PASS — `run_harness_stage1_r_prints_an_r_block`, `run_harness_sma_is_pip_only_no_r_block`, `run_unknown_harness_exits_two`.
|
||||
|
||||
Run: `cargo test -p aura-cli --test cli_run`
|
||||
Expected: PASS — every existing run/--macd/--real/--trace/chart/usage test stays green (the
|
||||
tokenizer accepts every form the old literal arms accepted, including the strict
|
||||
trailing-token and `run --real` paths), AND the new `stage1_r_trace_persists_r_equity_and_charts_it`
|
||||
round-trip passes, AND `run_trace_index_carries_manifest_and_ordered_tap_list` still pins the
|
||||
plain-`run` tap list to exactly `["equity","exposure"]` (pip runs write no third tap).
|
||||
|
||||
---
|
||||
|
||||
## Final gates (run after all tasks)
|
||||
|
||||
- [ ] **Whole-workspace build**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Whole-workspace test**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all green (the prior 500+ tests plus the new engine + cli tests).
|
||||
|
||||
- [ ] **Lint**
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: clean (no unused imports left from the fixture rewires).
|
||||
@@ -1,124 +0,0 @@
|
||||
# exposure→bias rename tail (cycle 0065, #126) — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (secondary change (a),
|
||||
> deferred from iter-0; scope fork decided on #117).
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this
|
||||
> plan. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** complete the `exposure → bias` rename of the strategy-output residue — the
|
||||
typed `exposure_sign_flips` metric (with serde back-compat), the registry rank vocab,
|
||||
the `exposure_scale` manifest label, and the `.named("exposure")` Bias instances —
|
||||
**without** touching SimBroker's deliberate pre-reframe exposure concept or the on-disk
|
||||
`exposure` tap label (fork on #117).
|
||||
|
||||
**Architecture:** `RunMetrics.exposure_sign_flips` is a serde struct field, so its
|
||||
rename is one atomic compile-unit across all crates; a `#[serde(alias =
|
||||
"exposure_sign_flips")]` keeps legacy `runs.jsonl` readable while new output uses
|
||||
`bias_sign_flips`. The registry rank metric accepts BOTH strings (CLI back-compat).
|
||||
The manifest param label and Bias instance names are plain string renames.
|
||||
|
||||
**Test split rule (load-bearing — apply everywhere):** a test that pins **new output**
|
||||
(serialize / byte-pin / a freshly-built `RunMetrics`) updates to `bias_sign_flips` /
|
||||
`bias_scale`; a test that reads a **legacy** line (deserialize back-compat) KEEPS the
|
||||
old `exposure_sign_flips` / `exposure_scale` key (it now pins the serde alias).
|
||||
|
||||
**Files this plan modifies** (site map from grep — the compiler enumerates the field-rename sites):
|
||||
- Modify: `crates/aura-engine/src/report.rs` — `RunMetrics.exposure_sign_flips` field (+ alias), the `summarize` fold, the constructor, tests.
|
||||
- Modify: `crates/aura-engine/src/mc.rs:40,88,211` — the parallel `exposure_sign_flips` aggregate field.
|
||||
- Modify: `crates/aura-registry/src/lib.rs` — `Metric::ExposureSignFlips` vocab, `metric_cmp`, the rank-string match (+both), the error message, constructor, tests.
|
||||
- Modify: `crates/aura-cli/src/main.rs` — the `:1462` JSON output key, the `exposure_scale` manifest builders (`:557,594,1439,1675,1825,2400`), the `.named("exposure")` instances (`:950,1042,1601,1738`), tests (`:2473,2560,2728`).
|
||||
- Modify: `crates/aura-cli/tests/cli_run.rs:39` — the byte-pin.
|
||||
- Modify: `crates/aura-engine/src/test_fixtures.rs:65`, `crates/aura-engine/src/blueprint.rs:2566` — `.named("exposure")`.
|
||||
- Modify: `crates/aura-engine/tests/risk_executor.rs:142`, `crates/aura-ingest/tests/{real_bars,streaming_seam}.rs`, `crates/aura-ingest/examples/ger40_breakout_{compare,real,sweep}.rs` — field/label references.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rename `exposure_sign_flips → bias_sign_flips` (typed field + serde alias + rank vocab)
|
||||
|
||||
**Files:** `report.rs`, `mc.rs`, `aura-registry/src/lib.rs`, `main.rs`, `cli_run.rs`, `risk_executor.rs`, the three ger40 examples — every `exposure_sign_flips` site (the compiler flags them once the field is renamed).
|
||||
|
||||
- [ ] **Step 1: Write the RED back-compat test** (in `crates/aura-engine/src/report.rs` tests)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() {
|
||||
// a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it.
|
||||
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#;
|
||||
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises");
|
||||
assert_eq!(m.bias_sign_flips, 3);
|
||||
// new output serialises the NEW key, and the old key is gone from output.
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}");
|
||||
assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to confirm RED**
|
||||
|
||||
Run: `cargo test -p aura-engine legacy_exposure_sign_flips_key_reads_via_alias`
|
||||
Expected: FAIL — does not compile (`m.bias_sign_flips` does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Rename the field + add the serde alias** (`crates/aura-engine/src/report.rs`)
|
||||
|
||||
In the `RunMetrics` struct (`:29`):
|
||||
```rust
|
||||
#[serde(alias = "exposure_sign_flips")]
|
||||
pub bias_sign_flips: u64,
|
||||
```
|
||||
Rename the fold local (`:310,317`) `exposure_sign_flips` → `bias_sign_flips` and the constructor (`:322`) `RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }`. Update the in-file tests per the **test split rule**: assertions on a freshly-summarised `m` (`:818,842,849`) and serialize round-trips (`:888,913,933,947,979`) → `bias_sign_flips`; the **legacy-deserialise** test (`:971`) KEEPS `exposure_sign_flips` in its JSON literal (it now pins the alias) and updates only its assertion field to `m.bias_sign_flips`.
|
||||
|
||||
- [ ] **Step 4: Propagate the field rename across the workspace**
|
||||
|
||||
Rename `exposure_sign_flips` → `bias_sign_flips` at every other site the compiler flags:
|
||||
- `crates/aura-engine/src/mc.rs:40,88,211` — the aggregate field + its `pick`/constructor uses (if the MC aggregate serialises to `families.jsonl`, add the same `#[serde(alias = "exposure_sign_flips")]` on its field).
|
||||
- `crates/aura-cli/src/main.rs:1462` (`"bias_sign_flips": agg.bias_sign_flips`), `:2560` (field ref); the byte-pin tests `:2473` (`"bias_sign_flips":`), `:2728` (assertion) → new key.
|
||||
- `crates/aura-cli/tests/cli_run.rs:39` → `line.ends_with("\"bias_sign_flips\":1}}")` (new output).
|
||||
- `crates/aura-engine/tests/risk_executor.rs:142`, the ger40 examples (`compare:130,154`, `real:89`, `sweep:149`) → field refs.
|
||||
|
||||
- [ ] **Step 5: Registry rank vocab — rename the variant, accept BOTH strings** (`crates/aura-registry/src/lib.rs`)
|
||||
|
||||
```rust
|
||||
// the Metric enum variant (:103):
|
||||
BiasSignFlips,
|
||||
// rank-string match (:120-122) — accept the new name AND the old one (CLI back-compat):
|
||||
"bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips,
|
||||
// metric_cmp arm (:130-131):
|
||||
Metric::BiasSignFlips => a.metrics.bias_sign_flips.cmp(&b.metrics.bias_sign_flips),
|
||||
// error/listing (:185): "(known: total_pips, max_drawdown, bias_sign_flips)"
|
||||
// constructor (:214): bias_sign_flips: flips
|
||||
```
|
||||
Per the test split: the legacy-deserialise test (`:275`) KEEPS `exposure_sign_flips` in its JSON literal; the `rank_by` tests (`:296,297`) update the assertion field to `bias_sign_flips` (rank string may use either — keep `"exposure_sign_flips"` at `:296` to exercise the alias, or add a sibling asserting `"bias_sign_flips"` ranks identically).
|
||||
|
||||
- [ ] **Step 6: Build + the RED test now GREEN + suite green**
|
||||
|
||||
Run: `cargo test -p aura-engine legacy_exposure_sign_flips_key_reads_via_alias`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all green (the renamed field + alias; legacy-read tests pin the alias; new-output tests pin `bias_sign_flips`).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rename the `exposure_scale` manifest label + `.named("exposure")` Bias instances
|
||||
|
||||
**Files:** `main.rs` (manifest builders + Bias instances), `report.rs` (manifest-builder tests), `test_fixtures.rs`, `blueprint.rs`, `real_bars.rs`, `streaming_seam.rs`.
|
||||
|
||||
- [ ] **Step 1: Rename the `.named("exposure")` Bias instances → `.named("bias")`**
|
||||
|
||||
At `crates/aura-cli/src/main.rs:950,1042,1601,1738`, `crates/aura-engine/src/test_fixtures.rs:65`, `crates/aura-engine/src/blueprint.rs:2566`: `Bias::builder().named("exposure")` → `Bias::builder().named("bias")`. (Pure debug symbols — no behaviour change. If any test asserts the node label/name `"exposure"`, update that assertion to `"bias"`; if a graph-render fixture pins the name, update it.)
|
||||
|
||||
- [ ] **Step 2: Rename the `exposure_scale` manifest param label → `bias_scale`**
|
||||
|
||||
In every manifest **builder** (new output): `crates/aura-cli/src/main.rs:557,594,1439,1675,1825,2400`, `crates/aura-engine/src/report.rs:637,879,907,927`, `crates/aura-ingest/tests/real_bars.rs:87`, `crates/aura-ingest/tests/streaming_seam.rs:87` → `("bias_scale".to_string(), Scalar::f64(...))`. Per the test split: the **legacy-deserialise** JSON literal at `report.rs:894` KEEPS `exposure_scale` (it reads an old manifest; manifest params are an untyped `Vec<(String, Scalar)>`, so no serde alias is needed — old data just carries the old label).
|
||||
|
||||
- [ ] **Step 3: Build + suite green + lint**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: clean.
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all green.
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: clean.
|
||||
@@ -1,535 +0,0 @@
|
||||
# Stage-1 R-based signal quality — Design Spec
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
**Cycle:** 0065 — milestone "R-based signal quality (Stage 1)"
|
||||
**Issues:** #119 (stop-rule / R), #126 (exposure → bias), #127 (Sizer + position-management), #128 (RiskExecutor + Veto seam), #129 (R-evaluator). Reference / decision log: #117.
|
||||
**Contract:** ledger C10 (reframed, commit `f040b66`).
|
||||
|
||||
## Goal
|
||||
|
||||
Make a strategy's **signal quality measurable in R** — the account- and
|
||||
instrument-agnostic Van-Tharp yardstick *"how much R out per 1R risked?"* — as a
|
||||
**feed-forward** (no equity feedback) downstream layer on a strategy's **bias**
|
||||
stream. This is Stage 1 of C10: the primary research loop, maximally parallel and
|
||||
deterministic (C1). Currency P&L, compounding, and realistic brokers are Stage 2
|
||||
(a later milestone), entered only once `E[R] > 0`.
|
||||
|
||||
Concretely the cycle delivers: the `exposure → bias` rename (the unsized strategy
|
||||
output); a **stop-rule** node family that emits a protective-stop *distance*
|
||||
(which **defines 1R**); a **position-management** node that turns bias + stop into
|
||||
a stream of realised **per-trade R-outcomes**; a flat-1R **Sizer** seam; a
|
||||
per-symbol **RiskExecutor** composite; and a post-run **`summarize_r`** fold that
|
||||
reduces the trade ledger into R-metrics (E[R], SQN, win-rate, …) plus a by-trade
|
||||
R-equity curve.
|
||||
|
||||
This is **new discrete-trade machinery**, not an extension of `SimBroker`.
|
||||
`SimBroker` integrates a *continuous* `exposure · price-return` into a pip curve —
|
||||
no discrete trade, no stop, no risk unit; it is the ancestor *in spirit only* (a
|
||||
downstream node that produces a quality curve) and is retained unchanged as the
|
||||
pre-reframe pip-quality node.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Stage-1 chain, all ordinary downstream nodes (C8/C9), feed-forward:
|
||||
|
||||
```
|
||||
bias (f64 ∈ [-1,+1], unsized) # renamed from exposure; sign=direction, |·|=conviction
|
||||
│ price (f64)
|
||||
▼ │
|
||||
stop-rule ──► stop_distance (f64 ≥ 0) # VolStop = k·EMA(|Δprice|), or FixedStop(d)
|
||||
│ │
|
||||
│ ┌───────┴──────────────┐
|
||||
▼ ▼ ▼
|
||||
[Sizer] ──► size (f64) position-management ──► dense per-cycle R-record
|
||||
(iter-2 seam) │ (the trade ledger + R-equity)
|
||||
▼
|
||||
Recorder (tap) ──drain post-run──► summarize_r ──► RMetrics + R-curve
|
||||
```
|
||||
|
||||
- **stop-rule** is a pure per-cycle function of price (and its own volatility
|
||||
state). It emits a **direction-agnostic stop *distance*** in price units, never
|
||||
an absolute level — so it is reusable long or short and `R` is a dimensionless
|
||||
ratio. Two concrete nodes ship: `VolStop` (volatility-scaled, the meaningful
|
||||
default) and `FixedStop` (constant, the test fixture / structural-axis sibling).
|
||||
- **position-management** is the stateful heart. It opens on a nonzero bias,
|
||||
**latches the entry-cycle stop distance once as the immutable R-denominator**,
|
||||
tracks the stop level, marks the position against the **one-cycle-lagged fill**
|
||||
(the `SimBroker` no-look-ahead discipline), detects exits (stop / bias-flip /
|
||||
reversal), and emits a **dense per-cycle R-record** carrying both the running
|
||||
R-equity and, when a trade closes that cycle, its realised R-outcome.
|
||||
- **Sizer** (iter-2) is a load-bearing seam: `size = risk_budget / stop_distance`
|
||||
(flat-1R), reading bias + stop_distance so the interface is genuinely exercised;
|
||||
Stage 2 swaps `risk_budget` for `risk_fraction · equity`, same shape. The
|
||||
R-outcome is computed **size-invariantly**, so the Sizer never contaminates R.
|
||||
- **RiskExecutor** (iter-2) is a per-symbol `Composite` bundling
|
||||
`stop-rule → [Sizer] → position-management` with `bias` + `price` as input roles
|
||||
(price fanned to both stop-rule and position-management). The **Veto** is a
|
||||
*documented seam only* (a named position in this spec, **not** a runtime node) —
|
||||
a pass-through identity is exactly what C19/C23 DCE is licensed to delete.
|
||||
- **`summarize_r`** is a **post-run fold** (sibling of `summarize`), **not** an
|
||||
in-graph node: recorder taps are drained post-run (`rx.try_iter().collect()`;
|
||||
the playground is web-from-disk), so an in-graph R-curve node would buy nothing,
|
||||
double-maintain state, and carry a broken sparse time axis. The in-graph
|
||||
running-equity node is reserved for Stage 2 (compounding through the z⁻¹
|
||||
fill-edge register).
|
||||
|
||||
**Why a dense R-record (not a sparse `Some`-on-close / `None` stream).** A single
|
||||
net position closes ≤1 trade per cycle, so a sparse stream would be C8-legal — but
|
||||
it cannot express the **R-equity curve / max-drawdown-in-R** (no intra-trade mark)
|
||||
and cannot reveal a position **still open at window end** (it only carries closes).
|
||||
A dense record (one row per cycle, always `Some`, exactly like `SimBroker`) carries
|
||||
a `closed_this_cycle` flag: the **trade ledger** is the subset of rows where it is
|
||||
set; the **R-equity** is the dense `cum_realized_r + unrealized_r`; the
|
||||
**window-end** open trade is the last row with `open = true`. ≤1 record per eval
|
||||
still holds (C8), and the asymmetry with the position-event table is intact (a
|
||||
reversal is *two* book events at one ts but only *one* close, so the table needs
|
||||
>1/instant while this record needs only one).
|
||||
|
||||
The dense **multi-field** output follows the `Resample` precedent
|
||||
(`crates/aura-std/src/resample.rs`, `out: [Cell; 4]`, four `FieldSpec`s — an OHLC
|
||||
bundle consumed and recorded end-to-end in the GER40 breakout tests), so it is on a
|
||||
**tested runtime path**. Issue #47's untested-ness concerns only the CLI graph
|
||||
*re-export rendering* of such a producer inside a composite — orthogonal to the
|
||||
recording path this cycle uses.
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### The user-facing program (the acceptance evidence)
|
||||
|
||||
What a strategy author writes to measure a bias strategy's signal quality in R
|
||||
(mirrors the existing `ger40_breakout_real` harness shape — author a composite,
|
||||
bootstrap, run, drain, fold):
|
||||
|
||||
```rust
|
||||
use aura_std::{Bias, VolStop, PositionManagement, Recorder};
|
||||
use aura_engine::{GraphBuilder, report::summarize_r};
|
||||
|
||||
// A per-symbol risk-based executor over a bias stream (iter-2 ships this as the
|
||||
// reusable `RiskExecutor` composite; shown here wired by hand for clarity).
|
||||
let mut g = GraphBuilder::new("breakout_R");
|
||||
let bias = g.input_role("bias"); // the strategy's unsized output, f64 ∈ [-1,+1]
|
||||
let price = g.input_role("price"); // the instrument price
|
||||
|
||||
let stop = g.add(VolStop::builder(/*length*/ 20, /*k*/ 2.0)); // k·EMA(|Δprice|) → stop_distance
|
||||
let pm = g.add(PositionManagement::builder()); // bias + price + stop_distance → R-record
|
||||
let rtap = g.add(Recorder::builder(PositionManagement::RECORD_KINDS.into(), Firing::Any, tx));
|
||||
|
||||
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans out
|
||||
g.feed(bias, [pm.input("bias")]);
|
||||
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
|
||||
for (i, field) in PositionManagement::FIELD_NAMES.iter().enumerate() {
|
||||
g.connect(pm.output(field), rtap.input(&format!("col[{i}]"))); // tap each field of the dense R-record
|
||||
}
|
||||
let exec = g.build().expect("RiskExecutor wires");
|
||||
|
||||
// ... bootstrap `exec` with the bias-producing strategy + a price source, run ...
|
||||
let ledger = drain(&rtap); // Vec<(Timestamp, Vec<Scalar>)>
|
||||
let r: RMetrics = summarize_r(&ledger); // the post-run fold
|
||||
|
||||
println!("E[R] = {:+.3}", r.expectancy_r); // how much R out per 1R risked
|
||||
println!("SQN = {:.2}", r.sqn); // dispersion-adjusted quality (sweep objective)
|
||||
println!("trades = {} (win {:.0}%, {} open@end)", r.n_trades, 100.0*r.win_rate, r.n_open_at_end);
|
||||
```
|
||||
|
||||
The criterion's evidence: a trader-author reaches for exactly this — "score my
|
||||
strategy in R" — and gets a risk-normalised, account-agnostic number that pips
|
||||
cannot give. It introduces no look-ahead (the lagged-fill discipline + a RED test
|
||||
enforce C2) and no feedback (flat-1R is feed-forward, C1).
|
||||
|
||||
### Before → after: the load-bearing changes (secondary)
|
||||
|
||||
**(a) `exposure → bias` rename (#126, commit-0, behaviour-preserving).**
|
||||
|
||||
```rust
|
||||
// before: crates/aura-std/src/exposure.rs
|
||||
pub struct Exposure { scale: f64, out: [Cell; 1] } // node "Exposure", output field "exposure"
|
||||
// after: crates/aura-std/src/bias.rs
|
||||
pub struct Bias { scale: f64, out: [Cell; 1] } // node "Bias", output field "bias"
|
||||
// computation UNCHANGED: clamp(signal / scale, -1, +1). SEMANTICS change: the output is the
|
||||
// UNSIZED strategy bias; sizing leaves the strategy (moves downstream to the Sizer).
|
||||
```
|
||||
Scope: the `Bias` node (type, file, node-name string, output field `exposure`→`bias`,
|
||||
`label()`), `SimBroker`'s slot-0 port name + doc, the `aura-std` re-export, and all
|
||||
example/test wiring. **Data back-compat:** the persisted `RunMetrics.exposure_sign_flips`
|
||||
field is renamed to `bias_sign_flips` **with `#[serde(alias = "exposure_sign_flips")]`** so
|
||||
historical `runs.jsonl` still deserialises (the planner pins the exact sites; ~311
|
||||
`exposure` / 82 `Exposure` references across `crates/`).
|
||||
|
||||
**(b) Stop-rule (#119).**
|
||||
|
||||
> **CORRECTION (2026-06-24, #117).** The "fused `VolStop` node (no Abs/Mul
|
||||
> primitive exists, so fuse)" below was a design error. A node is a PRIMITIVE only
|
||||
> if it is **not** DAG-expressible from other primitives; a missing primitive is
|
||||
> **added**, not fused away. The vol stop is pure feed-forward arithmetic → a
|
||||
> **composition**, not a primitive. Corrected design:
|
||||
> - add primitives `Mul` (two-stream f64 product) + `Sqrt` to `aura-std`;
|
||||
> - the vol stop is a **rolling stddev (EWMA volatility)**: `stop_distance =
|
||||
> k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`, scaled
|
||||
> `k·σ` via the existing `LinComb` (k a param weight). `Sqrt` is mandatory
|
||||
> (variance is price², `stop_distance` must be price so R is dimensionless);
|
||||
> `Abs` is **not** added (Δ² not |Δ|);
|
||||
> - this `vol_stop(length, k) → Composite` lives in `aura-engine` (composites need
|
||||
> `GraphBuilder`); the fused `VolStop` node is removed;
|
||||
> - `FixedStop` is **kept** as a legitimate primitive (a param constant gated on
|
||||
> its price input — a source-less `Const` has no firing trigger in the push model).
|
||||
>
|
||||
> The block below is the superseded pre-correction shape, retained for ancestry.
|
||||
|
||||
```rust
|
||||
// SUPERSEDED (see CORRECTION above): the fused-node shape, not the shipped design.
|
||||
pub struct VolStop { length: usize, k: f64, prev_price: Option<f64>, ema: f64, comp: f64, count: usize, out: [Cell; 1] }
|
||||
// eval: d = |price - prev_price|; ema = EMA_length(d) (Kahan, like Sma); out = k * ema.
|
||||
pub struct FixedStop { distance: f64, out: [Cell; 1] } // KEPT: out = distance, constant (a triggered-constant primitive)
|
||||
```
|
||||
|
||||
**(c) `PositionManagement` node (#127) — new, `aura-std`. The dense R-record.**
|
||||
|
||||
```rust
|
||||
pub struct PositionManagement {
|
||||
// open-position state (None = flat):
|
||||
pos: Option<OpenPosition>, // { dir: i64, entry_price, latched_dist, stop_level, entry_ts, bias_abs }
|
||||
prev_price: Option<f64>, // the fill held INTO this cycle (SimBroker discipline)
|
||||
cum_realized_r: f64, // running closed-trade R
|
||||
out: [Cell; PositionManagement::WIDTH],
|
||||
}
|
||||
// Inputs (order load-bearing, all f64/Any): 0 bias, 1 price, 2 stop_distance.
|
||||
// Output: ONE dense record per cycle (C8), columns (RECORD_KINDS):
|
||||
// 0 closed_this_cycle : Bool
|
||||
// 1 realized_r : F64 // valid iff closed_this_cycle; else 0.0
|
||||
// 2 exit_reason : I64 // ExitReason enum: 0=stop 1=bias-flip 2=reversal-leg 3=window-end
|
||||
// 3 was_stopped : Bool
|
||||
// 4 direction : I64 // -1 / +1 of the closed (or open) trade
|
||||
// 5 entry_ts : Timestamp
|
||||
// 6 entry_price : F64
|
||||
// 7 stop_price : F64
|
||||
// 8 exit_price : F64
|
||||
// 9 bias_at_entry_abs : F64 // conviction, for the calibration diagnostic
|
||||
// 10 size : F64 // from the Sizer (iter-2); 1.0 placeholder in iter-1
|
||||
// 11 open : Bool // a position is open AFTER this cycle (window-end detection)
|
||||
// 12 unrealized_r : F64 // mark of the open position (0.0 when flat) → dense R-equity
|
||||
// 13 cum_realized_r : F64 // running closed R → by-trade R-equity = cum_realized + unrealized
|
||||
// r_multiple is debug-asserted == direction * (exit - entry) / latched_dist.
|
||||
```
|
||||
|
||||
`ExitReason` mirrors `PositionAction`'s i64-tagged-enum pattern (`#[serde(into="i64",
|
||||
try_from="i64")]`, checked `TryFrom`).
|
||||
|
||||
**(d) `summarize_r` fold + `RMetrics` (#129) — new, `aura-engine/src/report.rs`.**
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RMetrics {
|
||||
pub expectancy_r: f64, // mean realised R over closed trades (equal-weighted; headline)
|
||||
pub n_trades: u64,
|
||||
pub win_rate: f64,
|
||||
pub avg_win_r: f64,
|
||||
pub avg_loss_r: f64,
|
||||
pub profit_factor: f64, // sum(win R) / |sum(loss R)|
|
||||
pub sqn: f64, // √n · mean_R / stdev_R — dispersion-adjusted quality, the sweep objective
|
||||
pub max_r_drawdown: f64, // max peak-to-trough on the by-trade R-equity (cum_realized_r), ≥ 0
|
||||
pub n_open_at_end: u64, // positions force-closed at window-end (counted, not hidden)
|
||||
pub net_expectancy_r: f64, // E[R] minus one round-trip spread cost (price-unit param) — churn-honest
|
||||
pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile — conviction calibration
|
||||
}
|
||||
// Reduces the dense R-record stream: filters closed_this_cycle rows for the ledger; the last row's
|
||||
// `open`/`unrealized_r` synthesises a window-end forced close (exit_reason=window-end). Pure (C1).
|
||||
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) -> RMetrics { /* … */ }
|
||||
|
||||
// RunMetrics gains an optional R block, back-compat for old runs.jsonl:
|
||||
pub struct RunMetrics { /* … existing pip fields … */ #[serde(default)] pub r: Option<RMetrics> }
|
||||
```
|
||||
|
||||
**(e) `Sizer` seam + `RiskExecutor` composite (#127/#128, iter-2).**
|
||||
|
||||
```rust
|
||||
pub struct Sizer { risk_budget: f64, out: [Cell; 1] }
|
||||
// eval: out = risk_budget / stop_distance (risk_budget=1 ⇒ flat-1R; Stage-2: risk_fraction·equity)
|
||||
// Inputs: bias (direction), stop_distance. Output "size" (f64).
|
||||
// RiskExecutor: a GraphBuilder Composite, per symbol, input roles bias+price, exposes the R-record.
|
||||
// Veto: NOT a node — a documented seam (added as a real gating edge the cycle it first vetoes).
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Crate / file | Iter |
|
||||
|-----------|--------------|------|
|
||||
| `Bias` (rename of `Exposure`) | `aura-std/src/bias.rs` | 0 |
|
||||
| `VolStop`, `FixedStop` | `aura-std/src/stop_rule.rs` (or `vol_stop.rs`/`fixed_stop.rs`) | 1 |
|
||||
| `PositionManagement` + `ExitReason` | `aura-std/src/position_management.rs` | 1 |
|
||||
| `summarize_r` + `RMetrics` (core: E[R], n_trades, win_rate, max_r_drawdown, n_open_at_end) | `aura-engine/src/report.rs` | 1 |
|
||||
| `Sizer` | `aura-std/src/sizer.rs` | 2 |
|
||||
| `RiskExecutor` composite builder | `aura-engine` (or an `aura-std` composite helper) | 2 |
|
||||
| `summarize_r` enrichment (SQN, conviction terciles, net-of-cost) + `RunMetrics.r` + CLI/recording | `aura-engine`, `aura-cli` | 2 |
|
||||
|
||||
## Data flow (one cycle, position-management)
|
||||
|
||||
1. Read current `price` (slot 1), `bias` (slot 0), `stop_distance` (slot 2).
|
||||
2. **If a position is open:** mark P&L against `prev_price` (the fill held *into*
|
||||
this cycle — never the same-cycle price the stop is tested against). Test the
|
||||
stop: did price reach the latched `stop_level`? (close-only: tested at the
|
||||
close, a **documented optimistic bias**; with a resampled high/low, tested
|
||||
against the adverse extreme). If stopped → exit fill = **no better than the
|
||||
stop level** (gap-through ⇒ `R < -1`, the honest loss tail — never capped at
|
||||
−1). Else if bias exited (sign → 0 or flipped) → exit at the lagged fill.
|
||||
Compute `realized_r = dir·(exit − entry)/latched_dist`, set the close columns.
|
||||
3. **Reversal:** an open long with bias flipping short (or vice-versa) closes the
|
||||
current leg (one R-outcome, `exit_reason = reversal-leg`) **and** reopens the
|
||||
opposite side as state (the reopen is not an outcome — ≤1 close/cycle holds).
|
||||
4. **If flat and bias nonzero:** open — latch `entry_price` (the lagged fill),
|
||||
`dir = sign(bias)`, `latched_dist = stop_distance` (frozen for the trade — the
|
||||
R-unit is **never re-latched mid-trade**), `stop_level = entry ∓ latched_dist`,
|
||||
`bias_at_entry_abs = |bias|`.
|
||||
5. Emit the dense record (open/unrealized/cum fields always; close fields when
|
||||
`closed_this_cycle`). Update `prev_price` **after** taking the outcome (C2).
|
||||
|
||||
Post-run: drain the Recorder tap → `summarize_r(ledger, round_trip_cost)` →
|
||||
`RMetrics`; the last row's `open`/`unrealized_r` is force-closed as a window-end
|
||||
trade. The whole chain is feed-forward (position state is **intra-node** struct
|
||||
state like `Latch.held` / `SimBroker.prev_*` — not a graph edge, so it needs no
|
||||
back-edge support and does not trip the topo-sort cycle rejection).
|
||||
|
||||
## Error handling
|
||||
|
||||
- Builder asserts mirror the existing nodes: `VolStop` `length ≥ 1` & `k > 0`,
|
||||
`FixedStop` `distance > 0`, `Sizer` `risk_budget > 0` (panic at construction —
|
||||
these are authoring bugs, surfaced like `Exposure scale must be > 0`).
|
||||
- Input-order is load-bearing and all-`f64`/`i64` (a swapped wiring is not
|
||||
kind-caught) — documented per slot, exactly as `SimBroker` documents its
|
||||
`exposure`/`price` order.
|
||||
- `summarize_r` over an empty ledger returns a well-defined zero `RMetrics`
|
||||
(`expectancy_r = 0`, `n_trades = 0`, `sqn = 0`, …), never a NaN/panic;
|
||||
`profit_factor` with no losses is reported as a sentinel (e.g. `f64::INFINITY`
|
||||
documented, or `0.0` when no trades).
|
||||
- A `debug_assert!` ties `realized_r` to `dir·(exit−entry)/latched_dist`
|
||||
(the record's one redundancy, guarded — mirrors `PositionEvent`'s checked
|
||||
round-trip).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
RED-first where behaviour is test-specifiable. Mandatory tests:
|
||||
|
||||
1. **No look-ahead (the keystone RED test), parallel to `sim_broker_no_lookahead`:**
|
||||
a bias-flip-exit trade's realised R equals the **lagged-fill** R; a stopped
|
||||
trade is exactly −1R **in the no-gap case** independent of the fill price.
|
||||
2. **Stop tail is not capped:** a gap-through-stop trade realises `R < -1`
|
||||
(fill no better than the stop), not −1 — the loss-tail-truncation guard.
|
||||
3. **R-invariance under size:** scaling the Sizer's `size` leaves every
|
||||
`realized_r` unchanged (the property that keeps Stage 1 feed-forward).
|
||||
4. **flat-1R invariant:** `Sizer` output satisfies `size · stop_distance ≡ risk_budget`.
|
||||
5. **C8 / one-close-per-cycle:** a reversal cycle sets `closed_this_cycle` once
|
||||
(one R-outcome) and reopens as state; the record is one row.
|
||||
6. **Window-end:** a position open at the last cycle is force-closed
|
||||
(`exit_reason = window-end`), counted in `n_open_at_end`, and **not** silently
|
||||
folded as unrealised MtM.
|
||||
7. **`VolStop`** warm-up (`None` until `length`), Kahan stability, `= k·EMA(|Δ|)`;
|
||||
**`FixedStop`** constant.
|
||||
8. **`summarize_r`** arithmetic: E[R], win-rate, profit-factor, **SQN**
|
||||
(`√n·meanR/stdevR`), `max_r_drawdown` on a hand-built ledger; conviction
|
||||
terciles separate a conviction-calibrated synthetic strategy from a flat one;
|
||||
net-of-cost subtracts one round-trip spread; empty-ledger zero case.
|
||||
9. **Bias rename:** the existing `Exposure` tests carry over green as `Bias`
|
||||
(behaviour-preserving); old `runs.jsonl` with `exposure_sign_flips` still
|
||||
deserialises via the serde alias.
|
||||
10. **E2E:** a synthetic bias + price chain through the RiskExecutor produces a
|
||||
non-empty trade ledger and a sane `RMetrics` (a known-edge synthetic strategy
|
||||
yields `E[R] > 0`, a coin-flip yields `E[R] ≈ 0`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A strategy author can wire `bias → stop-rule → position-management`, run, and
|
||||
fold an `RMetrics` reporting **E[R] and SQN** — the worked example above runs.
|
||||
- R is **stop-defined and size-invariant**: `realized_r = dir·(exit−entry)/latched_dist`,
|
||||
the latched distance frozen at entry; scaling size does not change any R.
|
||||
- **No look-ahead, no feedback:** the keystone RED test passes; the chain is
|
||||
feed-forward (no graph cycle, no equity read).
|
||||
- **Honest loss tail:** stop-outs are not capped at −1R; gap-through realises `R < -1`.
|
||||
- **Volatility-defined R, not pips:** the default stop is `VolStop` (per-trade
|
||||
varying R-denominator), so E[R] is not a mere rescaling of pip-expectancy.
|
||||
- **No silent MtM:** window-end open trades are explicit, flagged, and counted.
|
||||
- `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- Old `runs.jsonl` still deserialise (`#[serde(default)]` `r`, `serde(alias)` on the renamed flip field).
|
||||
|
||||
## Iteration scope
|
||||
|
||||
- **Commit 0 (head):** `exposure → bias` rename (#126) — behaviour-preserving,
|
||||
compiler-driven, its own commit.
|
||||
- **Iteration 1 (this iteration):** `VolStop` + `FixedStop` (#119) +
|
||||
`PositionManagement` (the dense R-record, latch + lagged-fill discipline,
|
||||
size-invariant R) + core `summarize_r`/`RMetrics` (E[R], n_trades, win_rate,
|
||||
max_r_drawdown, n_open_at_end) + the mandatory RED tests (1–7, 9). A complete,
|
||||
runnable, testable R-producing vertical slice
|
||||
(`source → bias → stop-rule → position-management → Recorder`, folded by
|
||||
`summarize_r`). **R-invariance is the load-bearing property pinned here.**
|
||||
- **Iteration 2:** the `Sizer` seam + the `RiskExecutor` composite (#128, Veto
|
||||
documented-not-built) + `summarize_r` enrichment (SQN, conviction terciles,
|
||||
net-of-cost) + `RunMetrics.r` + tests 8, 10. (The `RiskExecutor`/`vol_stop`
|
||||
composites land here as **integration-test fixtures**; the CLI/recording surface
|
||||
originally lumped here is split to Iteration 3.)
|
||||
- **Iteration 3 — the CLI surface (#129):** promote `vol_stop` + `risk_executor`
|
||||
to public `aura-engine` composite-builders; add an `aura run --harness <name>`
|
||||
selector and a `stage1-r` dual-tap harness; fold `summarize_r → RunMetrics.r` in
|
||||
the run path; tap the R-equity series for `aura chart`. Full design below.
|
||||
|
||||
## Iteration 3 — the CLI surface (#129)
|
||||
|
||||
Iteration 2 shipped the node + metric layer (the `Sizer`, the
|
||||
`RiskExecutor`/`vol_stop` composites as **integration-test fixtures**,
|
||||
`summarize_r`, `RunMetrics.r`). Iteration 3 makes that layer **operable from the
|
||||
command line** — the research loop's primary verb, ergonomic to drive from a shell
|
||||
(decision log: #117, user-delegated). Authoring stays in Rust (C9/C17/C22): the CLI
|
||||
*runs and inspects* a Rust-authored harness — it does **not** wire topology. The
|
||||
broad meta-level CLI (#109: project-as-crate, composable orchestration) is a
|
||||
separate future milestone, deliberately out of scope here.
|
||||
|
||||
### The user-facing program (the acceptance evidence)
|
||||
|
||||
What Claude (or a trader) types to score a strategy's signal quality in R:
|
||||
|
||||
```console
|
||||
$ aura run --harness stage1-r --real GER40 --from 1700000000000 --trace q1
|
||||
{"manifest":{ ... ,"broker":"risk-executor"},
|
||||
"metrics":{"total_pips":123.4,"max_drawdown": ... ,"exposure_sign_flips":7,
|
||||
"r":{"expectancy_r":0.42,"sqn":1.85,"n_trades":31,"win_rate":0.55,
|
||||
"avg_win_r":1.9,"avg_loss_r":-0.8,"profit_factor":1.7,
|
||||
"max_r_drawdown":3.2,"n_open_at_end":1,"net_expectancy_r":0.42,
|
||||
"conviction_terciles_r":[0.1,0.4,0.7]}}}
|
||||
|
||||
$ aura chart q1 --tap r_equity > r_curve.html # by-trade R-equity curve, existing renderer
|
||||
```
|
||||
|
||||
The R block rides in the existing `RunReport` JSON via `RunMetrics.r`
|
||||
(`#[serde(skip_serializing_if = "Option::is_none")]`), so it appears exactly when
|
||||
an R-producing harness ran and is absent otherwise — old `runs.jsonl` and pip-only
|
||||
runs stay byte-unchanged. JSON (not a pretty text block) is the surface: it is the
|
||||
most machine-ergonomic for Claude and is consumed unchanged by `runs family` and
|
||||
`chart`. `--real` / `--trace` compose orthogonally with `--harness`.
|
||||
|
||||
Two example details pinned: the pip key is `exposure_sign_flips` — the secondary
|
||||
change (a) renaming it to `bias_sign_flips` was **not** carried out (the field is
|
||||
still `exposure_sign_flips` in `report.rs`; the rename is deferred as a follow-on,
|
||||
out of iter-3 scope), so the example uses the as-built key. And
|
||||
`net_expectancy_r == expectancy_r` here because the run path folds with
|
||||
`round_trip_cost = 0.0` — Stage-1 R is frictionless signal quality (costs are the
|
||||
Stage-2 realistic-broker concern, C10 A-side); a `--cost <price-units>` knob that
|
||||
threads a nonzero cost into the existing `summarize_r` param is a deliberate
|
||||
follow-on, not iter-3.
|
||||
|
||||
The `stage1-r` harness wires the **same bias stream into both** a `SimBroker` (the
|
||||
existing pip curve) **and** the `RiskExecutor` (the new R outcomes), so one report
|
||||
carries both yardsticks honestly — a direct pip-vs-R comparison of one signal, no
|
||||
meaningless zeros — and the diff from the shipped pip `sample_harness` is exactly
|
||||
the added risk branch.
|
||||
|
||||
### Before → after: the load-bearing changes (secondary)
|
||||
|
||||
**(f) `aura run --harness <name>` selector (`aura-cli/src/main.rs`).** Today `run`
|
||||
is dispatched by an **exhaustive literal-slice match** (`["run"]`,
|
||||
`["run","--macd"]`, `["run","--trace",n]`, `["run","--macd","--trace",n]`,
|
||||
`["run","--real", rest@..]`) — there is no `run` tail parser, so the orthogonal
|
||||
flag composition the worked example needs (`--harness` + `--real` + `--from` +
|
||||
`--trace` in one call) is unreachable as-is. iter-3 therefore **replaces the `run`
|
||||
literal arms with a `parse_run_args` tokenizer** (the same shape as the existing
|
||||
`parse_real_args` / `parse_chart_args` helpers): it accepts `--harness <name>`,
|
||||
`--macd`, `--real <SYMBOL>`, `--from <ms>`, `--to <ms>`, `--trace <name>` **in any
|
||||
order**, each at most once. Semantics: `--harness <name>` resolves a
|
||||
**compile-time** `match name { "sma" => …, "macd" => …, "stage1-r" => … }` over
|
||||
Rust-authored built-ins — a fixed enumeration, **not** a runtime node registry and
|
||||
**not** a DSL (C9/C17); `--harness sma` is the default (today's bare `run`
|
||||
behaviour); `--macd` is a back-compat alias for `--harness macd` (mutually
|
||||
exclusive with `--harness`); `--from`/`--to` are legal only with `--real`. Every
|
||||
invocation the current literal arms accept must still parse identically (the
|
||||
existing `cli_run` tests stay green); the tokenizer only *adds* the
|
||||
`--harness`×`--real`×`--trace` combinations.
|
||||
|
||||
**(g) Promote `vol_stop` + `risk_executor` to shippable composite-builders
|
||||
(`aura-engine`).** Both exist today only as integration-test fixtures
|
||||
(`tests/vol_stop_composite.rs`, `tests/risk_executor.rs`); iter-3 lifts them to
|
||||
public `aura-engine` functions (composites need `GraphBuilder`, which `aura-std`
|
||||
lacks) so the CLI harness can wire them. Behaviour preserved: each public fn IS the
|
||||
fixture's body verbatim (`vol_stop` as-is; `risk_executor`'s internal
|
||||
`stop-rule → Sizer → PositionManagement` shape kept, the embedded stop generalized
|
||||
from a hardcoded `FixedStop` to the `StopRule` match); the two test fixtures each
|
||||
**delete their local `fn`** and import + call the `aura_engine::` public fn (the
|
||||
`ConstLongBias` producer stays local to the test). A new `aura-engine` module
|
||||
(e.g. `src/composites.rs`, re-exported from `lib.rs`) is their home.
|
||||
|
||||
```rust
|
||||
// aura-engine — new public API, promoted from the test fixtures:
|
||||
pub fn vol_stop(length: i64, k: f64) -> Composite; // k·√EMA(Δ²); role: price → stop_distance
|
||||
pub enum StopRule { Fixed(f64), Vol { length: i64, k: f64 } } // the stop axis (C11 structural)
|
||||
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite;
|
||||
// roles: bias + price; embeds the chosen stop-rule (FixedStop and vol_stop BOTH expose
|
||||
// price → stop_distance, so the `match` arm is the only difference); price fans to the
|
||||
// stop-rule + position-management; exposes the dense PM R-record. The fixture's
|
||||
// `risk_executor(d, b)` becomes `risk_executor(StopRule::Fixed(d), b)` — its tests carry
|
||||
// over unchanged; the `stage1-r` harness passes `StopRule::Vol { length: 20, k: 2.0 }`,
|
||||
// the volatility-defined default.
|
||||
```
|
||||
|
||||
**(h) The `stage1-r` harness + the R fold in the run path (`aura-cli`).** A
|
||||
`stage1_r_harness()` builder reuses the SMA-cross→`Bias` signal and fans the **one**
|
||||
in-graph `bias` output to **three** consumers: `SimBroker` (pip equity), the
|
||||
`risk_executor` (the dense R-record), and the existing exposure tap. **Pip metrics
|
||||
are unchanged** — `summarize(equity, exposure)` reads the `SimBroker` equity tap and
|
||||
the `Bias` exposure tap exactly as today's `sample_harness` does (this is the
|
||||
already-tested output fan-out in `macd_strategy_blueprint`). **R metrics are the new
|
||||
path** — the run handler drains the `risk_executor`'s dense R-record tap (a third,
|
||||
independent tap), folds `summarize_r(&ledger, /*round_trip_cost*/ 0.0)` → `RMetrics`,
|
||||
and sets `RunMetrics.r = Some(..)`. The fold runs only for an R-producing harness;
|
||||
`--harness sma`/`macd` leave `RunMetrics.r = None` (the `skip_serializing_if` keeps
|
||||
their JSON byte-unchanged).
|
||||
|
||||
**(i) The R-equity tap + its persistence.** The harness sums the executor's
|
||||
`cum_realized_r` + `unrealized_r` outputs through a `LinComb([1,1])` into an
|
||||
`r_equity` series and taps it. Today `persist_traces(name, manifest, equity, exposure)`
|
||||
is hardwired to exactly **two** taps; iter-3 extends it to thread the **third**
|
||||
`r_equity` tap (a signature + drain-plumbing change — the one place this is *not*
|
||||
free). The decimation needs no new arm: `reduce_for_tap` already defaults every
|
||||
non-`exposure` tap to MinMax, which suits an equity-like curve (the same reducer
|
||||
`equity` uses). The chart **viewer** (uPlot/HTML) is genuinely unchanged: `aura
|
||||
chart <name> --tap r_equity` resolves the named tap through the existing
|
||||
`filter_to_tap` path. So "no new code" holds for the *renderer*; the persist helper
|
||||
is the bounded plumbing change.
|
||||
|
||||
### Components (iter-3)
|
||||
|
||||
| Component | Crate / file |
|
||||
|-----------|--------------|
|
||||
| `vol_stop`, `risk_executor` + `StopRule` promoted to public composite-builders | `aura-engine` (a new `composites` module, re-exported from `lib.rs`; the two fixtures delete their local `fn` and call these) |
|
||||
| `parse_run_args` tokenizer (replaces the `run` literal-slice arms) + `--harness <name>` selector + `stage1_r_harness()` + the R fold in the run path | `aura-cli/src/main.rs` |
|
||||
| `r_equity` tap wiring + `persist_traces` extended to a third tap | `aura-cli/src/main.rs` (the harness builder + the persist helper) |
|
||||
|
||||
### Testing strategy (iter-3)
|
||||
|
||||
- **CLI smoke:** `aura run --harness stage1-r` (synthetic) emits a `RunReport`
|
||||
whose `metrics.r` is `Some` with a finite `sqn` / `expectancy_r` and
|
||||
`n_trades ≥ 1` — a `cli_run`-suite integration test (the existing home for CLI
|
||||
tests).
|
||||
- **Selector:** `--harness sma` ≡ today's default output; `--harness macd` ≡ the
|
||||
`--macd` output (the alias holds); an unknown `--harness x` exits non-zero with a
|
||||
usage error.
|
||||
- **Flag composition (the new tokenizer):** every invocation the old literal arms
|
||||
accepted (`run`, `run --macd`, `run --trace t`, `run --real <SYM>`) still parses
|
||||
identically; the new combination `run --harness stage1-r --real <SYM> --trace t`
|
||||
(which no literal arm could express) parses and runs; `--macd` together with
|
||||
`--harness` is rejected, as is `--from` without `--real`.
|
||||
- **Additive back-compat:** a pip run (`--harness sma`) emits **no** `r` key
|
||||
(`skip_serializing_if`); the existing legacy-`runs.jsonl` deserialise test stays
|
||||
green.
|
||||
- **R-equity round-trip:** `aura run --harness stage1-r --trace t` then
|
||||
`aura chart t --tap r_equity` produces non-empty HTML over the R-equity series.
|
||||
- **Promotion non-regression:** the `vol_stop_composite` and `risk_executor`
|
||||
fixture tests stay green after they are rewired to call the promoted public fns.
|
||||
|
||||
### Acceptance criteria (iter-3)
|
||||
|
||||
- Claude can run a Stage-1 R backtest and read its signal-quality metrics in **one
|
||||
shell call** (`aura run --harness stage1-r [--real …]`) — the R block in the
|
||||
`RunReport` JSON.
|
||||
- The CLI **runs** a Rust-authored harness; it does not author or wire topology
|
||||
(C9/C17/C22 intact — compile-time selector, no registry, no DSL).
|
||||
- Pip-only and legacy runs are byte-unchanged on disk (`r` absent when no
|
||||
R-record); determinism (C1) untouched.
|
||||
- `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
Reference in New Issue
Block a user