//! 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)> = 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() ); }