spec: 0065 stage1-r-signal-quality (boss-signed)

Stage-1 "R-based signal quality" cycle spec (milestone #119/#126/#127/#128/#129,
contract C10). Signal quality measured in R on a strategy's unsized bias stream,
feed-forward (no equity feedback): bias -> stop-rule -> position-management ->
per-trade R-outcomes -> summarize_r fold -> E[R]/SQN.

Design adversarially hardened (12-juror refute panel; decisions logged on #117):
- R is stop-defined and LATCHED at entry (frozen R-denominator, never re-read).
- Stop-outs are NOT capped at -1R (honest loss tail; gap-through -> R < -1).
- Fills one-cycle-lagged (SimBroker no-look-ahead discipline; RED test mandatory).
- First stop is volatility-scaled (VolStop = k * EMA(|d price|)), not constant
  (a constant makes R just rescaled pips).
- position-management emits a dense per-cycle R-record (trade ledger + R-equity +
  window-end handling), tapped by a Recorder, folded post-run by summarize_r
  (a post-run fold, NOT an in-graph node).
- Sizer = load-bearing flat-1R seam; R computed size-invariantly. Veto = doc seam,
  not a runtime node (DCE deletes an identity).
- exposure -> bias rename lands first (compiler-driven).

Grounding-check PASS (11 assumptions ratified by green tests; auto-signed under /boss).

refs #117
This commit is contained in:
2026-06-23 17:32:05 +02:00
parent f040b66f30
commit 51c87e5a91
+351
View File
@@ -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<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) `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<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.
// 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<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·(exitentry)/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·(exitentry)/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 (17, 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.