# 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.