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:
2026-06-24 13:13:58 +02:00
parent c6c1d3bb10
commit c406b969f5
6 changed files with 0 additions and 3682 deletions
-535
View File
@@ -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·(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` + 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.