Skip to main content

wickra_core/indicators/
coppock.rs

1//! Coppock Curve.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::{Roc, Wma};
7
8/// Coppock Curve — Edwin Coppock's long-term momentum indicator.
9///
10/// The Coppock Curve is a weighted moving average of the sum of two rates of
11/// change:
12///
13/// ```text
14/// Coppock = WMA( ROC(long) + ROC(short), wma_period )
15/// ```
16///
17/// Coppock designed it (1962) as a long-horizon buy signal for stock indices:
18/// on a monthly chart with the conventional `(long = 14, short = 11,
19/// wma_period = 10)`, a turn upward from below zero has historically marked
20/// the start of a new bull phase. The two ROCs blend a slightly longer and a
21/// slightly shorter momentum horizon; the WMA smooths the result.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, Coppock};
27///
28/// let mut indicator = Coppock::new(14, 11, 10).unwrap();
29/// let mut last = None;
30/// for i in 0..120 {
31///     last = indicator.update(100.0 + f64::from(i));
32/// }
33/// assert!(last.is_some());
34/// ```
35#[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    /// Construct a new Coppock Curve with the two ROC periods and the WMA period.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`Error::PeriodZero`] if any period is `0`.
52    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    /// The `(roc_long, roc_short, wma)` periods.
68    pub const fn periods(&self) -> (usize, usize, usize) {
69        (self.roc_long_period, self.roc_short_period, self.wma_period)
70    }
71
72    /// Current value if available.
73    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            // Non-finite input is ignored; no component is advanced.
86            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        // Let `L = max(roc_long_period, roc_short_period)` and `W = wma_period`.
110        // Both ROCs need `period + 1` inputs to emit; the slower one therefore
111        // first emits at **0-based index L** (= the `(L + 1)`-th input). From
112        // that bar onward both ROCs feed the WMA in lock-step, so the WMA
113        // sees its `W`-th input at 0-based index `L + W − 1` — the first bar
114        // it emits. `warmup_period` is the 1-based count of inputs needed for
115        // the first `Some` value, which is `(L + W − 1) + 1 = L + W`.
116        //
117        // Worked example for `Coppock::new(6, 4, 3)`:
118        //   - ROC(6).first_some at index 6 (the 7th input).
119        //   - ROC(4).first_some at index 4 (the 5th input). Both available
120        //     from index 6 onward.
121        //   - WMA(3) consumes 3 inputs at indices 6, 7, 8 → first WMA `Some`
122        //     at index 8 (the 9th input). `warmup_period() == 9`.
123        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    /// Cover the const accessors `periods` / `value` (lines 68-75) and the
151    /// Indicator-impl `name` body (128-130). Existing tests inspect numeric
152    /// output and `warmup_period` but never query the configured periods,
153    /// the current cached value, or the indicator name.
154    #[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        // Drive past warmup so value() flips to Some.
161        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    /// `warmup_period()` equals the 1-based index of the first emitted
179    /// `Some` for every legal parameter combination — including the
180    /// parameter set `(roc_long=4, roc_short=2, wma=3)` that an external
181    /// audit claimed would prove the formula off by one. It does not: the
182    /// slower ROC first emits at 0-based index 4, the WMA needs 3 such inputs
183    /// and emits at 0-based index 6 (the 7th input), which is what
184    /// `roc_long.max(roc_short) + wma = max(4, 2) + 3 = 7` reports.
185    #[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        // Both ROCs are 0 on a flat series, so the WMA of zeros is 0.
208        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        // A steady uptrend has positive ROCs, so the Coppock Curve is positive.
218        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}