spec: 0007 signal-quality loop (exposure stream + sim-optimal broker)

Cycle 0007 bundles Gitea #4 (exposure node) and #5 (sim-optimal broker) — the two
halves of the signal-quality loop, the realization of the C10 reframe. Ships two
aura-std nodes: Exposure { scale } (clamp(signal/scale, -1, +1)) and
SimBroker { pip_size } (causal prev_exposure * dprice / pip_size integration, no
look-ahead). Engine unchanged — pure node authoring on the frozen substrate.

grounding-check: PASS (all substrate assumptions ratified by green tests).

refs #4 #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:02:33 +02:00
parent 0d7deb3a91
commit fe77b13e73
+297
View File
@@ -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<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.