Files
Aura/crates/aura-engine/tests/vol_stop_composite.rs
T
Brummel 0998f9aabf refactor(stage1-r): the vol stop is a composition, not a fused node
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
2026-06-24 01:04:19 +02:00

66 lines
3.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
);
}