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
This commit is contained in:
2026-06-24 01:04:19 +02:00
parent 831092841e
commit 0998f9aabf
4 changed files with 101 additions and 101 deletions
+14 -14
View File
@@ -27,7 +27,7 @@
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
use aura_engine::{RMetrics, summarize_r};
use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement, VolStop};
use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};
// The indices `report.rs::summarize_r` reads the dense record by. Re-declared here
// (the consumer's `r_col` module is private to `aura-engine`) so the guard test can
@@ -119,26 +119,26 @@ fn long_path(prices: &[f64]) -> Vec<(f64, f64)> {
/// distances. With a no-gap stop hit at exactly the stop level the fold returns
/// exactly -1R *whatever the distance*, so to observe the stop-defines-R property
/// end-to-end the price must overshoot the stop: then a wider FixedStop (distance
/// 10) realises a shallower R-loss than a tight VolStop. A run that ignored the
/// stop distance (e.g. measured R in raw pips) would collapse these to one number.
/// 10) realises a shallower R-loss than a tight FixedStop (distance 1). A run that
/// ignored the stop distance (e.g. measured R in raw pips) would collapse these.
#[test]
fn r_is_stop_defined_two_stops_fold_to_different_expectancy() {
// Open long ~100, then a sharp drop that overshoots both stops.
// FixedStop(10): stop @90, fill @85 -> R = (85-100)/10 = -1.5.
let mut fixed = FixedStop::new(10.0);
let wide = run_chain(&mut fixed, &long_path(&[100.0, 100.0, 85.0]));
// VolStop(1, 1.0): length-1 VolStop warms after one return; entry on the cycle
// after warm-up. returns: 100->101 (=1), warm; entry @101 with dist=1, stop @100;
// drop to 96. A unit (1.0) far tighter than the wide FixedStop unit (10.0).
let mut vol = VolStop::new(1, 1.0);
let vol_m = run_chain(&mut vol, &long_path(&[100.0, 101.0, 96.0]));
assert!(wide.n_trades >= 1 && vol_m.n_trades >= 1);
// R is the (exit-entry)/distance ratio: the tight VolStop unit (1.0) yields a far
// deeper R-loss than the wide FixedStop unit (10.0) for a comparable price drop.
// A TIGHT FixedStop (distance 1.0): entry @100, stop @99; drop to 96 overshoots,
// fill @96 -> R = (96-100)/1 = -4. A unit (1.0) far tighter than the wide unit (10.0),
// so for a comparable drop it folds to a ~10x deeper R-loss — R is stop-defined.
let mut tight = FixedStop::new(1.0);
let tight_m = run_chain(&mut tight, &long_path(&[100.0, 100.0, 96.0]));
assert!(wide.n_trades >= 1 && tight_m.n_trades >= 1);
// R is the (exit-entry)/distance ratio: the tight unit (1.0) yields a far deeper
// R-loss than the wide unit (10.0) for a comparable price drop.
assert!(
vol_m.expectancy_r < wide.expectancy_r,
"stop choice must change folded R: vol={:?} wide={:?}",
vol_m.expectancy_r,
tight_m.expectancy_r < wide.expectancy_r,
"stop choice must change folded R: tight={:?} wide={:?}",
tight_m.expectancy_r,
wide.expectancy_r,
);
}
@@ -0,0 +1,65 @@
//! 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()
);
}