diff --git a/docs/specs/0007-signal-quality-loop.md b/docs/specs/0007-signal-quality-loop.md new file mode 100644 index 0000000..f9d6b65 --- /dev/null +++ b/docs/specs/0007-signal-quality-loop.md @@ -0,0 +1,297 @@ +# Signal-quality loop — exposure stream + sim-optimal broker — Design Spec + +**Date:** 2026-06-04 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Make the substrate produce its first trading result: backtest the **quality of a +signal**. A strategy DAG emits an **intent / exposure stream** (one bounded +signed `f64 ∈ [-1, +1]` per cycle); a **sim-optimal broker** integrates +`exposure · price-return` into a synthetic **pip-equity** curve that measures the +signal's quality — not an execution-modelled P&L. This is the primary research +loop: author a signal, backtest its quality, later combine signals and backtest +the combination. + +This cycle bundles Gitea **#4** (the exposure node) and **#5** (the sim-optimal +broker): the two halves of one indivisible deliverable — neither alone reaches +"backtest a signal's quality". It **realizes the C10 reframe** already recorded +in the ledger (`docs/design/INDEX.md`): the strategy's primary output is the +exposure stream; the broker-independent position-event table is a decoupled, +derived position-management layer (deferred to a later cycle, for realistic +brokers / deploy). Closes #4 and #5. + +### Why the reframe (the C8↔C10 impedance) + +The DAG is a synchronous reactive graph: at time t it holds exactly one state, +and a node emits **at most one record per `eval`** (C8). A *sequence of position +events* — where one decision instant (a stop-and-reverse) needs a `close` **and** +an `open` at the same `event_ts` — cannot be the DAG's per-cycle output without +violating C8. The state the DAG *can* express faithfully is the **desired +exposure** (one value per cycle); the buy/sell/close events are its first +difference, a derived consequence. So the exposure stream is the strategy output, +and the position-event table is a downstream derived layer (C10, reframed). + +## Architecture + +Two new `aura-std` nodes, composed with the existing `Sma` / `Sub` into a +runnable signal-quality harness on the existing engine. The engine +(`aura-core` / `aura-engine`) stays **domain-free** — it sees only `f64` records, +never "exposure" or "equity". + +1. **`Exposure { scale }`** (`aura-std`) — the decision/sizing node of C10's + chain `signals → decision/sizing node → exposure stream`. One `f64` input (a + raw signal score), one `f64` output: `clamp(signal / scale, -1.0, +1.0)`. + Position sizing / risk live here: `scale` sets which signal magnitude maps to + full exposure. `None` until its input is present (warm-up filter, C8). + +2. **`SimBroker { pip_size }`** (`aura-std`) — class (a) of C10: deterministic, + frictionless, perfect-fill. Two `f64` inputs (slot 0 = exposure, slot 1 = + price), one `f64` output (cumulative pip equity). It holds `prev_price`, + `prev_exposure`, `cum`, and `pip_size` (per-instrument reference metadata + beside the hot path, C7/C15 — never streamed). Each fired cycle it realizes + the return earned by the exposure **held into** the cycle (decided at t-1) and + accumulates it; it is a producer (emits `cum` every fired cycle) — a + downstream consumer sink taps the equity to record a displayable trace + (cycle 0006 / C22). + +No engine change: `Harness::bootstrap` / `run`, `Ctx`, `Node`, `Edge`, +`SourceSpec`, `Scalar` are all used verbatim. This cycle is pure node authoring +on the frozen substrate. + +## Concrete code shapes + +### User-facing program (the worked example, = the acceptance evidence) + +Backtest the *quality* of a moving-average-cross signal: `SMA(2) − SMA(4)` → +exposure ∈ [-1,+1] → frictionless pip equity. `Recorder` is the cycle-0006 +test-local sink fixture (a node with `output: vec![]` that sends `(now, row)` to +a channel) — it stands in for a real recording sink; nothing in the engine +surface is a `Sink`. + +```rust +use std::sync::mpsc; +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::{Exposure, Sma, SimBroker, Sub}; + +let (tx_eq, rx_eq) = mpsc::channel(); + +let mut h = Harness::bootstrap( + vec![ + Box::new(Sma::new(2)), // 0 fast + Box::new(Sma::new(4)), // 1 slow + Box::new(Sub::new()), // 2 raw signal = fast - slow + Box::new(Exposure::new(0.5)), // 3 intent = clamp(sig/0.5, -1, +1) + Box::new(SimBroker::new(0.0001)), // 4 exposure*return -> pip equity + Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 records equity + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: 0, slot: 0 }, // price -> SMA fast + Target { node: 1, slot: 0 }, // price -> SMA slow + Target { node: 4, slot: 1 }, // price -> broker price input (return needs price) + ], + }], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast -> Sub.0 + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow -> Sub.1 + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Exposure + Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure -> broker.0 + Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> recorder + ], +) +.expect("valid signal-quality harness"); + +h.run(vec![price_stream(&[(1, 1.0000), (2, 1.0010), (3, 1.0025), (4, 1.0020), (5, 1.0040)])]); + +let equity: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); +// `equity` is the cumulative pip-equity curve of the MA-cross SIGNAL — its +// quality, not an execution-modelled strategy P&L. +``` + +### `Exposure` (the implementation shape — supporting) + +```rust +pub struct Exposure { scale: f64, out: [Scalar; 1] } + +impl Exposure { + pub fn new(scale: f64) -> Self { + assert!(scale > 0.0, "Exposure scale must be > 0"); + Self { scale, out: [Scalar::F64(0.0)] } + } +} + +impl Node for Exposure { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], + output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }], + } + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let w = ctx.f64_in(0); + if w.is_empty() { return None; } // not yet warmed up (C8 filter) + self.out[0] = Scalar::F64((w[0] / self.scale).clamp(-1.0, 1.0)); + Some(&self.out) + } +} +``` + +### `SimBroker` (the implementation shape — the causal integration) + +No-look-ahead (C2): the exposure held over `[t-1, t]` (decided at t-1) earns the +return into t; so the PnL uses `prev_exposure`, updated to the current exposure +only **after** the PnL is taken. + +```rust +pub struct SimBroker { + pip_size: f64, + prev_price: Option, + prev_exposure: f64, // exposure held into this cycle (decided at t-1); 0.0 = flat + cum: f64, // cumulative pips + out: [Scalar; 1], +} + +impl SimBroker { + pub fn new(pip_size: f64) -> Self { + assert!(pip_size > 0.0, "SimBroker pip_size must be > 0"); + Self { pip_size, prev_price: None, prev_exposure: 0.0, cum: 0.0, out: [Scalar::F64(0.0)] } + } +} + +impl Node for SimBroker { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![ + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 0 exposure + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 1 price + ], + output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }], + } + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let price = ctx.f64_in(1); + if price.is_empty() { return None; } // no price yet -> nothing to mark + let price = price[0]; + let expo = ctx.f64_in(0).first().copied().unwrap_or(0.0); // flat until exposure warms up + if let Some(pp) = self.prev_price { + self.cum += self.prev_exposure * (price - pp) / self.pip_size; + } + self.prev_price = Some(price); + self.prev_exposure = expo; // update AFTER taking PnL -> no look-ahead + self.out[0] = Scalar::F64(self.cum); + Some(&self.out) + } +} +``` + +## Components + +- **aura-std** — two new modules: `exposure.rs` (`Exposure`) and `sim_broker.rs` + (`SimBroker`), re-exported from `lib.rs` beside `Sma` / `Sub`. Each carries + hand-driven unit tests in the established style (drive `eval` by hand via + `AnyColumn` + `Ctx::new`, no engine present). The crate stays the home of + universal blocks (C16); the engine learns nothing about exposure or equity. +- **aura-engine** — no source change. New end-to-end tests in `harness.rs`'s + `#[cfg(test)] mod tests`, reusing the cycle-0006 test-local `Recorder` fixture + and the existing `Sma` dev-dependency, plus `Exposure` / `SimBroker`. (The + signal-quality loop must be wired through a `Harness`, which only the engine's + tests can see together with `aura-std`.) +- **design ledger** — C10 is already amended (the contract). At cycle close a + **Realization (cycle 0007)** note is added to C10 recording the concrete + shapes: the exposure stream realized as an `aura-std` node output; the + sim-optimal broker realized as `SimBroker` integrating `exposure·return` into a + pip-equity producer; the position-event table confirmed deferred (not built + this cycle). + +## Data flow + +Per source tick the engine advances one cycle with timestamp `ts` (C4) and +forwards the price into its three target slots (both SMAs and the broker's price +slot). Topological evaluation then runs: `Sma(2)`, `Sma(4)` → `Sub` (raw signal) +→ `Exposure` (clamps to [-1,+1]) → `SimBroker`. The broker reads its price slot +(always fresh) and its exposure slot (the latest exposure the chain produced, or +empty → 0.0 during warm-up), takes the PnL of the *previously held* exposure over +the latest price move, accumulates, and emits cumulative pip equity. The recorder +sink taps the broker's output and pushes `(now, [equity])` out of the graph. The +exposure chain warms up first (SMA(4) needs four points), so early cycles see the +broker flat (exposure 0.0) and equity pinned at 0.0 until the signal comes alive. + +Firing (C5/C6): every node here uses `Firing::Any`. The broker fires on each +fresh price; the held exposure contributes its last value (sample-and-hold). The +recorded equity stream is one record per fired broker cycle, tagged `ctx.now()` +(sparse, timestamped — cycle 0006). + +## Error handling + +- `Exposure::new` asserts `scale > 0`; `SimBroker::new` asserts `pip_size > 0` + (construction-time invariants — a zero `scale` / `pip_size` is a divide-by-zero + bug, not a runtime condition). Mirrors `Sma::new`'s `length >= 1` assert. +- All inputs are kind-checked at bootstrap exactly like any consumer's (the + per-field `KindMismatch` / `BadIndex` from cycle 0005). A mis-wired exposure or + price edge fails at bootstrap, before any data flows. +- `Exposure` returns `None` until its input is present; `SimBroker` returns `None` + until a price is present, then is flat (exposure 0.0, equity 0.0) until the + exposure chain warms up — no panic, no cold/None special case downstream. +- The recorder destination is an `mpsc::Sender`; a send to a dropped receiver + returns `Err`, which the fixture ignores (`let _ = self.tx.send(..)`). +- No new `BootstrapError` variant; no new public engine type. + +## Testing strategy + +`aura-std` unit tests (hand-driven, no engine): + +- `exposure_clamps_to_unit_band` — `signal/scale` below/within/above ±1 + maps to the clamped value; sign preserved; saturates at ±1. +- `exposure_is_none_until_input_present` — empty input → `None`. +- `sim_broker_integrates_lagged_exposure_times_return` — feed a known + `(exposure, price)` sequence by hand; assert `cum == Σ prev_exposure·Δprice / + pip_size` at each step. +- `sim_broker_is_flat_during_warmup` — no exposure ever pushed → equity stays + `0.0` across price moves. +- `sim_broker_no_lookahead` — the exposure set on cycle t does **not** earn the + return into t; pinned by a two-step sequence where using the fresh (not lagged) + exposure would give a different equity. +- `sim_broker_first_cycle_has_no_pnl` — first price sets `prev_price`, equity + `0.0` (no prior price to mark against). + +`aura-engine` end-to-end tests (through a `Harness`): + +- `signal_quality_loop_records_pip_equity` — the worked example above; the + drained equity stream matches a hand-computed pip curve. +- `signal_quality_loop_is_deterministic` — two fresh harnesses + channels, + identical input, bit-identical drained equity streams (C1). + +Gates: `cargo build/test --workspace`, `cargo clippy --workspace --all-targets -D +warnings`, and the audit's engine surface-purity grep (no `dyn Any` / `Rc` / +`RefCell` in `aura-engine`/`aura-core` source — unaffected, the new nodes are +plain structs in `aura-std`). + +## Acceptance criteria + +The project's feature-acceptance criterion (CLAUDE.md): the audience naturally +reaches for it; it measurably improves correctness/removes redundancy; it +reintroduces no failure class a core constraint exists to prevent. + +- **Naturally reached for.** The worked example is the program a downstream + author writes to backtest a signal's quality — the project's stated first goal. + It composes with the existing `Sma` / `Sub` with no new mechanism. +- **Improves correctness.** It realizes the C10 reframe that resolves the C8↔C10 + impedance: the strategy output is now a per-cycle state the DAG can faithfully + emit (one record per `eval`), and signal quality is measured without an + execution model that the substrate cannot express atomically. +- **Reintroduces no forbidden failure class.** Determinism (C1) holds — two runs + are bit-identical. Causality (C2) holds — the broker integrates the + *previously held* exposure against the latest return; the fresh exposure never + earns the return into its own cycle (the `no_lookahead` test pins this). Purity + (C7) holds — the new nodes are plain `aura-std` structs; the engine surface is + unchanged. One output port per node (C8) holds — exposure and equity are each a + single `f64` column. +- **Ship gate:** `Exposure` and `SimBroker` shipped in `aura-std` with the + signatures above; the worked signal-quality harness runs and its equity is + recorded via a sink; the full test matrix green; the determinism re-run + bit-identical; the C10 Realization (cycle 0007) note added to the ledger; all + three gates (build/test, clippy, purity grep) clean.