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 #[inline]
83 fn update(&mut self, input: f64) -> Option<f64> {
84 if !input.is_finite() {
85 return None;
87 }
88 let long = self.roc_long.update(input);
89 let short = self.roc_short.update(input);
90 let result = match (long, short) {
91 (Some(l), Some(s)) => self.wma.update(l + s),
92 _ => None,
93 };
94 if result.is_some() {
95 self.current = result;
96 }
97 result
98 }
99
100 fn reset(&mut self) {
101 self.roc_long.reset();
102 self.roc_short.reset();
103 self.wma.reset();
104 self.current = None;
105 }
106
107 #[inline]
108 fn warmup_period(&self) -> usize {
109 self.roc_long_period.max(self.roc_short_period) + self.wma_period
124 }
125
126 #[inline]
127 fn is_ready(&self) -> bool {
128 self.current.is_some()
129 }
130
131 #[inline]
132 fn name(&self) -> &'static str {
133 "Coppock"
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::traits::BatchExt;
141 use approx::assert_relative_eq;
142
143 #[test]
144 fn new_rejects_zero_period() {
145 assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero)));
146 assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero)));
147 assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
148 }
149
150 #[test]
155 fn accessors_and_metadata() {
156 let mut c = Coppock::new(14, 11, 10).unwrap();
157 assert_eq!(c.periods(), (14, 11, 10));
158 assert_eq!(c.name(), "Coppock");
159 assert_eq!(c.value(), None);
160 for i in 1..=u32::try_from(c.warmup_period()).unwrap() {
162 c.update(100.0 + f64::from(i));
163 }
164 assert!(c.value().is_some());
165 }
166
167 #[test]
168 fn first_emission_at_warmup_period() {
169 let mut c = Coppock::new(6, 4, 3).unwrap();
170 assert_eq!(c.warmup_period(), 9);
171 let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
172 for v in out.iter().take(8) {
173 assert!(v.is_none());
174 }
175 assert!(out[8].is_some());
176 }
177
178 #[test]
186 fn warmup_period_matches_first_some_for_every_parameter_set() {
187 let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
188 for &(long, short, wma) in &[(6, 4, 3), (14, 11, 10), (4, 2, 3), (10, 3, 5), (3, 3, 3)] {
189 let mut c = Coppock::new(long, short, wma).unwrap();
190 let warmup = c.warmup_period();
191 let out = c.batch(&prices);
192 for (i, v) in out.iter().enumerate().take(warmup - 1) {
193 assert!(
194 v.is_none(),
195 "Coppock({long}, {short}, {wma}): index {i} expected None during warmup, got {v:?}"
196 );
197 }
198 assert!(
199 out[warmup - 1].is_some(),
200 "Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
201 );
202 }
203 }
204
205 #[test]
206 fn constant_series_yields_zero() {
207 let mut c = Coppock::new(6, 4, 3).unwrap();
209 let out = c.batch(&[100.0; 40]);
210 for v in out.iter().skip(c.warmup_period() - 1).flatten() {
211 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
212 }
213 }
214
215 #[test]
216 fn uptrend_is_positive() {
217 let mut c = Coppock::new(14, 11, 10).unwrap();
219 let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
220 let out = c.batch(&prices);
221 let last = out.iter().rev().flatten().next().unwrap();
222 assert!(
223 *last > 0.0,
224 "uptrend Coppock should be positive, got {last}"
225 );
226 }
227
228 #[test]
229 fn ignores_non_finite_input() {
230 let mut c = Coppock::new(6, 4, 3).unwrap();
231 let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
232 let last = *out.last().unwrap();
233 assert!(last.is_some());
234 assert_eq!(c.update(f64::NAN), None);
235 assert_eq!(c.update(f64::INFINITY), None);
236 }
237
238 #[test]
239 fn reset_clears_state() {
240 let mut c = Coppock::new(6, 4, 3).unwrap();
241 c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
242 assert!(c.is_ready());
243 c.reset();
244 assert!(!c.is_ready());
245 assert_eq!(c.update(1.0), None);
246 }
247
248 #[test]
249 fn batch_equals_streaming() {
250 let prices: Vec<f64> = (1..=120)
251 .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0)
252 .collect();
253 let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices);
254 let mut b = Coppock::new(14, 11, 10).unwrap();
255 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
256 assert_eq!(batch, streamed);
257 }
258}