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>
178 lines
7.2 KiB
Rust
178 lines
7.2 KiB
Rust
//! Fieldtest c0007 #4 — the north-star research move: combine TWO signals into
|
|
//! one exposure and backtest the combination.
|
|
//!
|
|
//! Axis: "combining two signals (e.g. two different MA-cross spreads) into one
|
|
//! exposure and backtesting the combination (the project's stated north-star
|
|
//! research move)" — design INDEX C10: "backtest one signal, combine it with
|
|
//! another, backtest the combination."
|
|
//!
|
|
//! Two MA-cross spreads on the same price:
|
|
//! spread_fast = SMA(2) - SMA(4)
|
|
//! spread_slow = SMA(3) - SMA(6)
|
|
//! summed into one combined score, shaped to exposure, fed to the sim-optimal
|
|
//! broker:
|
|
//!
|
|
//! price --+--> SMA2 --\
|
|
//! | Sub --> spread_fast --\
|
|
//! +--> SMA4 --/ Add2 --> Exposure --\
|
|
//! +--> SMA3 --\ / |
|
|
//! | Sub --> spread_slow --/ v
|
|
//! +--> SMA6 --/ SimBroker --> Recorder
|
|
//! +----------------------------------------> (price slot)
|
|
//!
|
|
//! aura-std ships Sma, Sub, Exposure, SimBroker — but NO summing/weighting
|
|
//! combinator, which is exactly the operation "combine two signals" needs. So a
|
|
//! researcher hand-authors a project-local Add2 node (legitimate per C16). The
|
|
//! need to write this by hand for the stated north-star move is a friction
|
|
//! finding.
|
|
|
|
use std::sync::mpsc::{self, Sender};
|
|
|
|
use aura_core::{Ctx, Firing, FieldSpec, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{Edge, Harness, SourceSpec, Target};
|
|
use aura_std::{Exposure, SimBroker, Sma, Sub};
|
|
|
|
/// Sum of two f64 inputs (a combinator aura-std does not ship). Emits None until
|
|
/// both inputs are present — same warm-up discipline as aura_std::Sub.
|
|
struct Add2 {
|
|
out: Vec<Scalar>,
|
|
}
|
|
impl Add2 {
|
|
fn new() -> Self {
|
|
Self { out: vec![Scalar::F64(0.0)] }
|
|
}
|
|
}
|
|
impl Node for Add2 {
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
inputs: vec![
|
|
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
|
|
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
|
|
],
|
|
output: vec![FieldSpec { name: "combined", kind: ScalarKind::F64 }],
|
|
}
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
|
let a = ctx.f64_in(0);
|
|
let b = ctx.f64_in(1);
|
|
if a.is_empty() || b.is_empty() {
|
|
return None;
|
|
}
|
|
self.out[0] = Scalar::F64(a[0] + b[0]);
|
|
Some(&self.out)
|
|
}
|
|
}
|
|
|
|
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 SMA2 1 SMA4 2 SMA3 3 SMA6
|
|
// 4 Sub(fast = SMA2-SMA4) 5 Sub(slow = SMA3-SMA6)
|
|
// 6 Add2(fast+slow) 7 Exposure(scale=4) 8 SimBroker(1.0) 9 Recorder
|
|
let mut h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sma::new(3)),
|
|
Box::new(Sma::new(6)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Sub::new()),
|
|
Box::new(Add2::new()),
|
|
Box::new(Exposure::new(4.0)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(Recorder { tx }),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 2, slot: 0 },
|
|
Target { node: 3, slot: 0 },
|
|
Target { node: 8, slot: 1 }, // price -> broker price slot
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 4, slot: 0, from_field: 0 }, // SMA2 -> fast.in0
|
|
Edge { from: 1, to: 4, slot: 1, from_field: 0 }, // SMA4 -> fast.in1
|
|
Edge { from: 2, to: 5, slot: 0, from_field: 0 }, // SMA3 -> slow.in0
|
|
Edge { from: 3, to: 5, slot: 1, from_field: 0 }, // SMA6 -> slow.in1
|
|
Edge { from: 4, to: 6, slot: 0, from_field: 0 }, // fast -> Add2.in0
|
|
Edge { from: 5, to: 6, slot: 1, from_field: 0 }, // slow -> Add2.in1
|
|
Edge { from: 6, to: 7, slot: 0, from_field: 0 }, // Add2 -> Exposure
|
|
Edge { from: 7, to: 8, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
|
|
Edge { from: 8, to: 9, slot: 0, from_field: 0 }, // SimBroker -> Recorder
|
|
],
|
|
)
|
|
.expect("valid combined-signal DAG");
|
|
|
|
// Rising price: both spreads positive => combined 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),
|
|
(8, 114.0),
|
|
];
|
|
h.run(vec![f64_stream(prices)]);
|
|
|
|
let equity: Vec<(Timestamp, f64)> = rx.try_iter().collect();
|
|
println!("combined-signal pip-equity = {equity:?}");
|
|
|
|
// The combined exposure only becomes well-defined once BOTH spreads warm.
|
|
// spread_fast warms at t=4 (SMA4), spread_slow at t=6 (SMA6).
|
|
// For a +2/step arithmetic ramp the SMAs are evenly spaced, so each spread
|
|
// is constant once warm: spread_fast = +2, spread_slow = +3.
|
|
// Combined score from t=6 on = 5 -> Exposure clamp(5/4,-1,1) = +1.0 (saturated).
|
|
// From t=7: prev_exposure=+1.0, dprice=2, pip_size=1 -> +2 pips/step.
|
|
// Broker fires on every price-fresh cycle (the spec_gap from #1), so the
|
|
// curve carries leading rows before t=6; we assert the WARM region only,
|
|
// plus monotone-increasing equity, plus the final value.
|
|
let warm: Vec<_> = equity.iter().filter(|(t, _)| t.0 >= 6).cloned().collect();
|
|
println!("warm region (t>=6) = {warm:?}");
|
|
|
|
// exposure saturates to +1 from t=6, so equity gains +2 per step from t=7.
|
|
// t=6: prev_exposure was not yet the combined +1 (slow leg warmed this cycle),
|
|
// so this step's gain depends on the prior held exposure; we assert only
|
|
// monotonicity here and the steady-state slope between t=7 and t=8.
|
|
assert!(!warm.is_empty(), "combined signal produces a warm equity region");
|
|
let final_pips = equity.last().map(|&(_, v)| v).unwrap_or(f64::NAN);
|
|
assert!(final_pips > 0.0, "combined long signal on rising price makes positive pips");
|
|
|
|
// steady-state: with saturated +1 exposure and +2 price steps, the last
|
|
// increment must be +2 pips.
|
|
let n = equity.len();
|
|
let last_step = equity[n - 1].1 - equity[n - 2].1;
|
|
println!("final pips = {final_pips}, last step = {last_step}");
|
|
assert!((last_step - 2.0).abs() < 1e-12, "steady-state +1 exposure earns +2 pips/step");
|
|
println!("c0007_4 OK: two MA-cross spreads combined (hand-authored Add2) -> pips");
|
|
}
|