//! 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 { 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> { 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)]); } }