//! `ConstantCost` — a flat round-trip cost charged once per closed trade, in R. //! The simplest cost node of the C10 cost-model graph: a stateless [`CostNode`] //! factor whose price-unit numerator is a flat `cost_per_trade`. The shared //! [`CostRunner`] supplies the co-temporality skeleton (geometry gating, the //! `cost_per_trade / |entry - stop|` R-normalization, the closed/open charge, the //! running `cum`, the 3-field emit). R-pure: notional cancels (C10). use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind}; use crate::cost::{cost_node_builder, CostNode, CostRunner}; /// A flat per-trade cost in price units (`cost_per_trade`), emitted in R via the /// shared [`CostRunner`]. pub struct ConstantCost { cost_per_trade: f64, } impl ConstantCost { /// A flat per-trade cost node: the factor wrapped in the shared [`CostRunner`]. pub fn new(cost_per_trade: f64) -> CostRunner { assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0"); CostRunner::new(ConstantCost { cost_per_trade }) } /// The param-generic recipe: one `cost_per_trade` F64 knob, no extra inputs. pub fn builder() -> PrimitiveBuilder { cost_node_builder( "ConstantCost", Vec::new(), vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }], |p| Box::new(ConstantCost::new(p[0].f64())), ) } } impl CostNode for ConstantCost { fn label(&self) -> String { format!("ConstantCost({})", self.cost_per_trade) } fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { self.cost_per_trade } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Cell, Node, Scalar, Timestamp}; fn cols() -> Vec { vec![ AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed AnyColumn::with_capacity(ScalarKind::Bool, 1), // open AnyColumn::with_capacity(ScalarKind::F64, 1), // entry AnyColumn::with_capacity(ScalarKind::F64, 1), // stop ] } #[test] fn no_geometry_yet_withholds() { let mut c = ConstantCost::new(2.0); let inputs = cols(); // entry column empty assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None); } #[test] fn closed_charges_cost_per_trade_over_latched() { let mut c = ConstantCost::new(2.0); let mut inputs = cols(); inputs[0].push(Scalar::bool(true)).unwrap(); // closed inputs[1].push(Scalar::bool(false)).unwrap(); // not open inputs[2].push(Scalar::f64(100.0)).unwrap(); // entry inputs[3].push(Scalar::f64(96.0)).unwrap(); // stop -> latched 4.0 // cost_in_r = 2.0/4.0 = 0.5; cum = 0.5; open_cost_in_r = 0.0 assert_eq!( c.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.5), Cell::from_f64(0.5), Cell::from_f64(0.0)].as_slice()) ); } #[test] fn open_emits_would_be_cost_not_charged_to_cum() { let mut c = ConstantCost::new(2.0); let mut inputs = cols(); inputs[0].push(Scalar::bool(false)).unwrap(); // not closed inputs[1].push(Scalar::bool(true)).unwrap(); // open inputs[2].push(Scalar::f64(100.0)).unwrap(); inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0 // cost_in_r = 0 (no close); cum stays 0; open_cost_in_r = 0.5 assert_eq!( c.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) ); } #[test] fn zero_latched_contributes_no_cost() { let mut c = ConstantCost::new(2.0); let mut inputs = cols(); inputs[0].push(Scalar::bool(true)).unwrap(); inputs[1].push(Scalar::bool(false)).unwrap(); inputs[2].push(Scalar::f64(100.0)).unwrap(); inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0 -> no divide assert_eq!( c.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice()) ); } #[test] fn label_carries_the_cost() { // Parameterised sibling convention: the identifying param is in the label, // so two differently-priced cost nodes disambiguate in a trace. assert_eq!(ConstantCost::new(2.0).label(), "ConstantCost(2)"); assert_eq!(ConstantCost::new(0.5).label(), "ConstantCost(0.5)"); } #[test] #[should_panic(expected = "cost_per_trade must be >= 0")] fn new_panics_on_negative_cost() { let _ = ConstantCost::new(-1.0); } #[test] fn cum_accumulates_across_closes() { let mut c = ConstantCost::new(2.0); let mut a = cols(); a[0].push(Scalar::bool(true)).unwrap(); a[1].push(Scalar::bool(false)).unwrap(); a[2].push(Scalar::f64(100.0)).unwrap(); a[3].push(Scalar::f64(96.0)).unwrap(); // 0.5 let _ = c.eval(Ctx::new(&a, Timestamp(0))); let mut b = cols(); b[0].push(Scalar::bool(true)).unwrap(); b[1].push(Scalar::bool(false)).unwrap(); b[2].push(Scalar::f64(100.0)).unwrap(); b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2.0 -> 1.0; cum 1.5 assert_eq!( c.eval(Ctx::new(&b, Timestamp(1))), Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice()) ); } }