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        Ok(Self {
67            length,
68            smoothing,
69            closes: VecDeque::with_capacity(length + 1),
70            energy_ema: Ema::new(length)?,
71            smooth_ema: Ema::new(smoothing)?,
72        })
73    }
74
75    /// Configured `(length, smoothing)`.
76    pub const fn periods(&self) -> (usize, usize) {
77        (self.length, self.smoothing)
78    }
79}
80
81impl Indicator for WavePm {
82    type Input = f64;
83    type Output = f64;
84
85    fn update(&mut self, close: f64) -> Option<f64> {
86        self.closes.push_back(close);
87        if self.closes.len() > self.length + 1 {
88            self.closes.pop_front();
89        }
90        if self.closes.len() <= self.length {
91            return None;
92        }
93
94        let oldest = *self.closes.front().unwrap_or(&close);
95        let momentum = close - oldest;
96        let energy = self.energy_ema.update(momentum * momentum)?;
97        let raw = if energy <= 0.0 {
98            0.0
99        } else {
100            1.0 - (-(momentum * momentum) / (2.0 * energy)).exp()
101        };
102        self.smooth_ema.update(raw).map(|v| v * 100.0)
103    }
104
105    fn reset(&mut self) {
106        self.closes.clear();
107        self.energy_ema.reset();
108        self.smooth_ema.reset();
109    }
110
111    fn warmup_period(&self) -> usize {
112        2 * self.length + self.smoothing - 1
113    }
114
115    fn is_ready(&self) -> bool {
116        self.smooth_ema.is_ready()
117    }
118
119    fn name(&self) -> &'static str {
120        "WavePm"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    #[test]
131    fn rejects_zero_period() {
132        assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero)));
133        assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero)));
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let w = WavePm::new(10, 3).unwrap();
139        assert_eq!(w.periods(), (10, 3));
140        // 2*10 + 3 - 1 = 22.
141        assert_eq!(w.warmup_period(), 22);
142        assert_eq!(w.name(), "WavePm");
143        assert!(!w.is_ready());
144    }
145
146    #[test]
147    fn warmup_emits_at_expected_bar() {
148        let mut w = WavePm::new(3, 2).unwrap();
149        // warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6).
150        let inputs: Vec<f64> = (0..12).map(f64::from).collect();
151        let out = w.batch(&inputs);
152        assert!(out[5].is_none());
153        assert!(out[6].is_some());
154    }
155
156    #[test]
157    fn flat_market_reads_zero() {
158        let mut w = WavePm::new(4, 2).unwrap();
159        let inputs = [50.0; 20];
160        let last = w.batch(&inputs).last().unwrap().unwrap();
161        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
162    }
163
164    #[test]
165    fn steady_trend_reads_baseline() {
166        // Constant-slope ramp: momentum equals its own energy every bar, so the
167        // reading pins to the baseline 100*(1 - e^-0.5).
168        let mut w = WavePm::new(10, 3).unwrap();
169        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
170        let last = w.batch(&inputs).last().unwrap().unwrap();
171        let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
172        assert_relative_eq!(last, baseline, epsilon = 1e-9);
173    }
174
175    #[test]
176    fn acceleration_reads_above_baseline() {
177        // A quadratic path: momentum keeps outrunning its lagged energy, so the
178        // reading sits above the steady-trend baseline.
179        let mut w = WavePm::new(10, 3).unwrap();
180        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i * i) * 0.1).collect();
181        let last = w.batch(&inputs).last().unwrap().unwrap();
182        let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
183        assert!(
184            last > baseline,
185            "accelerating wpm {last} should exceed {baseline}"
186        );
187        assert!(last <= 100.0, "wpm {last} must stay <= 100");
188    }
189
190    #[test]
191    fn reset_clears_state() {
192        let mut w = WavePm::new(10, 3).unwrap();
193        let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
194        w.batch(&inputs);
195        assert!(w.is_ready());
196        w.reset();
197        assert!(!w.is_ready());
198    }
199
200    #[test]
201    fn batch_equals_streaming() {
202        let inputs: Vec<f64> = (0..80)
203            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
204            .collect();
205        let mut a = WavePm::new(10, 3).unwrap();
206        let mut b = WavePm::new(10, 3).unwrap();
207        assert_eq!(
208            a.batch(&inputs),
209            inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
210        );
211    }
212}