//! `CarryCost` — a flat per-held-cycle carry/financing cost, in R. The simplest //! ACCRUAL cost node (C10): a cost incurred for every cycle a position is held, //! accruing over the hold rather than charged once at close. A labelled stress //! parameter (a constant price-unit carry), the flat base case of the accrual //! family. A `ConstantCost` twin differing only in `charge_mode() -> PerHeldCycle`; //! the shared `CostRunner` accrues, dumps at close, and marks the open position so //! the net_r_equity curve bleeds over the hold. use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind}; use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner}; /// A flat per-held-cycle carry in price units (`carry_per_cycle`), emitted in R via the /// shared [`CostRunner`] in its per-held-cycle (accrual) charge mode. pub struct CarryCost { carry_per_cycle: f64, } impl CarryCost { /// A flat per-held-cycle carry node: the factor wrapped in the shared [`CostRunner`]. pub fn new(carry_per_cycle: f64) -> CostRunner { assert!(carry_per_cycle >= 0.0, "CarryCost carry_per_cycle must be >= 0"); CostRunner::new(CarryCost { carry_per_cycle }) } /// The param-generic recipe: one `carry_per_cycle` F64 knob, no extra inputs. pub fn builder() -> PrimitiveBuilder { cost_node_builder( "CarryCost", Vec::new(), vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }], |p| Box::new(CarryCost::new(p[0].f64())), ) } } impl CostNode for CarryCost { fn label(&self) -> String { format!("CarryCost({})", self.carry_per_cycle) } fn charge_mode(&self) -> ChargeMode { ChargeMode::PerHeldCycle } fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { self.carry_per_cycle } } #[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 charge_mode_is_per_held_cycle() { assert_eq!(CarryCost { carry_per_cycle: 1.0 }.charge_mode(), ChargeMode::PerHeldCycle); } #[test] fn accrues_each_held_cycle_then_dumps_at_close() { // per = 1/4 = 0.25. open, open, close -> open_cost 0.25, 0.5, then dump 0.75. let mut c = CarryCost::new(1.0); let mut a = cols(); a[0].push(Scalar::bool(false)).unwrap(); a[1].push(Scalar::bool(true)).unwrap(); a[2].push(Scalar::f64(100.0)).unwrap(); a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 assert_eq!( c.eval(Ctx::new(&a, Timestamp(0))), Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.25)].as_slice()) ); let mut b = cols(); b[0].push(Scalar::bool(false)).unwrap(); b[1].push(Scalar::bool(true)).unwrap(); b[2].push(Scalar::f64(100.0)).unwrap(); b[3].push(Scalar::f64(96.0)).unwrap(); assert_eq!( c.eval(Ctx::new(&b, Timestamp(1))), Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) ); let mut d = cols(); d[0].push(Scalar::bool(true)).unwrap(); d[1].push(Scalar::bool(false)).unwrap(); d[2].push(Scalar::f64(100.0)).unwrap(); d[3].push(Scalar::f64(96.0)).unwrap(); assert_eq!( c.eval(Ctx::new(&d, Timestamp(2))), Some([Cell::from_f64(0.75), Cell::from_f64(0.75), Cell::from_f64(0.0)].as_slice()) ); } #[test] fn label_carries_the_carry() { assert_eq!(CarryCost::new(0.5).label(), "CarryCost(0.5)"); } #[test] fn builder_exposes_one_param_no_extra() { let builder = CarryCost::builder(); let schema = builder.schema(); assert_eq!(schema.params.len(), 1); assert_eq!(schema.params[0].name, "carry_per_cycle"); // geometry-only inputs (4), no extras. assert_eq!(schema.inputs.len(), crate::cost::GEOMETRY_WIDTH); } #[test] #[should_panic(expected = "carry_per_cycle must be >= 0")] fn new_panics_on_negative_carry() { let _ = CarryCost::new(-1.0); } }