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>
14 KiB
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".
-
Exposure { scale }(aura-std) — the decision/sizing node of C10's chainsignals → decision/sizing node → exposure stream. Onef64input (a raw signal score), onef64output:clamp(signal / scale, -1.0, +1.0). Position sizing / risk live here:scalesets which signal magnitude maps to full exposure.Noneuntil its input is present (warm-up filter, C8). -
SimBroker { pip_size }(aura-std) — class (a) of C10: deterministic, frictionless, perfect-fill. Twof64inputs (slot 0 = exposure, slot 1 = price), onef64output (cumulative pip equity). It holdsprev_price,prev_exposure,cum, andpip_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 (emitscumevery 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) andsim_broker.rs(SimBroker), re-exported fromlib.rsbesideSma/Sub. Each carries hand-driven unit tests in the established style (driveevalby hand viaAnyColumn+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-localRecorderfixture and the existingSmadev-dependency, plusExposure/SimBroker. (The signal-quality loop must be wired through aHarness, which only the engine's tests can see together withaura-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-stdnode output; the sim-optimal broker realized asSimBrokerintegratingexposure·returninto 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::newassertsscale > 0;SimBroker::newassertspip_size > 0(construction-time invariants — a zeroscale/pip_sizeis a divide-by-zero bug, not a runtime condition). MirrorsSma::new'slength >= 1assert.- All inputs are kind-checked at bootstrap exactly like any consumer's (the
per-field
KindMismatch/BadIndexfrom cycle 0005). A mis-wired exposure or price edge fails at bootstrap, before any data flows. ExposurereturnsNoneuntil its input is present;SimBrokerreturnsNoneuntil 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 returnsErr, which the fixture ignores (let _ = self.tx.send(..)). - No new
BootstrapErrorvariant; no new public engine type.
Testing strategy
aura-std unit tests (hand-driven, no engine):
exposure_clamps_to_unit_band—signal/scalebelow/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; assertcum == Σ prev_exposure·Δprice / pip_sizeat each step.sim_broker_is_flat_during_warmup— no exposure ever pushed → equity stays0.0across 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 setsprev_price, equity0.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/Subwith 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_lookaheadtest pins this). Purity (C7) holds — the new nodes are plainaura-stdstructs; the engine surface is unchanged. One output port per node (C8) holds — exposure and equity are each a singlef64column. - Ship gate:
ExposureandSimBrokershipped inaura-stdwith 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.