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
+1 -1
View File
@@ -56,5 +56,5 @@ pub use session::Session;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use sqrt::Sqrt;
pub use stop_rule::{FixedStop, VolStop};
pub use stop_rule::FixedStop;
pub use sub::Sub;
+21 -86
View File
@@ -1,17 +1,24 @@
//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0,
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
//! position-management latches the entry-cycle distance as the frozen R-denominator.
//! `FixedStop` is a constant distance (test fixture / structural-axis sibling);
//! `VolStop` is a close-to-close volatility stop `k * EMA_length(|price - prev_price|)`
//! (true-range ATR is deferred — it needs OHLC). One fused node each (no Abs/Mul
//! primitive exists, so abs+delta+EMA fuse inside VolStop).
//!
//! `FixedStop` is the only stop-rule PRIMITIVE: a constant distance gated on its price
//! input (a source-less `Const` has no firing trigger in the push model, so the
//! triggered-constant shape is the honest primitive). The volatility stop is NOT a
//! primitive — it is a COMPOSITION of primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))`
//! with `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built with
//! `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`. True-range ATR
//! (a richer stop) is deferred — it needs OHLC.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Constant stop distance. `distance` must be > 0.
pub struct FixedStop { distance: f64, out: [Cell; 1] }
/// Constant stop distance, gated on a price input. `distance` must be > 0.
pub struct FixedStop {
distance: f64,
out: [Cell; 1],
}
impl FixedStop {
pub fn new(distance: f64) -> Self {
assert!(distance > 0.0, "FixedStop distance must be > 0");
@@ -30,74 +37,19 @@ impl FixedStop {
}
}
impl Node for FixedStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() { return None; } // fire with price (warm-up filter)
if ctx.f64_in(0).is_empty() {
return None; // fire with price (warm-up filter)
}
self.out[0] = Cell::from_f64(self.distance);
Some(&self.out)
}
fn label(&self) -> String { format!("FixedStop({})", self.distance) }
}
/// Volatility stop distance: `k * EMA_length(|price - prev_price|)`. EMA is the
/// standard `alpha = 2/(length+1)` recursion, but its WARM-UP DIFFERS from the
/// crate's [`crate::Ema`]: that node seeds with the SMA of its first `length` samples
/// (ta-lib convention), whereas `VolStop` seeds with the FIRST abs-return and runs the
/// recurrence from there (a first-value seed). The divergence is deliberate — the
/// abs-return stream needs a prior price before any sample exists, so a uniform
/// `length`-sample SMA seed would push warm-up an extra cycle out and complicate the
/// frozen-R latch; the first-value seed keeps the stop available as early as the data
/// allows. Output is `None` until `length` abs-returns have been seen (warm-up).
/// `prev_price` updated AFTER use (intra-node z⁻¹, C2-clean). `length >= 1`, `k > 0`.
pub struct VolStop {
length: usize,
k: f64,
prev_price: Option<f64>,
ema: f64,
count: usize,
out: [Cell; 1],
}
impl VolStop {
pub fn new(length: usize, k: f64) -> Self {
assert!(length >= 1, "VolStop length must be >= 1");
assert!(k > 0.0, "VolStop k must be > 0");
Self { length, k, prev_price: None, ema: 0.0, count: 0, out: [Cell::from_f64(0.0)] }
fn label(&self) -> String {
format!("FixedStop({})", self.distance)
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"VolStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
],
},
|p| Box::new(VolStop::new(p[0].i64() as usize, p[1].f64())),
)
}
}
impl Node for VolStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() { return None; }
let price = w[0];
let Some(pp) = self.prev_price else {
self.prev_price = Some(price);
return None; // need a prior price to form the first abs-return
};
let d = (price - pp).abs();
let alpha = 2.0 / (self.length as f64 + 1.0);
if self.count == 0 { self.ema = d; } else { self.ema += alpha * (d - self.ema); }
self.count += 1;
self.prev_price = Some(price); // update AFTER use (C2)
if self.count < self.length { return None; } // warm-up
self.out[0] = Cell::from_f64(self.k * self.ema);
Some(&self.out)
}
fn label(&self) -> String { format!("VolStop({},{})", self.length, self.k) }
}
#[cfg(test)]
@@ -118,21 +70,4 @@ mod tests {
let mut s = FixedStop::new(2.5);
assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]);
}
#[test]
fn vol_stop_is_none_until_warm() {
// length 3: needs a prior price (cycle 1 -> None) then 3 abs-returns.
let mut s = VolStop::new(3, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got[0], None); // no prior price
assert_eq!(got[1], None); // 1 return
assert_eq!(got[2], None); // 2 returns
assert!(got[3].is_some()); // 3 returns -> warm
}
#[test]
fn vol_stop_tracks_k_times_ema_abs_return() {
// constant abs-return of 1.0 -> EMA = 1.0 -> distance = k*1.0 = 2.0.
let mut s = VolStop::new(2, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got.last().unwrap().unwrap(), 2.0);
}
}