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,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