4de6d5cbad
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped the two-stage research model: a 1 structurally implies a 2 that no longer exists. The family is renamed by its live discriminator - the R yardstick - with members named by their signal, uniform with their signal-named siblings (sma, macd, momentum): - selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout, stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no silent alias) - identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma, r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_* - persisted identity: the sma_signal composite (param prefix sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids shift - no test pins a literal hash, the registry parses no record names) - e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e - dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical entries stay; fieldtests corpus untouched) - CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe faithfully (the token-swap alone would have laundered the retired gated-currency/realistic-broker design into unmarked live prose); the unbacked account-mode clause dropped Verification: cargo build/test --workspace green (51 targets), clippy -D warnings clean, doc build clean, acceptance grep gate leaves exactly the one resampling-stage false positive (harness.rs), smoke: --harness r-sma runs, --harness stage1-r exits 2 with the new usage line. Decision log: forks and rationale recorded on the issue (reconciliation + implementation-phase comments). closes #174
83 lines
4.7 KiB
Rust
83 lines
4.7 KiB
Rust
//! Breakout signal composition: close -> Delay(1) -> {RollingMax,RollingMin}(N) ->
|
|
//! {Gt,Gt} -> {Latch,Latch} -> Sub = bias in {-1,0,+1}. Tests the C2 causality guard
|
|
//! (the channel excludes the current bar via Delay(1)) and the held ±1 direction,
|
|
//! built straight from aura-std nodes (no CLI dependency).
|
|
|
|
use aura_core::{Scalar, Timestamp};
|
|
use aura_engine::{GraphBuilder, Harness, Source, VecSource};
|
|
use aura_std::{Delay, Gt, Latch, Recorder, RollingMax, RollingMin, Sub};
|
|
use std::sync::mpsc;
|
|
|
|
// A minimal single-f64 source role "price" fed into the breakout signal subgraph,
|
|
// tapping the Sub (bias) output through a Recorder. channel N = 3.
|
|
fn run_breakout_bias(closes: &[f64]) -> Vec<(Timestamp, f64)> {
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut g = GraphBuilder::new("breakout_sig");
|
|
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
|
let mx = g.add(RollingMax::builder().bind("length", Scalar::i64(3)));
|
|
let mn = g.add(RollingMin::builder().bind("length", Scalar::i64(3)));
|
|
let gt_up = g.add(Gt::builder());
|
|
let gt_down = g.add(Gt::builder());
|
|
let up_latch = g.add(Latch::builder());
|
|
let down_latch = g.add(Latch::builder());
|
|
let bias = g.add(Sub::builder());
|
|
let rec = g.add(Recorder::builder(vec![aura_core::ScalarKind::F64], aura_core::Firing::Any, tx));
|
|
let price = g.source_role("price", aura_core::ScalarKind::F64);
|
|
g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]);
|
|
g.connect(delay.output("value"), mx.input("series"));
|
|
g.connect(delay.output("value"), mn.input("series"));
|
|
g.connect(mx.output("value"), gt_up.input("b")); // close > max(prior N)
|
|
g.connect(mn.output("value"), gt_down.input("a")); // min(prior N) > close
|
|
g.connect(gt_up.output("value"), up_latch.input("set"));
|
|
g.connect(gt_down.output("value"), up_latch.input("reset"));
|
|
g.connect(gt_down.output("value"), down_latch.input("set"));
|
|
g.connect(gt_up.output("value"), down_latch.input("reset"));
|
|
g.connect(up_latch.output("value"), bias.input("lhs"));
|
|
g.connect(down_latch.output("value"), bias.input("rhs"));
|
|
g.connect(bias.output("value"), rec.input("col[0]"));
|
|
let flat = g.build().expect("breakout signal wiring resolves").compile_with_params(&[]).expect("compiles");
|
|
let mut h = Harness::bootstrap(flat).expect("bootstraps");
|
|
let prices: Vec<(Timestamp, Scalar)> =
|
|
closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect();
|
|
let src: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(prices))];
|
|
h.run(src);
|
|
rx.try_iter().map(|(ts, row): (Timestamp, Vec<Scalar>)| (ts, row[0].as_f64())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn breakout_channel_excludes_the_current_bar_c2() {
|
|
// C2 (no look-ahead), contrastive form: on a strictly monotonically rising series
|
|
// every warmed bar is a fresh all-time high. The channel is max(close[t-N..t-1])
|
|
// (Delay(1) excludes the current bar), so close[t] > that prior max on EVERY warmed
|
|
// bar -> a continuous UP-break -> bias pinned at +1.
|
|
//
|
|
// If the channel WRONGLY included the current bar (the look-ahead bug), the test
|
|
// would be `close[t] > max(close[t-N..t])`, and a value is never strictly greater
|
|
// than the max of a window that contains itself -> the up-break could NEVER fire ->
|
|
// bias would never reach +1. So a sustained +1 here is only reachable when the
|
|
// current bar is structurally excluded: this asserts the C2 guard directly.
|
|
let closes = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0];
|
|
let vals: Vec<f64> = run_breakout_bias(&closes).iter().map(|(_, v)| *v).collect();
|
|
assert!(
|
|
!vals.is_empty() && vals.iter().all(|&v| v == 1.0),
|
|
"a strictly rising series must up-break every warmed bar (+1); got {vals:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn breakout_is_causal_and_holds_pm1_direction() {
|
|
// closes: an up-break at bar 3, quiet hold, a down-break at bar 6.
|
|
// idx: 0 1 2 3 4 5 6 7
|
|
let closes = [100.0, 101.0, 99.0, 102.0, 100.0, 100.0, 95.0, 100.0];
|
|
// bar3: max(close[0..2])=101, close[3]=102 > 101 -> UP-break. (C2: a window INCLUDING
|
|
// bar3 would be max=102, and 102>102 strict is false -> no break; the break
|
|
// firing here proves the current bar is excluded.)
|
|
// bars4,5: no break -> bias HOLDS +1.
|
|
// bar6: min(close[3..5])=100, close[6]=95 < 100 -> DOWN-break -> bias flips to -1.
|
|
// bar7: no break -> HOLDS -1.
|
|
let got = run_breakout_bias(&closes);
|
|
let vals: Vec<f64> = got.iter().map(|(_, v)| *v).collect();
|
|
// bias is emitted only once the channel warms up (Delay(1)+RollingMax(3) -> first at bar 3).
|
|
assert_eq!(vals, vec![1.0, 1.0, 1.0, -1.0, -1.0], "expected +1 held then -1 held; got {vals:?}");
|
|
}
|