# 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<Scalar>)> = 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<f64>,
    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.
