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:
@@ -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.
|
||||
Reference in New Issue
Block a user