1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::{Roc, Wma};
7
8#[derive(Debug, Clone)]
36pub struct Coppock {
37 roc_long_period: usize,
38 roc_short_period: usize,
39 wma_period: usize,
40 roc_long: Roc,
41 roc_short: Roc,
42 wma: Wma,
43 current: Option<f64>,
44}
45
46impl Coppock {
47 pub fn new(roc_long_period: usize, roc_short_period: usize, wma_period: usize) -> Result<Self> {
53 if roc_long_period == 0 || roc_short_period == 0 || wma_period == 0 {
54 return Err(Error::PeriodZero);
55 }
56 Ok(Self {
57 roc_long_period,
58 roc_short_period,
59 wma_period,
60 roc_long: Roc::new(roc_long_period)?,
61 roc_short: Roc::new(roc_short_period)?,
62 wma: Wma::new(wma_period)?,
63 current: None,
64 })
65 }
66
67 pub const fn periods(&self) -> (usize, usize, usize) {
69 (self.roc_long_period, self.roc_short_period, self.wma_period)
70 }
71
72 pub const fn value(&self) -> Option<f64> {
74 self.current
75 }
76}
77
78impl Indicator for Coppock {
79 type Input = f64;
80 type Output = f64;
81
82 fn update(&mut self, input: f64) -> Option<f64> {
83 if !input.is_finite() {
84 return self.current;
86 }
87 let long = self.roc_long.update(input);
88 let short = self.roc_short.update(input);
89 let result = match (long, short) {
90 (Some(l), Some(s)) => self.wma.update(l + s),
91 _ => None,
92 };
93 if result.is_some() {
94 self.current = result;
95 }
96 result
97 }
98
99 fn reset(&mut self) {
100 self.roc_long.reset();
101 self.roc_short.reset();
102 self.wma.reset();
103 self.current = None;
104 }
105
106 fn warmup_period(&self) -> usize {
107 self.roc_long_period.max(self.roc_short_period) + self.wma_period
122 }
123
124 fn is_ready(&self) -> bool {
125 self.current.is_some()
126 }
127
128 fn name(&self) -> &'static str {
129 "Coppock"
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::traits::BatchExt;
137 use approx::assert_relative_eq;
138
139 #[test]
140 fn new_rejects_zero_period() {
141 assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero)));
142 assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero)));
143 assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
144 }
145
146 #[test]
151 fn accessors_and_metadata() {
152 let mut c = Coppock::new(14, 11, 10).unwrap();
153 assert_eq!(c.periods(), (14, 11, 10));
154 assert_eq!(c.name(), "Coppock");
155 assert_eq!(c.value(), None);
156 for i in 1..=u32::try_from(c.warmup_period()).unwrap() {
158 c.update(100.0 + f64::from(i));
159 }
160 assert!(c.value().is_some());
161 }
162
163 #[test]
164 fn first_emission_at_warmup_period() {
165 let mut c = Coppock::new(6, 4, 3).unwrap();
166 assert_eq!(c.warmup_period(), 9);
167 let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
168 for v in out.iter().take(8) {
169 assert!(v.is_none());
170 }
171 assert!(out[8].is_some());
172 }
173
174 #[test]
182 fn warmup_period_matches_first_some_for_every_parameter_set() {
183 let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
184 for &(long, short, wma) in &[(6, 4, 3), (14, 11, 10), (4, 2, 3), (10, 3, 5), (3, 3, 3)] {
185 let mut c = Coppock::new(long, short, wma).unwrap();
186 let warmup = c.warmup_period();
187 let out = c.batch(&prices);
188 for (i, v) in out.iter().enumerate().take(warmup - 1) {
189 assert!(
190 v.is_none(),
191 "Coppock({long}, {short}, {wma}): index {i} expected None during warmup, got {v:?}"
192 );
193 }
194 assert!(
195 out[warmup - 1].is_some(),
196 "Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
197 );
198 }
199 }
200
201 #[test]
202 fn constant_series_yields_zero() {
203 let mut c = Coppock::new(6, 4, 3).unwrap();
205 let out = c.batch(&[100.0; 40]);
206 for v in out.iter().skip(c.warmup_period() - 1).flatten() {
207 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
208 }
209 }
210
211 #[test]
212 fn uptrend_is_positive() {
213 let mut c = Coppock::new(14, 11, 10).unwrap();
215 let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
216 let out = c.batch(&prices);
217 let last = out.iter().rev().flatten().next().unwrap();
218 assert!(
219 *last > 0.0,
220 "uptrend Coppock should be positive, got {last}"
221 );
222 }
223
224 #[test]
225 fn ignores_non_finite_input() {
226 let mut c = Coppock::new(6, 4, 3).unwrap();
227 let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
228 let last = *out.last().unwrap();
229 assert!(last.is_some());
230 assert_eq!(c.update(f64::NAN), last);
231 assert_eq!(c.update(f64::INFINITY), last);
232 }
233
234 #[test]
235 fn reset_clears_state() {
236 let mut c = Coppock::new(6, 4, 3).unwrap();
237 c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
238 assert!(c.is_ready());
239 c.reset();
240 assert!(!c.is_ready());
241 assert_eq!(c.update(1.0), None);
242 }
243
244 #[test]
245 fn batch_equals_streaming() {
246 let prices: Vec<f64> = (1..=120)
247 .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0)
248 .collect();
249 let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices);
250 let mut b = Coppock::new(14, 11, 10).unwrap();
251 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
252 assert_eq!(batch, streamed);
253 }
254}