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>
118 lines
4.2 KiB
Rust
118 lines
4.2 KiB
Rust
//! Fieldtest c0007 #3 — sim-optimal pip integration with a non-unit pip_size and
|
|
//! a SHORT exposure on a falling market.
|
|
//!
|
|
//! Axis: "sim-optimal pip-equity integration via SimBroker" + "a run on a
|
|
//! different pip_size / exposure scale".
|
|
//!
|
|
//! Two things a researcher checks about the sim-optimal broker:
|
|
//! 1. A short signal on a falling price makes POSITIVE pips
|
|
//! (negative exposure * negative return > 0) — the signal is "good".
|
|
//! 2. pip_size is a divisor: doubling pip_size halves the pip count for the
|
|
//! same price move (rustdoc struct.SimBroker; ledger C10 Realization:
|
|
//! prev_exposure * (price - prev_price) / pip_size).
|
|
//!
|
|
//! price --+--> SMA(2) --\
|
|
//! | Sub(fast - slow) --> Exposure(4) --\
|
|
//! +--> SMA(4) --/ |
|
|
//! +------------------------------> SimBroker(pip_size) --> Recorder
|
|
//!
|
|
//! We run the SAME falling-price stream through two harnesses, pip_size 1.0 vs
|
|
//! 2.0, and assert the pip-2.0 curve is exactly half the pip-1.0 curve.
|
|
|
|
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 run_curve(pip_size: f64, prices: &[(i64, f64)]) -> Vec<(Timestamp, f64)> {
|
|
let (tx, rx) = mpsc::channel();
|
|
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(pip_size)),
|
|
Box::new(Recorder { tx }),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
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 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
|
|
],
|
|
)
|
|
.expect("valid DAG");
|
|
h.run(vec![f64_stream(prices)]);
|
|
rx.try_iter().collect()
|
|
}
|
|
|
|
fn main() {
|
|
// Steadily FALLING price: SMA(2) below SMA(4) => negative spread => short
|
|
// exposure; the falling price pays that short positive pips.
|
|
let prices: &[(i64, f64)] = &[
|
|
(1, 112.0),
|
|
(2, 110.0),
|
|
(3, 108.0),
|
|
(4, 106.0),
|
|
(5, 104.0),
|
|
(6, 102.0),
|
|
(7, 100.0),
|
|
];
|
|
|
|
let c1 = run_curve(1.0, prices);
|
|
let c2 = run_curve(2.0, prices);
|
|
println!("pip_size=1.0 curve = {c1:?}");
|
|
println!("pip_size=2.0 curve = {c2:?}");
|
|
|
|
// Short on a falling market => non-negative, eventually positive pips.
|
|
let final_pips_1 = c1.last().map(|&(_, v)| v).unwrap_or(f64::NAN);
|
|
println!("final pips (pip_size=1.0) = {final_pips_1}");
|
|
assert!(final_pips_1 > 0.0, "short signal on falling price makes positive pips");
|
|
|
|
// pip_size is a divisor: c2 must be exactly half c1, timestamp-for-timestamp.
|
|
assert_eq!(c1.len(), c2.len(), "same firing pattern regardless of pip_size");
|
|
for (&(t1, v1), &(t2, v2)) in c1.iter().zip(c2.iter()) {
|
|
assert_eq!(t1, t2, "same timestamps");
|
|
assert!(
|
|
(v2 - v1 / 2.0).abs() < 1e-12,
|
|
"pip_size 2.0 pip {v2} == half of pip_size 1.0 pip {v1} at {t1:?}"
|
|
);
|
|
}
|
|
println!("c0007_3 OK: short pays positive pips; pip_size halves the pip count");
|
|
}
|