f3cd2e1320
First fieldtest of the signal-quality loop. A standalone downstream-consumer crate
(fieldtests/cycle-0007-signal-quality/) path-depends on the engine crates and
exercises the post-0007 surface from the public interface only (rustdoc + specs +
ledger + glossary, never crates/*/src):
1 single-signal quality backtest, end to end (SMA-cross -> Exposure -> SimBroker
-> Recorder pip curve)
2 Exposure clamp + sizing (hard ±1 saturation, sign preserved)
3 sim-optimal integration: short-on-falling pays positive pips; pip_size=2 curve
exactly halves pip_size=1
4 north-star combine-two-signals (two MA-cross spreads summed into one exposure)
Findings: 3 working (carry-on), 2 spec_gap, 2 friction.
- spec_gap: SimBroker firing policy + cold-exposure->0.0 warm-up emission shape
not on the public surface (only in the feat commit body).
- spec_gap: SimBroker input slot order (0=exposure, 1=price) only in C10 prose /
commit body; a swapped wiring is not caught at bootstrap (both f64).
- friction: the north-star "combine two signals" move needs a sum/LinComb
combinator aura-std does not ship (hand-authored Add2 in the fixture).
- friction: standalone consumer crate still needs the empty [workspace] table
(carried from cycle-0006; tracked as #9).
Spec at docs/specs/fieldtest-0007-signal-quality.md feeds the next plan.
refs #4 #5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
6.2 KiB
Rust
149 lines
6.2 KiB
Rust
//! Fieldtest c0007 #1 — a single-signal quality backtest, end to end.
|
|
//!
|
|
//! Axis: "composition into an end-to-end signal-quality harness + recording the
|
|
//! equity curve" — the project's primary research loop (docs/design INDEX C10,
|
|
//! project-layout.md "A day in the life" step 3).
|
|
//!
|
|
//! The chain a downstream researcher wires to ask "does this SMA-cross signal,
|
|
//! held as exposure over time, make pips?":
|
|
//!
|
|
//! price ----+--> SMA(2) --\
|
|
//! | Sub(fast - slow) --> Exposure(scale) --\
|
|
//! +--> SMA(4) --/ |
|
|
//! | v
|
|
//! +-------------------------------> SimBroker(pip_size, exposure, price)
|
|
//! |
|
|
//! v
|
|
//! Recorder (equity curve)
|
|
//!
|
|
//! Public-surface facts used:
|
|
//! - aura_std::Exposure::new(scale) = clamp(signal/scale, -1, +1), None until warm
|
|
//! (rustdoc struct.Exposure).
|
|
//! - aura_std::SimBroker::new(pip_size) integrates exposure*price-return -> pips,
|
|
//! pip_size held metadata (rustdoc struct.SimBroker).
|
|
//! - SimBroker input slot order: exposure = slot 0, price = slot 1
|
|
//! (design ledger C10 "Realization (cycle 0007)" + the feat commit body;
|
|
//! the rustdoc itself does NOT state the slot order — recorded as a spec_gap).
|
|
//! - integration is prev_exposure * (price - prev_price) / pip_size, cumulative
|
|
//! (ledger C10 Realization).
|
|
|
|
use std::sync::mpsc::{self, Sender};
|
|
|
|
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{Edge, Harness, SourceSpec, Target};
|
|
use aura_std::{Exposure, SimBroker, Sma, Sub};
|
|
|
|
struct Recorder {
|
|
tx: Sender<(Timestamp, f64)>,
|
|
}
|
|
impl Node for Recorder {
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
|
|
output: vec![],
|
|
}
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
|
let w = ctx.f64_in(0);
|
|
if w.is_empty() {
|
|
return None;
|
|
}
|
|
let _ = self.tx.send((ctx.now(), w[0]));
|
|
None
|
|
}
|
|
}
|
|
|
|
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
|
|
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
|
|
}
|
|
|
|
fn main() {
|
|
let (tx, rx) = mpsc::channel();
|
|
|
|
// nodes: 0=SMA(2) fast, 1=SMA(4) slow, 2=Sub(fast-slow), 3=Exposure(scale=4),
|
|
// 4=SimBroker(pip_size=1.0), 5=Recorder taps broker equity.
|
|
let mut h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Exposure::new(4.0)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(Recorder { tx }),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
// price fans into both SMAs AND the broker's price slot (slot 1).
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 4, slot: 1 },
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // SMA2 -> Sub.in0 (fast)
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // SMA4 -> Sub.in1 (slow)
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // Sub -> Exposure
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // SimBroker -> Recorder
|
|
],
|
|
)
|
|
.expect("valid signal-quality DAG");
|
|
|
|
// A steadily-rising price: the fast SMA leads, spread positive => long exposure,
|
|
// and the rising price then pays that long exposure positive pips.
|
|
let prices: &[(i64, f64)] = &[
|
|
(1, 100.0),
|
|
(2, 102.0),
|
|
(3, 104.0),
|
|
(4, 106.0),
|
|
(5, 108.0),
|
|
(6, 110.0),
|
|
(7, 112.0),
|
|
];
|
|
h.run(vec![f64_stream(prices)]);
|
|
|
|
let equity: Vec<(Timestamp, f64)> = rx.try_iter().collect();
|
|
println!("recorded pip-equity curve = {equity:?}");
|
|
|
|
// What I FIRST predicted (public-surface model, Exposure-warm-gated):
|
|
// SMA4 warms at t=4, so the spread/exposure exists only from t=4 on; I
|
|
// expected the broker to emit ONLY from t=4 (its exposure leg's first warm
|
|
// cycle), giving 4 rows starting at t=4.
|
|
// t=4: prev_exp=0 (cold) -> +0 -> cum 0.0
|
|
// t=5: prev_exp=0.5, dprice=2 -> +1.0 -> cum 1.0
|
|
// t=6: prev_exp=0.5, dprice=2 -> +1.0 -> cum 2.0
|
|
// t=7: prev_exp=0.5, dprice=2 -> +1.0 -> cum 3.0
|
|
let predicted = vec![
|
|
(Timestamp(4), 0.0),
|
|
(Timestamp(5), 1.0),
|
|
(Timestamp(6), 2.0),
|
|
(Timestamp(7), 3.0),
|
|
];
|
|
|
|
// What ACTUALLY happens: the broker emits a row on EVERY price-fresh cycle
|
|
// (firing policy A on its price leg), starting at t=1 — long before the
|
|
// exposure leg warms. Cold exposure is treated as 0.0 (no position held), so
|
|
// those leading rows are cum 0.0. The pip VALUES from t=4 on are identical to
|
|
// my prediction; only the WARM-UP EMISSION SHAPE differs (3 extra leading
|
|
// 0.0 rows at t=1,2,3). The rustdoc for SimBroker states neither its firing
|
|
// policy nor this leading-zero emission — recorded as a spec_gap.
|
|
let actual_observed = vec![
|
|
(Timestamp(1), 0.0),
|
|
(Timestamp(2), 0.0),
|
|
(Timestamp(3), 0.0),
|
|
(Timestamp(4), 0.0),
|
|
(Timestamp(5), 1.0),
|
|
(Timestamp(6), 2.0),
|
|
(Timestamp(7), 3.0),
|
|
];
|
|
println!("first prediction (warm-gated) = {predicted:?}");
|
|
println!("actual (price-fresh fires) = {actual_observed:?}");
|
|
|
|
// The pip values where exposure is warm match the prediction exactly:
|
|
let warm: Vec<_> = equity.iter().filter(|(t, _)| t.0 >= 4).cloned().collect();
|
|
assert_eq!(warm, predicted, "warm-region pip values match the hand model");
|
|
assert_eq!(equity, actual_observed, "full recorded curve incl. leading zeros");
|
|
println!("c0007_1 OK: SMA-cross -> Exposure -> SimBroker -> Recorder pip curve");
|
|
}
|