Files
Aura/docs/specs/0007-signal-quality-loop.md
T
Brummel fe77b13e73 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>
2026-06-04 17:02:33 +02:00

14 KiB
Raw Blame History

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.

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)

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.

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_bandsignal/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.