Skip to main content

wickra_core/indicators/
kvo.rs

1//! Klinger Volume Oscillator.
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Stephen J. Klinger's Volume Oscillator — a long/short-term volume-force
9/// MACD with trend-aware cumulative-money-flow weighting.
10///
11/// Each bar produces a "volume force" (`vf`) whose sign tracks the daily trend
12/// (`+1` on an up day, `−1` on a down day, carry-over otherwise) and whose
13/// magnitude scales with how the current bar's range compares to the range
14/// accumulated since the trend last flipped. The KVO line is the difference of two EMAs of `vf`:
15///
16/// ```text
17/// hlc_t  = high_t + low_t + close_t                     (decides the trend)
18/// trend  = sign(hlc_t − hlc_{t−1})   (carried over when equal)
19/// dm_t   = high_t − low_t                               (the "daily measurement")
20/// cm_t   = cm_{t−1} + dm_t          if trend unchanged
21/// cm_t   = dm_{t−1} + dm_t          if trend just flipped
22/// vf_t   = volume_t · |2·(dm_t/cm_t − 1)| · trend · 100
23/// KVO_t  = EMA(vf, fast)_t − EMA(vf, slow)_t
24/// ```
25///
26/// Klinger's textbook configuration is `fast = 34, slow = 55` on daily bars.
27/// The first bar only seeds `dm_{t−1}`, so the very first `vf` lands at bar 2;
28/// the slow EMA then needs `slow` raw `vf` values to seed, putting the first
29/// KVO emission at bar `slow + 1`. A zero `cm_t` (only possible when every bar
30/// since the last flip has a zero range) collapses `vf` to `0`.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, Kvo};
36///
37/// let mut indicator = Kvo::new(34, 55).unwrap();
38/// let mut last = None;
39/// for i in 0..120 {
40///     let base = 100.0 + f64::from(i);
41///     let candle =
42///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
43///     last = indicator.update(candle);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct Kvo {
49    fast_period: usize,
50    slow_period: usize,
51    fast: Ema,
52    slow: Ema,
53    prev_dm: Option<f64>,
54    prev_hlc: f64,
55    trend: i8,
56    cm: f64,
57}
58
59impl Kvo {
60    /// Construct a new KVO with the given EMA periods.
61    ///
62    /// # Errors
63    /// Returns [`Error::PeriodZero`] if either period is zero, or
64    /// [`Error::InvalidPeriod`] if `fast >= slow`.
65    pub fn new(fast: usize, slow: usize) -> Result<Self> {
66        if fast == 0 || slow == 0 {
67            return Err(Error::PeriodZero);
68        }
69        if fast >= slow {
70            return Err(Error::InvalidPeriod {
71                message: "KVO needs fast < slow",
72            });
73        }
74        Ok(Self {
75            fast_period: fast,
76            slow_period: slow,
77            fast: Ema::new(fast)?,
78            slow: Ema::new(slow)?,
79            prev_dm: None,
80            prev_hlc: 0.0,
81            trend: 0,
82            cm: 0.0,
83        })
84    }
85
86    /// Klinger's classic configuration: `EMA(vf, 34) − EMA(vf, 55)`.
87    pub fn classic() -> Self {
88        Self::new(34, 55).expect("classic Klinger periods are valid")
89    }
90
91    /// Configured `(fast, slow)` periods.
92    pub const fn periods(&self) -> (usize, usize) {
93        (self.fast_period, self.slow_period)
94    }
95}
96
97impl Indicator for Kvo {
98    type Input = Candle;
99    type Output = f64;
100
101    #[inline]
102    fn update(&mut self, candle: Candle) -> Option<f64> {
103        let hlc = candle.high + candle.low + candle.close;
104        let dm = candle.high - candle.low;
105        let Some(prev_dm) = self.prev_dm else {
106            // The first bar only establishes the previous bar's measurements.
107            self.prev_dm = Some(dm);
108            self.prev_hlc = hlc;
109            return None;
110        };
111
112        // The trend sign compares H + L + C with the previous bar's.
113        let new_trend: i8 = if hlc > self.prev_hlc {
114            1
115        } else if hlc < self.prev_hlc {
116            -1
117        } else {
118            self.trend
119        };
120
121        // Cumulative measurement resets to (prev_dm + dm) whenever the trend
122        // flips. On the very first sign read (trend was 0) we also seed from
123        // the two-bar sum, matching the textbook definition.
124        if new_trend != self.trend || self.trend == 0 {
125            self.cm = prev_dm + dm;
126        } else {
127            self.cm += dm;
128        }
129        self.trend = new_trend;
130
131        let vf = if self.cm == 0.0 {
132            // Zero-range stretch since the flip — no force to register.
133            0.0
134        } else {
135            candle.volume * (2.0 * (dm / self.cm - 1.0)).abs() * f64::from(new_trend) * 100.0
136        };
137
138        self.prev_dm = Some(dm);
139        self.prev_hlc = hlc;
140
141        let fast = self.fast.update(vf);
142        let slow = self.slow.update(vf);
143        Some(fast? - slow?)
144    }
145
146    fn reset(&mut self) {
147        self.fast.reset();
148        self.slow.reset();
149        self.prev_dm = None;
150        self.prev_hlc = 0.0;
151        self.trend = 0;
152        self.cm = 0.0;
153    }
154
155    #[inline]
156    fn warmup_period(&self) -> usize {
157        // One bar to seed `prev_dm`, then the slow EMA needs `slow` raw `vf` values.
158        self.slow_period + 1
159    }
160
161    #[inline]
162    fn is_ready(&self) -> bool {
163        self.fast.is_ready() && self.slow.is_ready()
164    }
165
166    #[inline]
167    fn name(&self) -> &'static str {
168        "KVO"
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::traits::BatchExt;
176    use approx::assert_relative_eq;
177
178    fn c(high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
179        Candle::new(low, high, low, close, volume, ts).unwrap()
180    }
181
182    #[test]
183    fn rejects_zero_period() {
184        assert!(matches!(Kvo::new(0, 10), Err(Error::PeriodZero)));
185        assert!(matches!(Kvo::new(3, 0), Err(Error::PeriodZero)));
186    }
187
188    #[test]
189    fn rejects_fast_geq_slow() {
190        assert!(matches!(Kvo::new(34, 34), Err(Error::InvalidPeriod { .. })));
191        assert!(matches!(Kvo::new(55, 34), Err(Error::InvalidPeriod { .. })));
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let k = Kvo::classic();
197        assert_eq!(k.periods(), (34, 55));
198        assert_eq!(k.name(), "KVO");
199        assert_eq!(k.warmup_period(), 56);
200    }
201
202    #[test]
203    fn zero_ohlc_collapses_vf_to_zero() {
204        // Two consecutive all-zero bars: dm = 0 for both, so prev_dm + dm = 0
205        // and `cm == 0.0` fires the defensive branch, holding vf at zero.
206        let mut k = Kvo::new(3, 6).unwrap();
207        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 100.0, 0).unwrap();
208        assert_eq!(k.update(zero), None);
209        assert_eq!(k.update(zero), None);
210        assert_eq!(k.update(zero), None);
211    }
212
213    #[test]
214    fn constant_series_yields_zero() {
215        // dm flat -> trend never sets to a nonzero sign and vf collapses to 0
216        // for every bar; both EMAs hold at 0 once seeded.
217        let candles: Vec<Candle> = (0..120).map(|i| c(10.0, 10.0, 10.0, 100.0, i)).collect();
218        let mut k = Kvo::new(3, 6).unwrap();
219        for v in k.batch(&candles).into_iter().flatten() {
220            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
221        }
222    }
223
224    #[test]
225    fn warmup_emits_at_slow_plus_one() {
226        let candles: Vec<Candle> = (0..30i64)
227            .map(|i| {
228                let f = i as f64;
229                c(10.0 + f, 8.0 + f, 9.0 + f, 100.0, i)
230            })
231            .collect();
232        let mut k = Kvo::new(3, 5).unwrap();
233        let out = k.batch(&candles);
234        for (i, v) in out.iter().enumerate().take(5) {
235            assert!(v.is_none(), "index {i} must be None during warmup");
236        }
237        // First emission lands at index slow_period (one seed bar + slow EMA seeding from there).
238        assert!(out[5].is_some(), "first value lands at slow_period");
239    }
240
241    #[test]
242    fn batch_equals_streaming() {
243        let candles: Vec<Candle> = (0..100i64)
244            .map(|i| {
245                let f = i as f64;
246                let mid = 100.0 + (f * 0.2).sin() * 4.0;
247                c(mid + 1.0, mid - 1.0, mid, 10.0 + ((i % 5) as f64), i)
248            })
249            .collect();
250        let mut a = Kvo::classic();
251        let mut b = Kvo::classic();
252        assert_eq!(
253            a.batch(&candles),
254            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
255        );
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let candles: Vec<Candle> = (0..80i64)
261            .map(|i| {
262                let f = i as f64;
263                c(11.0 + f, 9.0 + f, 10.0 + f, 100.0, i)
264            })
265            .collect();
266        let mut k = Kvo::classic();
267        k.batch(&candles);
268        assert!(k.is_ready());
269        k.reset();
270        assert!(!k.is_ready());
271        assert_eq!(k.update(candles[0]), None);
272    }
273
274    fn wave(len: i64) -> Vec<Candle> {
275        (0..len)
276            .map(|i| {
277                let step = f64::from(i32::try_from(i).unwrap());
278                let mid = 100.0 + (step * 0.31).sin() * 4.0;
279                let half = 0.6 + (step * 0.17).cos().abs();
280                c(
281                    mid + half,
282                    mid - half,
283                    mid + 0.3 * half,
284                    50.0 + (step * 0.7).sin() * 20.0,
285                    i,
286                )
287            })
288            .collect()
289    }
290
291    #[test]
292    fn rejects_period_above_max() {
293        let too_big = crate::error::MAX_PERIOD + 1;
294        assert!(matches!(
295            Kvo::new(3, too_big),
296            Err(Error::InvalidPeriod { .. })
297        ));
298    }
299
300    #[test]
301    fn hand_computed_reference() {
302        // fast EMA(2) alpha 2/3, slow EMA(3) alpha 1/2; both seeded with the mean.
303        //   bar  H    L    C     V    hlc   dm   trend  cm               vf
304        //   b0   10   8    9     100  27    2    seed
305        //   b1   11   9    10    100  30    2    +1     2 + 2 = 4 (first) 100·|2(2/4 − 1)|·100  = 10000
306        //   b2   12   9    11    200  32    3    +1     4 + 3 = 7         200·|2(3/7 − 1)|·100  = 160000/7
307        //   b3   11   10   10.5  100  31.5  1    −1     3 + 1 = 4 (flip)  −100·|2(1/4 − 1)|·100 = −15000
308        //   b4   11   10   10.5  100  31.5  1    −1     4 + 1 = 5 (carry) −100·|2(1/5 − 1)|·100 = −16000
309        //   b5   12   9.5  11    100  32.5  2.5  +1     1 + 2.5 = 3.5     100·|2(2.5/3.5 − 1)|·100 = 40000/7
310        // fast: f2 = (10000 + 160000/7)/2 = 115000/7
311        //       f3 = 2/3·(−15000) + 1/3·f2 = −95000/21
312        //       f4 = 2/3·(−16000) + 1/3·f3 = −767000/63
313        // slow: s3 = (10000 + 160000/7 − 15000)/3 = 125000/21
314        //       s4 = 1/2·(−16000) + 1/2·s3 = −316500/63
315        // KVO3 = f3 − s3 = −220000/21, KVO4 = f4 − s4 = −450500/63
316        // b5:  f5 = 2/3·40000/7 + 1/3·f4, s5 = 1/2·40000/7 + 1/2·s4
317        let candles = [
318            c(10.0, 8.0, 9.0, 100.0, 0),
319            c(11.0, 9.0, 10.0, 100.0, 1),
320            c(12.0, 9.0, 11.0, 200.0, 2),
321            c(11.0, 10.0, 10.5, 100.0, 3),
322            c(11.0, 10.0, 10.5, 100.0, 4),
323            c(12.0, 9.5, 11.0, 100.0, 5),
324        ];
325        let mut k = Kvo::new(2, 3).unwrap();
326        assert_eq!(k.warmup_period(), 4);
327        let out = k.batch(&candles);
328        assert!(out[..3].iter().all(Option::is_none));
329        assert_relative_eq!(out[3].unwrap(), -220_000.0 / 21.0, max_relative = 1e-12);
330        assert_relative_eq!(out[4].unwrap(), -450_500.0 / 63.0, max_relative = 1e-12);
331        let f5 = 2.0 / 3.0 * (40_000.0 / 7.0) + (-767_000.0 / 63.0) / 3.0;
332        let s5 = 0.5 * (40_000.0 / 7.0) + 0.5 * (-316_500.0 / 63.0);
333        assert_relative_eq!(out[5].unwrap(), f5 - s5, max_relative = 1e-12);
334    }
335
336    #[test]
337    fn zero_volume_yields_zero() {
338        // vf scales with volume, so a zero-volume series gives KVO = 0.
339        let candles: Vec<Candle> = wave(30)
340            .into_iter()
341            .map(|x| Candle::new(x.open, x.high, x.low, x.close, 0.0, x.timestamp).unwrap())
342            .collect();
343        let out = Kvo::new(3, 5).unwrap().batch(&candles);
344        assert!(out[5..].iter().all(|v| v.is_some_and(|x| x.abs() < 1e-12)));
345    }
346
347    #[test]
348    fn zero_range_after_a_flip_registers_no_force() {
349        // b1 sets an up trend, b2 flips down with b1 and b2 both zero-range,
350        // so cm = 0 + 0 and vf is 0 instead of a division by zero.
351        let mut k = Kvo::new(1, 2).unwrap();
352        assert_eq!(k.update(c(10.0, 8.0, 9.0, 100.0, 0)), None);
353        assert_eq!(k.update(c(11.0, 11.0, 11.0, 100.0, 1)), None);
354        // vf1 = 100·|2(0/2 − 1)|·100 = 20000, vf2 = 0 -> fast = 0, slow = 10000.
355        let v = k.update(c(10.0, 10.0, 10.0, 100.0, 2)).unwrap();
356        assert_relative_eq!(v, -10_000.0, epsilon = 1e-9);
357    }
358
359    #[test]
360    fn reset_reproduces_a_fresh_run() {
361        let candles = wave(80);
362        let mut k = Kvo::new(5, 13).unwrap();
363        let first = k.batch(&candles);
364        k.reset();
365        let second = k.batch(&candles);
366        assert_eq!(first, second);
367        assert_eq!(second, Kvo::new(5, 13).unwrap().batch(&candles));
368    }
369
370    #[test]
371    fn batch_nan_into_matches_streaming_bits() {
372        let candles = wave(80);
373        let mut streaming = Kvo::new(5, 13).unwrap();
374        let expected: Vec<u64> = candles
375            .iter()
376            .map(|x| streaming.update(*x).unwrap_or(f64::NAN).to_bits())
377            .collect();
378        let mut out = vec![0.0; candles.len()];
379        Kvo::new(5, 13).unwrap().batch_nan_into(&candles, &mut out);
380        let got: Vec<u64> = out.iter().map(|v| v.to_bits()).collect();
381        assert_eq!(got, expected);
382    }
383}