//! `Sizer` โ€” the flat-1R sizing seam (C10). Turns a protective-stop distance //! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is //! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the //! stop โ€” NOT a constant. The `bias` input gates firing (a size is only meaningful when a //! strategy is taking a position) and is the live/deploy-edge seam: fixed-fractional //! sizing swaps `risk_budget` for `risk_fraction ยท equity` (an added equity input), same //! node shape. R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates //! signal quality โ€” it only scales the (deploy-edge) currency exposure. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, }; /// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None` /// until both inputs are present (warm-up filter, C8). pub struct Sizer { risk_budget: f64, out: [Cell; 1], } impl Sizer { /// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R). pub fn new(risk_budget: f64) -> Self { assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0"); Self { risk_budget, out: [Cell::from_f64(0.0)] } } /// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "Sizer", NodeSchema { inputs: vec![ PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, ], output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }], }, |p| Box::new(Sizer::new(p[0].f64())), ) } } impl Node for Sizer { fn lookbacks(&self) -> Vec { vec![1, 1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let bias = ctx.f64_in(0); let dist = ctx.f64_in(1); if bias.is_empty() || dist.is_empty() { return None; // a size needs a (present) bias and a stop distance } let d = dist[0]; // a zero/degenerate stop distance yields zero size (no division blow-up); a real // stop rule emits a strictly positive distance, so this guards only warm-up edges. let size = if d > 0.0 { self.risk_budget / d } else { 0.0 }; self.out[0] = Cell::from_f64(size); Some(&self.out) } fn label(&self) -> String { format!("Sizer({})", self.risk_budget) } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar, Timestamp}; fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option { let mut c = vec![ AnyColumn::with_capacity(ScalarKind::F64, 1), AnyColumn::with_capacity(ScalarKind::F64, 1), ]; c[0].push(Scalar::f64(bias)).unwrap(); c[1].push(Scalar::f64(dist)).unwrap(); s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64()) } // (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance. #[test] fn sizer_is_flat_1r_invariant() { for budget in [1.0_f64, 2.5] { let mut s = Sizer::new(budget); for dist in [0.5_f64, 2.0, 10.0, 37.5] { let size = eval_once(&mut s, 1.0, dist).expect("both inputs present"); assert!( (size * dist - budget).abs() < 1e-9, "size*dist must equal risk_budget; budget={budget} dist={dist} size={size}" ); } } } #[test] #[should_panic(expected = "risk_budget must be > 0")] fn sizer_panics_on_zero_risk_budget() { let _ = Sizer::new(0.0); } #[test] #[should_panic(expected = "risk_budget must be > 0")] fn sizer_panics_on_negative_risk_budget() { let _ = Sizer::new(-1.0); } #[test] fn sizer_is_none_until_both_inputs_present() { let mut s = Sizer::new(1.0); let mut c = vec![ AnyColumn::with_capacity(ScalarKind::F64, 1), AnyColumn::with_capacity(ScalarKind::F64, 1), ]; // only bias present -> None c[0].push(Scalar::f64(1.0)).unwrap(); assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None); // both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1 c[1].push(Scalar::f64(10.0)).unwrap(); assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice())); } #[test] fn input_slots_are_named_bias_and_stop_distance() { let b = Sizer::builder(); let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["bias", "stop_distance"]); } }