Files
Aura/crates/aura-std/src/stop_rule.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

74 lines
2.8 KiB
Rust

//! 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 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, 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");
Self { distance, out: [Cell::from_f64(distance)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"FixedStop",
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: "distance".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(FixedStop::new(p[0].f64())),
)
}
}
impl Node for FixedStop {
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)
}
self.out[0] = Cell::from_f64(self.distance);
Some(&self.out)
}
fn label(&self) -> String {
format!("FixedStop({})", self.distance)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec<Option<f64>> {
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut out = vec![];
for &p in prices {
col[0].push(Scalar::f64(p)).unwrap();
out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64()));
}
out
}
#[test]
fn fixed_stop_is_constant_after_first_price() {
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)]);
}
}