wickra_core/indicators/
cfo.rs1use crate::error::{Error, Result};
4use crate::indicators::linreg::LinearRegression;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
32pub struct Cfo {
33 period: usize,
34 linreg: LinearRegression,
35 current: Option<f64>,
36}
37
38impl Cfo {
39 pub fn new(period: usize) -> Result<Self> {
42 if period == 0 {
43 return Err(Error::PeriodZero);
44 }
45 if period > crate::error::MAX_PERIOD {
46 return Err(Error::InvalidPeriod {
47 message: crate::error::PERIOD_ABOVE_MAX,
48 });
49 }
50 Ok(Self {
51 period,
52 linreg: LinearRegression::new(period)?,
53 current: None,
54 })
55 }
56
57 pub const fn period(&self) -> usize {
59 self.period
60 }
61}
62
63impl Indicator for Cfo {
64 type Input = f64;
65 type Output = f64;
66
67 #[inline]
68 fn update(&mut self, input: f64) -> Option<f64> {
69 if !input.is_finite() {
70 return None;
71 }
72 let forecast = self.linreg.update(input)?;
73 if input == 0.0 {
76 return self.current;
77 }
78 let value = 100.0 * (input - forecast) / input;
79 self.current = Some(value);
80 Some(value)
81 }
82
83 fn reset(&mut self) {
84 self.linreg.reset();
85 self.current = None;
86 }
87
88 #[inline]
89 fn warmup_period(&self) -> usize {
90 self.period
91 }
92
93 #[inline]
94 fn is_ready(&self) -> bool {
95 self.current.is_some()
96 }
97
98 #[inline]
99 fn name(&self) -> &'static str {
100 "CFO"
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::traits::BatchExt;
108 use approx::assert_relative_eq;
109
110 #[test]
111 fn rejects_zero_period() {
112 assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
113 }
114
115 #[test]
116 fn accessors_and_metadata() {
117 let cfo = Cfo::new(14).unwrap();
118 assert_eq!(cfo.period(), 14);
119 assert_eq!(cfo.warmup_period(), 14);
120 assert_eq!(cfo.name(), "CFO");
121 }
122
123 #[test]
124 fn constant_series_yields_zero() {
125 let mut cfo = Cfo::new(5).unwrap();
128 let out = cfo.batch(&[42.0_f64; 30]);
129 for v in out.iter().skip(4).flatten() {
130 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
131 }
132 }
133
134 #[test]
135 fn perfect_linear_series_yields_zero() {
136 let mut cfo = Cfo::new(5).unwrap();
139 let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
140 let out = cfo.batch(&prices);
141 for v in out.iter().skip(4).flatten() {
142 assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
143 }
144 }
145
146 #[test]
147 fn warmup_emits_first_value_at_period() {
148 let mut cfo = Cfo::new(3).unwrap();
149 for i in 1..=2 {
150 assert_eq!(cfo.update(f64::from(i)), None);
151 }
152 assert!(cfo.update(3.0).is_some());
153 }
154
155 #[test]
156 fn batch_equals_streaming() {
157 let prices: Vec<f64> = (1..=80)
158 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
159 .collect();
160 let mut a = Cfo::new(14).unwrap();
161 let mut b = Cfo::new(14).unwrap();
162 assert_eq!(
163 a.batch(&prices),
164 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
165 );
166 }
167
168 #[test]
169 fn reset_clears_state() {
170 let mut cfo = Cfo::new(5).unwrap();
171 cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
172 assert!(cfo.is_ready());
173 cfo.reset();
174 assert!(!cfo.is_ready());
175 assert_eq!(cfo.update(1.0), None);
176 }
177
178 #[test]
179 fn zero_close_holds_value() {
180 let mut cfo = Cfo::new(3).unwrap();
181 cfo.batch(&[1.0_f64, 2.0, 3.0]);
182 let before = cfo.current;
183 assert_eq!(cfo.update(0.0), before);
184 }
185}