diff --git a/docs/specs/0065-stage1-r-signal-quality.md b/docs/specs/0065-stage1-r-signal-quality.md new file mode 100644 index 0000000..6d77031 --- /dev/null +++ b/docs/specs/0065-stage1-r-signal-quality.md @@ -0,0 +1,351 @@ +# Stage-1 R-based signal quality — Design Spec + +**Date:** 2026-06-23 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude +**Cycle:** 0065 — milestone "R-based signal quality (Stage 1)" +**Issues:** #119 (stop-rule / R), #126 (exposure → bias), #127 (Sizer + position-management), #128 (RiskExecutor + Veto seam), #129 (R-evaluator). Reference / decision log: #117. +**Contract:** ledger C10 (reframed, commit `f040b66`). + +## Goal + +Make a strategy's **signal quality measurable in R** — the account- and +instrument-agnostic Van-Tharp yardstick *"how much R out per 1R risked?"* — as a +**feed-forward** (no equity feedback) downstream layer on a strategy's **bias** +stream. This is Stage 1 of C10: the primary research loop, maximally parallel and +deterministic (C1). Currency P&L, compounding, and realistic brokers are Stage 2 +(a later milestone), entered only once `E[R] > 0`. + +Concretely the cycle delivers: the `exposure → bias` rename (the unsized strategy +output); a **stop-rule** node family that emits a protective-stop *distance* +(which **defines 1R**); a **position-management** node that turns bias + stop into +a stream of realised **per-trade R-outcomes**; a flat-1R **Sizer** seam; a +per-symbol **RiskExecutor** composite; and a post-run **`summarize_r`** fold that +reduces the trade ledger into R-metrics (E[R], SQN, win-rate, …) plus a by-trade +R-equity curve. + +This is **new discrete-trade machinery**, not an extension of `SimBroker`. +`SimBroker` integrates a *continuous* `exposure · price-return` into a pip curve — +no discrete trade, no stop, no risk unit; it is the ancestor *in spirit only* (a +downstream node that produces a quality curve) and is retained unchanged as the +pre-reframe pip-quality node. + +## Architecture + +The Stage-1 chain, all ordinary downstream nodes (C8/C9), feed-forward: + +``` +bias (f64 ∈ [-1,+1], unsized) # renamed from exposure; sign=direction, |·|=conviction + │ price (f64) + ▼ │ +stop-rule ──► stop_distance (f64 ≥ 0) # VolStop = k·EMA(|Δprice|), or FixedStop(d) + │ │ + │ ┌───────┴──────────────┐ + ▼ ▼ ▼ +[Sizer] ──► size (f64) position-management ──► dense per-cycle R-record + (iter-2 seam) │ (the trade ledger + R-equity) + ▼ + Recorder (tap) ──drain post-run──► summarize_r ──► RMetrics + R-curve +``` + +- **stop-rule** is a pure per-cycle function of price (and its own volatility + state). It emits a **direction-agnostic stop *distance*** in price units, never + an absolute level — so it is reusable long or short and `R` is a dimensionless + ratio. Two concrete nodes ship: `VolStop` (volatility-scaled, the meaningful + default) and `FixedStop` (constant, the test fixture / structural-axis sibling). +- **position-management** is the stateful heart. It opens on a nonzero bias, + **latches the entry-cycle stop distance once as the immutable R-denominator**, + tracks the stop level, marks the position against the **one-cycle-lagged fill** + (the `SimBroker` no-look-ahead discipline), detects exits (stop / bias-flip / + reversal), and emits a **dense per-cycle R-record** carrying both the running + R-equity and, when a trade closes that cycle, its realised R-outcome. +- **Sizer** (iter-2) is a load-bearing seam: `size = risk_budget / stop_distance` + (flat-1R), reading bias + stop_distance so the interface is genuinely exercised; + Stage 2 swaps `risk_budget` for `risk_fraction · equity`, same shape. The + R-outcome is computed **size-invariantly**, so the Sizer never contaminates R. +- **RiskExecutor** (iter-2) is a per-symbol `Composite` bundling + `stop-rule → [Sizer] → position-management` with `bias` + `price` as input roles + (price fanned to both stop-rule and position-management). The **Veto** is a + *documented seam only* (a named position in this spec, **not** a runtime node) — + a pass-through identity is exactly what C19/C23 DCE is licensed to delete. +- **`summarize_r`** is a **post-run fold** (sibling of `summarize`), **not** an + in-graph node: recorder taps are drained post-run (`rx.try_iter().collect()`; + the playground is web-from-disk), so an in-graph R-curve node would buy nothing, + double-maintain state, and carry a broken sparse time axis. The in-graph + running-equity node is reserved for Stage 2 (compounding through the z⁻¹ + fill-edge register). + +**Why a dense R-record (not a sparse `Some`-on-close / `None` stream).** A single +net position closes ≤1 trade per cycle, so a sparse stream would be C8-legal — but +it cannot express the **R-equity curve / max-drawdown-in-R** (no intra-trade mark) +and cannot reveal a position **still open at window end** (it only carries closes). +A dense record (one row per cycle, always `Some`, exactly like `SimBroker`) carries +a `closed_this_cycle` flag: the **trade ledger** is the subset of rows where it is +set; the **R-equity** is the dense `cum_realized_r + unrealized_r`; the +**window-end** open trade is the last row with `open = true`. ≤1 record per eval +still holds (C8), and the asymmetry with the position-event table is intact (a +reversal is *two* book events at one ts but only *one* close, so the table needs +>1/instant while this record needs only one). + +The dense **multi-field** output follows the `Resample` precedent +(`crates/aura-std/src/resample.rs`, `out: [Cell; 4]`, four `FieldSpec`s — an OHLC +bundle consumed and recorded end-to-end in the GER40 breakout tests), so it is on a +**tested runtime path**. Issue #47's untested-ness concerns only the CLI graph +*re-export rendering* of such a producer inside a composite — orthogonal to the +recording path this cycle uses. + +## Concrete code shapes + +### The user-facing program (the acceptance evidence) + +What a strategy author writes to measure a bias strategy's signal quality in R +(mirrors the existing `ger40_breakout_real` harness shape — author a composite, +bootstrap, run, drain, fold): + +```rust +use aura_std::{Bias, VolStop, PositionManagement, Recorder}; +use aura_engine::{GraphBuilder, report::summarize_r}; + +// A per-symbol risk-based executor over a bias stream (iter-2 ships this as the +// reusable `RiskExecutor` composite; shown here wired by hand for clarity). +let mut g = GraphBuilder::new("breakout_R"); +let bias = g.input_role("bias"); // the strategy's unsized output, f64 ∈ [-1,+1] +let price = g.input_role("price"); // the instrument price + +let stop = g.add(VolStop::builder(/*length*/ 20, /*k*/ 2.0)); // k·EMA(|Δprice|) → stop_distance +let pm = g.add(PositionManagement::builder()); // bias + price + stop_distance → R-record +let rtap = g.add(Recorder::builder(PositionManagement::RECORD_KINDS.into(), Firing::Any, tx)); + +g.feed(price, [stop.input("price"), pm.input("price")]); // price fans out +g.feed(bias, [pm.input("bias")]); +g.connect(stop.output("stop_distance"), pm.input("stop_distance")); +for (i, field) in PositionManagement::FIELD_NAMES.iter().enumerate() { + g.connect(pm.output(field), rtap.input(&format!("col[{i}]"))); // tap each field of the dense R-record +} +let exec = g.build().expect("RiskExecutor wires"); + +// ... bootstrap `exec` with the bias-producing strategy + a price source, run ... +let ledger = drain(&rtap); // Vec<(Timestamp, Vec)> +let r: RMetrics = summarize_r(&ledger); // the post-run fold + +println!("E[R] = {:+.3}", r.expectancy_r); // how much R out per 1R risked +println!("SQN = {:.2}", r.sqn); // dispersion-adjusted quality (sweep objective) +println!("trades = {} (win {:.0}%, {} open@end)", r.n_trades, 100.0*r.win_rate, r.n_open_at_end); +``` + +The criterion's evidence: a trader-author reaches for exactly this — "score my +strategy in R" — and gets a risk-normalised, account-agnostic number that pips +cannot give. It introduces no look-ahead (the lagged-fill discipline + a RED test +enforce C2) and no feedback (flat-1R is feed-forward, C1). + +### Before → after: the load-bearing changes (secondary) + +**(a) `exposure → bias` rename (#126, commit-0, behaviour-preserving).** + +```rust +// before: crates/aura-std/src/exposure.rs +pub struct Exposure { scale: f64, out: [Cell; 1] } // node "Exposure", output field "exposure" +// after: crates/aura-std/src/bias.rs +pub struct Bias { scale: f64, out: [Cell; 1] } // node "Bias", output field "bias" +// computation UNCHANGED: clamp(signal / scale, -1, +1). SEMANTICS change: the output is the +// UNSIZED strategy bias; sizing leaves the strategy (moves downstream to the Sizer). +``` +Scope: the `Bias` node (type, file, node-name string, output field `exposure`→`bias`, +`label()`), `SimBroker`'s slot-0 port name + doc, the `aura-std` re-export, and all +example/test wiring. **Data back-compat:** the persisted `RunMetrics.exposure_sign_flips` +field is renamed to `bias_sign_flips` **with `#[serde(alias = "exposure_sign_flips")]`** so +historical `runs.jsonl` still deserialises (the planner pins the exact sites; ~311 +`exposure` / 82 `Exposure` references across `crates/`). + +**(b) `VolStop` + `FixedStop` stop-rule nodes (#119) — new, `aura-std`.** + +```rust +// VolStop: one fused node (no Abs/Mul primitive exists, so abs+delta+EMA must fuse). +pub struct VolStop { length: usize, k: f64, prev_price: Option, ema: f64, comp: f64, count: usize, out: [Cell; 1] } +// eval: d = |price - prev_price|; ema = EMA_length(d) (Kahan, like Sma); out = k * ema. +// None until warm (count < length). Input "price" (f64, Any). Output "stop_distance" (f64). +// Params length:i64, k:f64. prev_price updated AFTER use (intra-node z⁻¹, C2-clean). +pub struct FixedStop { distance: f64, out: [Cell; 1] } // out = distance, constant; test fixture + axis sibling +``` + +**(c) `PositionManagement` node (#127) — new, `aura-std`. The dense R-record.** + +```rust +pub struct PositionManagement { + // open-position state (None = flat): + pos: Option, // { dir: i64, entry_price, latched_dist, stop_level, entry_ts, bias_abs } + prev_price: Option, // the fill held INTO this cycle (SimBroker discipline) + cum_realized_r: f64, // running closed-trade R + out: [Cell; PositionManagement::WIDTH], +} +// Inputs (order load-bearing, all f64/Any): 0 bias, 1 price, 2 stop_distance. +// Output: ONE dense record per cycle (C8), columns (RECORD_KINDS): +// 0 closed_this_cycle : Bool +// 1 realized_r : F64 // valid iff closed_this_cycle; else 0.0 +// 2 exit_reason : I64 // ExitReason enum: 0=stop 1=bias-flip 2=reversal-leg 3=window-end +// 3 was_stopped : Bool +// 4 direction : I64 // -1 / +1 of the closed (or open) trade +// 5 entry_ts : Timestamp +// 6 entry_price : F64 +// 7 stop_price : F64 +// 8 exit_price : F64 +// 9 bias_at_entry_abs : F64 // conviction, for the calibration diagnostic +// 10 size : F64 // from the Sizer (iter-2); 1.0 placeholder in iter-1 +// 11 open : Bool // a position is open AFTER this cycle (window-end detection) +// 12 unrealized_r : F64 // mark of the open position (0.0 when flat) → dense R-equity +// 13 cum_realized_r : F64 // running closed R → by-trade R-equity = cum_realized + unrealized +// r_multiple is debug-asserted == direction * (exit - entry) / latched_dist. +``` + +`ExitReason` mirrors `PositionAction`'s i64-tagged-enum pattern (`#[serde(into="i64", +try_from="i64")]`, checked `TryFrom`). + +**(d) `summarize_r` fold + `RMetrics` (#129) — new, `aura-engine/src/report.rs`.** + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RMetrics { + pub expectancy_r: f64, // mean realised R over closed trades (equal-weighted; headline) + pub n_trades: u64, + pub win_rate: f64, + pub avg_win_r: f64, + pub avg_loss_r: f64, + pub profit_factor: f64, // sum(win R) / |sum(loss R)| + pub sqn: f64, // √n · mean_R / stdev_R — dispersion-adjusted quality, the sweep objective + pub max_r_drawdown: f64, // max peak-to-trough on the by-trade R-equity (cum_realized_r), ≥ 0 + pub n_open_at_end: u64, // positions force-closed at window-end (counted, not hidden) + pub net_expectancy_r: f64, // E[R] minus one round-trip spread cost (price-unit param) — churn-honest + pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile — conviction calibration +} +// Reduces the dense R-record stream: filters closed_this_cycle rows for the ledger; the last row's +// `open`/`unrealized_r` synthesises a window-end forced close (exit_reason=window-end). Pure (C1). +pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> RMetrics { /* … */ } + +// RunMetrics gains an optional R block, back-compat for old runs.jsonl: +pub struct RunMetrics { /* … existing pip fields … */ #[serde(default)] pub r: Option } +``` + +**(e) `Sizer` seam + `RiskExecutor` composite (#127/#128, iter-2).** + +```rust +pub struct Sizer { risk_budget: f64, out: [Cell; 1] } +// eval: out = risk_budget / stop_distance (risk_budget=1 ⇒ flat-1R; Stage-2: risk_fraction·equity) +// Inputs: bias (direction), stop_distance. Output "size" (f64). +// RiskExecutor: a GraphBuilder Composite, per symbol, input roles bias+price, exposes the R-record. +// Veto: NOT a node — a documented seam (added as a real gating edge the cycle it first vetoes). +``` + +## Components + +| Component | Crate / file | Iter | +|-----------|--------------|------| +| `Bias` (rename of `Exposure`) | `aura-std/src/bias.rs` | 0 | +| `VolStop`, `FixedStop` | `aura-std/src/stop_rule.rs` (or `vol_stop.rs`/`fixed_stop.rs`) | 1 | +| `PositionManagement` + `ExitReason` | `aura-std/src/position_management.rs` | 1 | +| `summarize_r` + `RMetrics` (core: E[R], n_trades, win_rate, max_r_drawdown, n_open_at_end) | `aura-engine/src/report.rs` | 1 | +| `Sizer` | `aura-std/src/sizer.rs` | 2 | +| `RiskExecutor` composite builder | `aura-engine` (or an `aura-std` composite helper) | 2 | +| `summarize_r` enrichment (SQN, conviction terciles, net-of-cost) + `RunMetrics.r` + CLI/recording | `aura-engine`, `aura-cli` | 2 | + +## Data flow (one cycle, position-management) + +1. Read current `price` (slot 1), `bias` (slot 0), `stop_distance` (slot 2). +2. **If a position is open:** mark P&L against `prev_price` (the fill held *into* + this cycle — never the same-cycle price the stop is tested against). Test the + stop: did price reach the latched `stop_level`? (close-only: tested at the + close, a **documented optimistic bias**; with a resampled high/low, tested + against the adverse extreme). If stopped → exit fill = **no better than the + stop level** (gap-through ⇒ `R < -1`, the honest loss tail — never capped at + −1). Else if bias exited (sign → 0 or flipped) → exit at the lagged fill. + Compute `realized_r = dir·(exit − entry)/latched_dist`, set the close columns. +3. **Reversal:** an open long with bias flipping short (or vice-versa) closes the + current leg (one R-outcome, `exit_reason = reversal-leg`) **and** reopens the + opposite side as state (the reopen is not an outcome — ≤1 close/cycle holds). +4. **If flat and bias nonzero:** open — latch `entry_price` (the lagged fill), + `dir = sign(bias)`, `latched_dist = stop_distance` (frozen for the trade — the + R-unit is **never re-latched mid-trade**), `stop_level = entry ∓ latched_dist`, + `bias_at_entry_abs = |bias|`. +5. Emit the dense record (open/unrealized/cum fields always; close fields when + `closed_this_cycle`). Update `prev_price` **after** taking the outcome (C2). + +Post-run: drain the Recorder tap → `summarize_r(ledger, round_trip_cost)` → +`RMetrics`; the last row's `open`/`unrealized_r` is force-closed as a window-end +trade. The whole chain is feed-forward (position state is **intra-node** struct +state like `Latch.held` / `SimBroker.prev_*` — not a graph edge, so it needs no +back-edge support and does not trip the topo-sort cycle rejection). + +## Error handling + +- Builder asserts mirror the existing nodes: `VolStop` `length ≥ 1` & `k > 0`, + `FixedStop` `distance > 0`, `Sizer` `risk_budget > 0` (panic at construction — + these are authoring bugs, surfaced like `Exposure scale must be > 0`). +- Input-order is load-bearing and all-`f64`/`i64` (a swapped wiring is not + kind-caught) — documented per slot, exactly as `SimBroker` documents its + `exposure`/`price` order. +- `summarize_r` over an empty ledger returns a well-defined zero `RMetrics` + (`expectancy_r = 0`, `n_trades = 0`, `sqn = 0`, …), never a NaN/panic; + `profit_factor` with no losses is reported as a sentinel (e.g. `f64::INFINITY` + documented, or `0.0` when no trades). +- A `debug_assert!` ties `realized_r` to `dir·(exit−entry)/latched_dist` + (the record's one redundancy, guarded — mirrors `PositionEvent`'s checked + round-trip). + +## Testing strategy + +RED-first where behaviour is test-specifiable. Mandatory tests: + +1. **No look-ahead (the keystone RED test), parallel to `sim_broker_no_lookahead`:** + a bias-flip-exit trade's realised R equals the **lagged-fill** R; a stopped + trade is exactly −1R **in the no-gap case** independent of the fill price. +2. **Stop tail is not capped:** a gap-through-stop trade realises `R < -1` + (fill no better than the stop), not −1 — the loss-tail-truncation guard. +3. **R-invariance under size:** scaling the Sizer's `size` leaves every + `realized_r` unchanged (the property that keeps Stage 1 feed-forward). +4. **flat-1R invariant:** `Sizer` output satisfies `size · stop_distance ≡ risk_budget`. +5. **C8 / one-close-per-cycle:** a reversal cycle sets `closed_this_cycle` once + (one R-outcome) and reopens as state; the record is one row. +6. **Window-end:** a position open at the last cycle is force-closed + (`exit_reason = window-end`), counted in `n_open_at_end`, and **not** silently + folded as unrealised MtM. +7. **`VolStop`** warm-up (`None` until `length`), Kahan stability, `= k·EMA(|Δ|)`; + **`FixedStop`** constant. +8. **`summarize_r`** arithmetic: E[R], win-rate, profit-factor, **SQN** + (`√n·meanR/stdevR`), `max_r_drawdown` on a hand-built ledger; conviction + terciles separate a conviction-calibrated synthetic strategy from a flat one; + net-of-cost subtracts one round-trip spread; empty-ledger zero case. +9. **Bias rename:** the existing `Exposure` tests carry over green as `Bias` + (behaviour-preserving); old `runs.jsonl` with `exposure_sign_flips` still + deserialises via the serde alias. +10. **E2E:** a synthetic bias + price chain through the RiskExecutor produces a + non-empty trade ledger and a sane `RMetrics` (a known-edge synthetic strategy + yields `E[R] > 0`, a coin-flip yields `E[R] ≈ 0`). + +## Acceptance criteria + +- A strategy author can wire `bias → stop-rule → position-management`, run, and + fold an `RMetrics` reporting **E[R] and SQN** — the worked example above runs. +- R is **stop-defined and size-invariant**: `realized_r = dir·(exit−entry)/latched_dist`, + the latched distance frozen at entry; scaling size does not change any R. +- **No look-ahead, no feedback:** the keystone RED test passes; the chain is + feed-forward (no graph cycle, no equity read). +- **Honest loss tail:** stop-outs are not capped at −1R; gap-through realises `R < -1`. +- **Volatility-defined R, not pips:** the default stop is `VolStop` (per-trade + varying R-denominator), so E[R] is not a mere rescaling of pip-expectancy. +- **No silent MtM:** window-end open trades are explicit, flagged, and counted. +- `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean. +- Old `runs.jsonl` still deserialise (`#[serde(default)]` `r`, `serde(alias)` on the renamed flip field). + +## Iteration scope + +- **Commit 0 (head):** `exposure → bias` rename (#126) — behaviour-preserving, + compiler-driven, its own commit. +- **Iteration 1 (this iteration):** `VolStop` + `FixedStop` (#119) + + `PositionManagement` (the dense R-record, latch + lagged-fill discipline, + size-invariant R) + core `summarize_r`/`RMetrics` (E[R], n_trades, win_rate, + max_r_drawdown, n_open_at_end) + the mandatory RED tests (1–7, 9). A complete, + runnable, testable R-producing vertical slice + (`source → bias → stop-rule → position-management → Recorder`, folded by + `summarize_r`). **R-invariance is the load-bearing property pinned here.** +- **Iteration 2:** the `Sizer` seam + the `RiskExecutor` composite (#128, Veto + documented-not-built) + `summarize_r` enrichment (SQN, conviction terciles, + net-of-cost) + `RunMetrics.r` + the CLI/recording surface (#129) + tests 8, 10.