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 accumulation horizon compares to the
14/// previous trend's. The KVO line is the difference of two EMAs of `vf`:
15///
16/// ```text
17/// dm_t   = high_t + low_t + close_t                                            (the "daily measurement")
18/// trend  = sign(dm_t − dm_{t−1})    if differs from previous trend, reset cm
19/// cm_t   = cm_{t−1} + dm_t          if trend unchanged
20/// cm_t   = dm_{t−1} + dm_t          if trend just flipped
21/// vf_t   = volume_t · |2·(dm_t/cm_t − 1)| · trend · 100
22/// KVO_t  = EMA(vf, fast)_t − EMA(vf, slow)_t
23/// ```
24///
25/// Klinger's textbook configuration is `fast = 34, slow = 55` on daily bars.
26/// The first bar only seeds `dm_{t−1}`, so the very first `vf` lands at bar 2;
27/// the slow EMA then needs `slow` raw `vf` values to seed, putting the first
28/// KVO emission at bar `slow + 1`. A zero `cm_t` (which only happens on the
29/// trend-flip branch when both the prior and current `dm` are zero) collapses
30/// `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    trend: i8,
55    cm: f64,
56}
57
58impl Kvo {
59    /// Construct a new KVO with the given EMA periods.
60    ///
61    /// # Errors
62    /// Returns [`Error::PeriodZero`] if either period is zero, or
63    /// [`Error::InvalidPeriod`] if `fast >= slow`.
64    pub fn new(fast: usize, slow: usize) -> Result<Self> {
65        if fast == 0 || slow == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if fast >= slow {
69            return Err(Error::InvalidPeriod {
70                message: "KVO needs fast < slow",
71            });
72        }
73        Ok(Self {
74            fast_period: fast,
75            slow_period: slow,
76            fast: Ema::new(fast)?,
77            slow: Ema::new(slow)?,
78            prev_dm: None,
79            trend: 0,
80            cm: 0.0,
81        })
82    }
83
84    /// Klinger's classic configuration: `EMA(vf, 34) − EMA(vf, 55)`.
85    pub fn classic() -> Self {
86        Self::new(34, 55).expect("classic Klinger periods are valid")
87    }
88
89    /// Configured `(fast, slow)` periods.
90    pub const fn periods(&self) -> (usize, usize) {
91        (self.fast_period, self.slow_period)
92    }
93}
94
95impl Indicator for Kvo {
96    type Input = Candle;
97    type Output = f64;
98
99    #[inline]
100    fn update(&mut self, candle: Candle) -> Option<f64> {
101        let dm = candle.high + candle.low + candle.close;
102        let Some(prev_dm) = self.prev_dm else {
103            // The first bar only establishes the previous daily measurement.
104            self.prev_dm = Some(dm);
105            return None;
106        };
107
108        // Determine the bar's trend sign relative to the previous bar.
109        let new_trend: i8 = if dm > prev_dm {
110            1
111        } else if dm < prev_dm {
112            -1
113        } else {
114            self.trend
115        };
116
117        // Cumulative measurement resets to (prev_dm + dm) whenever the trend
118        // flips. On the very first sign read (trend was 0) we also seed from
119        // the two-bar sum, matching the textbook definition.
120        if new_trend != self.trend || self.trend == 0 {
121            self.cm = prev_dm + dm;
122        } else {
123            self.cm += dm;
124        }
125        self.trend = new_trend;
126
127        let vf = if self.cm == 0.0 {
128            // Pathological all-zero OHLC stretch — no force to register.
129            0.0
130        } else {
131            candle.volume * (2.0 * (dm / self.cm - 1.0)).abs() * f64::from(new_trend) * 100.0
132        };
133
134        self.prev_dm = Some(dm);
135
136        let fast = self.fast.update(vf);
137        let slow = self.slow.update(vf);
138        Some(fast? - slow?)
139    }
140
141    fn reset(&mut self) {
142        self.fast.reset();
143        self.slow.reset();
144        self.prev_dm = None;
145        self.trend = 0;
146        self.cm = 0.0;
147    }
148
149    #[inline]
150    fn warmup_period(&self) -> usize {
151        // One bar to seed `prev_dm`, then the slow EMA needs `slow` raw `vf` values.
152        self.slow_period + 1
153    }
154
155    #[inline]
156    fn is_ready(&self) -> bool {
157        self.fast.is_ready() && self.slow.is_ready()
158    }
159
160    #[inline]
161    fn name(&self) -> &'static str {
162        "KVO"
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::traits::BatchExt;
170    use approx::assert_relative_eq;
171
172    fn c(high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
173        Candle::new(low, high, low, close, volume, ts).unwrap()
174    }
175
176    #[test]
177    fn rejects_zero_period() {
178        assert!(matches!(Kvo::new(0, 10), Err(Error::PeriodZero)));
179        assert!(matches!(Kvo::new(3, 0), Err(Error::PeriodZero)));
180    }
181
182    #[test]
183    fn rejects_fast_geq_slow() {
184        assert!(matches!(Kvo::new(34, 34), Err(Error::InvalidPeriod { .. })));
185        assert!(matches!(Kvo::new(55, 34), Err(Error::InvalidPeriod { .. })));
186    }
187
188    #[test]
189    fn accessors_and_metadata() {
190        let k = Kvo::classic();
191        assert_eq!(k.periods(), (34, 55));
192        assert_eq!(k.name(), "KVO");
193        assert_eq!(k.warmup_period(), 56);
194    }
195
196    #[test]
197    fn zero_ohlc_collapses_vf_to_zero() {
198        // Two consecutive all-zero bars: dm = 0 for both, so prev_dm + dm = 0
199        // and `cm == 0.0` fires the defensive branch, holding vf at zero.
200        let mut k = Kvo::new(3, 6).unwrap();
201        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 100.0, 0).unwrap();
202        assert_eq!(k.update(zero), None);
203        assert_eq!(k.update(zero), None);
204        assert_eq!(k.update(zero), None);
205    }
206
207    #[test]
208    fn constant_series_yields_zero() {
209        // dm flat -> trend never sets to a nonzero sign and vf collapses to 0
210        // for every bar; both EMAs hold at 0 once seeded.
211        let candles: Vec<Candle> = (0..120).map(|i| c(10.0, 10.0, 10.0, 100.0, i)).collect();
212        let mut k = Kvo::new(3, 6).unwrap();
213        for v in k.batch(&candles).into_iter().flatten() {
214            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
215        }
216    }
217
218    #[test]
219    fn warmup_emits_at_slow_plus_one() {
220        let candles: Vec<Candle> = (0..30i64)
221            .map(|i| {
222                let f = i as f64;
223                c(10.0 + f, 8.0 + f, 9.0 + f, 100.0, i)
224            })
225            .collect();
226        let mut k = Kvo::new(3, 5).unwrap();
227        let out = k.batch(&candles);
228        for (i, v) in out.iter().enumerate().take(5) {
229            assert!(v.is_none(), "index {i} must be None during warmup");
230        }
231        // First emission lands at index slow_period (one seed bar + slow EMA seeding from there).
232        assert!(out[5].is_some(), "first value lands at slow_period");
233    }
234
235    #[test]
236    fn batch_equals_streaming() {
237        let candles: Vec<Candle> = (0..100i64)
238            .map(|i| {
239                let f = i as f64;
240                let mid = 100.0 + (f * 0.2).sin() * 4.0;
241                c(mid + 1.0, mid - 1.0, mid, 10.0 + ((i % 5) as f64), i)
242            })
243            .collect();
244        let mut a = Kvo::classic();
245        let mut b = Kvo::classic();
246        assert_eq!(
247            a.batch(&candles),
248            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
249        );
250    }
251
252    #[test]
253    fn reset_clears_state() {
254        let candles: Vec<Candle> = (0..80i64)
255            .map(|i| {
256                let f = i as f64;
257                c(11.0 + f, 9.0 + f, 10.0 + f, 100.0, i)
258            })
259            .collect();
260        let mut k = Kvo::classic();
261        k.batch(&candles);
262        assert!(k.is_ready());
263        k.reset();
264        assert!(!k.is_ready());
265        assert_eq!(k.update(candles[0]), None);
266    }
267}