Skip to main content

wickra_core/indicators/
wave_pm.rs

1//! Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::ema::Ema;
7use crate::traits::Indicator;
8
9/// Wave PM (Peak Momentum): a `0..100` statistic that rises when the current
10/// `length`-bar momentum is large relative to its own recent energy — Cynthia
11/// Kase's gauge of how "peaked" the move is.
12///
13/// ```text
14/// m       = close_t - close_{t-length}                  (length-bar momentum)
15/// energy  = EMA(m^2, length)                            (mean squared momentum)
16/// raw     = 1 - exp( -m^2 / (2 * energy) )      (0 if energy == 0)
17/// WavePM  = 100 * EMA(raw, smoothing)
18/// ```
19///
20/// The momentum `m` is normalised by its recent variance (`energy`): a move that
21/// merely matches its typical energy sits at the baseline
22/// `100·(1 − e^{−1/2}) ≈ 39.35`, while a momentum *spike* that exceeds recent
23/// energy drives the reading toward `100`. A flat market (`m = 0`) reads `0`.
24/// High readings mark a peaking, possibly exhausted move rather than a fresh one.
25///
26/// Kase's published `WavePM` is platform-specific; this is Wickra's faithful
27/// reconstruction of its variance-normalised peak-momentum form. The exact
28/// constants differ from any single vendor implementation, but the shape — flat
29/// at zero, a fixed baseline on a steady trend, and saturation on an
30/// acceleration — matches the indicator's intent.
31///
32/// Reference: Cynthia Kase, *Trading with the Odds*, 1996 (Wickra reconstruction).
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, WavePm};
38///
39/// let mut indicator = WavePm::new(10, 3).unwrap();
40/// let mut last = None;
41/// for i in 0..60 {
42///     last = indicator.update(100.0 + f64::from(i));
43/// }
44/// assert!(last.is_some());
45/// ```
46#[derive(Debug, Clone)]
47pub struct WavePm {
48    length: usize,
49    smoothing: usize,
50    closes: VecDeque<f64>,
51    energy_ema: Ema,
52    smooth_ema: Ema,
53}
54
55impl WavePm {
56    /// Construct a Wave PM with the momentum `length` and the output `smoothing`
57    /// period.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `length == 0` or `smoothing == 0`.
62    pub fn new(length: usize, smoothing: usize) -> Result<Self> {
63        if length == 0 {
64            return Err(Error::PeriodZero);
65        }
66        if length > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            length,
73            smoothing,
74            closes: VecDeque::with_capacity(length + 1),
75            energy_ema: Ema::new(length)?,
76            smooth_ema: Ema::new(smoothing)?,
77        })
78    }
79
80    /// Configured `(length, smoothing)`.
81    pub const fn periods(&self) -> (usize, usize) {
82        (self.length, self.smoothing)
83    }
84}
85
86impl Indicator for WavePm {
87    type Input = f64;
88    type Output = f64;
89
90    #[inline]
91    fn update(&mut self, close: f64) -> Option<f64> {
92        if !close.is_finite() {
93            // There was no guard here at all, so a single NaN entered the
94            // window and poisoned every value that followed it.
95            return None;
96        }
97        self.closes.push_back(close);
98        if self.closes.len() > self.length + 1 {
99            self.closes.pop_front();
100        }
101        if self.closes.len() <= self.length {
102            return None;
103        }
104
105        let oldest = *self.closes.front().unwrap_or(&close);
106        let momentum = close - oldest;
107        let energy = self.energy_ema.update(momentum * momentum)?;
108        let raw = if energy <= 0.0 {
109            0.0
110        } else {
111            1.0 - (-(momentum * momentum) / (2.0 * energy)).exp()
112        };
113        self.smooth_ema.update(raw).map(|v| v * 100.0)
114    }
115
116    fn reset(&mut self) {
117        self.closes.clear();
118        self.energy_ema.reset();
119        self.smooth_ema.reset();
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        2 * self.length + self.smoothing - 1
125    }
126
127    #[inline]
128    fn is_ready(&self) -> bool {
129        self.smooth_ema.is_ready()
130    }
131
132    #[inline]
133    fn name(&self) -> &'static str {
134        "WavePm"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    #[test]
145    fn rejects_zero_period() {
146        assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero)));
147        assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero)));
148    }
149
150    #[test]
151    fn accessors_and_metadata() {
152        let w = WavePm::new(10, 3).unwrap();
153        assert_eq!(w.periods(), (10, 3));
154        // 2*10 + 3 - 1 = 22.
155        assert_eq!(w.warmup_period(), 22);
156        assert_eq!(w.name(), "WavePm");
157        assert!(!w.is_ready());
158    }
159
160    #[test]
161    fn warmup_emits_at_expected_bar() {
162        let mut w = WavePm::new(3, 2).unwrap();
163        // warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6).
164        let inputs: Vec<f64> = (0..12).map(f64::from).collect();
165        let out = w.batch(&inputs);
166        assert!(out[5].is_none());
167        assert!(out[6].is_some());
168    }
169
170    #[test]
171    fn flat_market_reads_zero() {
172        let mut w = WavePm::new(4, 2).unwrap();
173        let inputs = [50.0; 20];
174        let last = w.batch(&inputs).last().unwrap().unwrap();
175        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
176    }
177
178    #[test]
179    fn steady_trend_reads_baseline() {
180        // Constant-slope ramp: momentum equals its own energy every bar, so the
181        // reading pins to the baseline 100*(1 - e^-0.5).
182        let mut w = WavePm::new(10, 3).unwrap();
183        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
184        let last = w.batch(&inputs).last().unwrap().unwrap();
185        let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
186        assert_relative_eq!(last, baseline, epsilon = 1e-9);
187    }
188
189    #[test]
190    fn acceleration_reads_above_baseline() {
191        // A quadratic path: momentum keeps outrunning its lagged energy, so the
192        // reading sits above the steady-trend baseline.
193        let mut w = WavePm::new(10, 3).unwrap();
194        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i * i) * 0.1).collect();
195        let last = w.batch(&inputs).last().unwrap().unwrap();
196        let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
197        assert!(
198            last > baseline,
199            "accelerating wpm {last} should exceed {baseline}"
200        );
201        assert!(last <= 100.0, "wpm {last} must stay <= 100");
202    }
203
204    #[test]
205    fn reset_clears_state() {
206        let mut w = WavePm::new(10, 3).unwrap();
207        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
208        w.batch(&inputs);
209        assert!(w.is_ready());
210        w.reset();
211        assert!(!w.is_ready());
212    }
213
214    #[test]
215    fn batch_equals_streaming() {
216        let inputs: Vec<f64> = (0..80)
217            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
218            .collect();
219        let mut a = WavePm::new(10, 3).unwrap();
220        let mut b = WavePm::new(10, 3).unwrap();
221        assert_eq!(
222            a.batch(&inputs),
223            inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
224        );
225    }
226}