0998f9aabf
Applies the principle the user named: a node is a primitive only if it is NOT DAG-expressible from other primitives; a function that needs a missing primitive gets the primitive added, not fused away. - Remove the fused VolStop node (it was pure feed-forward arithmetic). - The volatility stop is now a composition of primitives, vol_stop(length, k): k * Sqrt(Ema(Mul(d,d), length)), d = Sub(price, Delay(price,1)) -- a rolling EWMA standard deviation. Built with GraphBuilder; proven end-to-end (bootstraps + runs + emits k*sigma) in tests/vol_stop_composite.rs. - Migrate the one VolStop caller (stage1_r_e2e.rs) off it: the R-is-stop-defined test now contrasts a tight vs a wide FixedStop (two constant distances still fold to different R, the property under test). Uses the Mul + Sqrt primitives added in the prior commit. FixedStop stays the only stop-rule primitive (a triggered constant). Full suite + clippy green. refs #117 #119
66 lines
3.2 KiB
Rust
66 lines
3.2 KiB
Rust
//! The volatility stop as a COMPOSITION of primitives (the post-correction design):
|
||
//! `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`
|
||
//! — a rolling EWMA standard deviation. This proves the decomposition wires through
|
||
//! `GraphBuilder`, bootstraps, and computes the right number end-to-end — the principle
|
||
//! that a node expressible as a DAG of primitives is a composition, not a fused node.
|
||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||
use aura_engine::{Composite, GraphBuilder, VecSource};
|
||
use aura_std::{Delay, Ema, LinComb, Mul, Recorder, Sqrt, Sub};
|
||
use std::sync::mpsc::channel;
|
||
|
||
/// Build the volatility-stop composite: `price -> k · rolling-EWMA-stddev`. The
|
||
/// `length`, `k`, and the `lag=1` register are bound at construction (no free params).
|
||
fn vol_stop(length: i64, k: f64) -> Composite {
|
||
let mut g = GraphBuilder::new("vol_stop");
|
||
let price = g.input_role("price");
|
||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
|
||
let sub = g.add(Sub::builder()); // Δ = price - prev
|
||
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
|
||
let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance
|
||
let sqrt = g.add(Sqrt::builder()); // -> σ (price units)
|
||
let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ
|
||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||
g.connect(delay.output("value"), sub.input("rhs"));
|
||
g.connect(sub.output("value"), sq.input("lhs"));
|
||
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
|
||
g.connect(sq.output("value"), ema.input("series"));
|
||
g.connect(ema.output("value"), sqrt.input("value"));
|
||
g.connect(sqrt.output("value"), scale.input("term[0]"));
|
||
g.expose(scale.output("value"), "stop_distance");
|
||
g.build().expect("vol_stop composite wires")
|
||
}
|
||
|
||
/// An alternating ±1 price path makes `Δ² ≡ 1`, so the EWMA variance is `1`, `σ = 1`,
|
||
/// and `stop_distance = k·σ = k`. With `k = 2.0` the warmed-up output is exactly `2.0`.
|
||
#[test]
|
||
fn vol_stop_emits_k_times_rolling_stddev() {
|
||
let (tx, rx) = channel();
|
||
let mut g = GraphBuilder::new("vol_stop_harness");
|
||
let price = g.source_role("price", ScalarKind::F64);
|
||
let vs = g.add(vol_stop(/*length*/ 3, /*k*/ 2.0));
|
||
let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx));
|
||
g.feed(price, [vs.input("price")]);
|
||
g.connect(vs.output("stop_distance"), rec.input("col[0]"));
|
||
let mut h = g
|
||
.build()
|
||
.expect("harness wires")
|
||
.bootstrap_with_params(vec![])
|
||
.expect("bootstraps");
|
||
|
||
let prices = [100.0, 101.0, 100.0, 101.0, 100.0, 101.0, 100.0, 101.0, 100.0, 101.0];
|
||
let stream: Vec<(Timestamp, Scalar)> = prices
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p)))
|
||
.collect();
|
||
h.run(vec![Box::new(VecSource::new(stream))]);
|
||
|
||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||
let last = rows.last().expect("vol_stop produced output after warm-up");
|
||
assert!(
|
||
(last.1[0].as_f64() - 2.0).abs() < 1e-9,
|
||
"expected stop_distance = k·σ = 2.0, got {}",
|
||
last.1[0].as_f64()
|
||
);
|
||
}
|