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    fn update(&mut self, input: f64) -> Option<f64> {
83        if !input.is_finite() {
84            // Non-finite input is ignored; no component is advanced.
85            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        // Let `L = max(roc_long_period, roc_short_period)` and `W = wma_period`.
108        // Both ROCs need `period + 1` inputs to emit; the slower one therefore
109        // first emits at **0-based index L** (= the `(L + 1)`-th input). From
110        // that bar onward both ROCs feed the WMA in lock-step, so the WMA
111        // sees its `W`-th input at 0-based index `L + W − 1` — the first bar
112        // it emits. `warmup_period` is the 1-based count of inputs needed for
113        // the first `Some` value, which is `(L + W − 1) + 1 = L + W`.
114        //
115        // Worked example for `Coppock::new(6, 4, 3)`:
116        //   - ROC(6).first_some at index 6 (the 7th input).
117        //   - ROC(4).first_some at index 4 (the 5th input). Both available
118        //     from index 6 onward.
119        //   - WMA(3) consumes 3 inputs at indices 6, 7, 8 → first WMA `Some`
120        //     at index 8 (the 9th input). `warmup_period() == 9`.
121        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    /// Cover the const accessors `periods` / `value` (lines 68-75) and the
147    /// Indicator-impl `name` body (128-130). Existing tests inspect numeric
148    /// output and `warmup_period` but never query the configured periods,
149    /// the current cached value, or the indicator name.
150    #[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        // Drive past warmup so value() flips to Some.
157        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    /// `warmup_period()` equals the 1-based index of the first emitted
175    /// `Some` for every legal parameter combination — including the
176    /// parameter set `(roc_long=4, roc_short=2, wma=3)` that an external
177    /// audit claimed would prove the formula off by one. It does not: the
178    /// slower ROC first emits at 0-based index 4, the WMA needs 3 such inputs
179    /// and emits at 0-based index 6 (the 7th input), which is what
180    /// `roc_long.max(roc_short) + wma = max(4, 2) + 3 = 7` reports.
181    #[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        // Both ROCs are 0 on a flat series, so the WMA of zeros is 0.
204        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        // A steady uptrend has positive ROCs, so the Coppock Curve is positive.
214        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}