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    fn update(&mut self, candle: Candle) -> Option<f64> {
100        let dm = candle.high + candle.low + candle.close;
101        let Some(prev_dm) = self.prev_dm else {
102            // The first bar only establishes the previous daily measurement.
103            self.prev_dm = Some(dm);
104            return None;
105        };
106
107        // Determine the bar's trend sign relative to the previous bar.
108        let new_trend: i8 = if dm > prev_dm {
109            1
110        } else if dm < prev_dm {
111            -1
112        } else {
113            self.trend
114        };
115
116        // Cumulative measurement resets to (prev_dm + dm) whenever the trend
117        // flips. On the very first sign read (trend was 0) we also seed from
118        // the two-bar sum, matching the textbook definition.
119        if new_trend != self.trend || self.trend == 0 {
120            self.cm = prev_dm + dm;
121        } else {
122            self.cm += dm;
123        }
124        self.trend = new_trend;
125
126        let vf = if self.cm == 0.0 {
127            // Pathological all-zero OHLC stretch — no force to register.
128            0.0
129        } else {
130            candle.volume * (2.0 * (dm / self.cm - 1.0)).abs() * f64::from(new_trend) * 100.0
131        };
132
133        self.prev_dm = Some(dm);
134
135        let fast = self.fast.update(vf);
136        let slow = self.slow.update(vf);
137        Some(fast? - slow?)
138    }
139
140    fn reset(&mut self) {
141        self.fast.reset();
142        self.slow.reset();
143        self.prev_dm = None;
144        self.trend = 0;
145        self.cm = 0.0;
146    }
147
148    fn warmup_period(&self) -> usize {
149        // One bar to seed `prev_dm`, then the slow EMA needs `slow` raw `vf` values.
150        self.slow_period + 1
151    }
152
153    fn is_ready(&self) -> bool {
154        self.fast.is_ready() && self.slow.is_ready()
155    }
156
157    fn name(&self) -> &'static str {
158        "KVO"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166    use approx::assert_relative_eq;
167
168    fn c(high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
169        Candle::new(low, high, low, close, volume, ts).unwrap()
170    }
171
172    #[test]
173    fn rejects_zero_period() {
174        assert!(matches!(Kvo::new(0, 10), Err(Error::PeriodZero)));
175        assert!(matches!(Kvo::new(3, 0), Err(Error::PeriodZero)));
176    }
177
178    #[test]
179    fn rejects_fast_geq_slow() {
180        assert!(matches!(Kvo::new(34, 34), Err(Error::InvalidPeriod { .. })));
181        assert!(matches!(Kvo::new(55, 34), Err(Error::InvalidPeriod { .. })));
182    }
183
184    #[test]
185    fn accessors_and_metadata() {
186        let k = Kvo::classic();
187        assert_eq!(k.periods(), (34, 55));
188        assert_eq!(k.name(), "KVO");
189        assert_eq!(k.warmup_period(), 56);
190    }
191
192    #[test]
193    fn zero_ohlc_collapses_vf_to_zero() {
194        // Two consecutive all-zero bars: dm = 0 for both, so prev_dm + dm = 0
195        // and `cm == 0.0` fires the defensive branch, holding vf at zero.
196        let mut k = Kvo::new(3, 6).unwrap();
197        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 100.0, 0).unwrap();
198        assert_eq!(k.update(zero), None);
199        assert_eq!(k.update(zero), None);
200        assert_eq!(k.update(zero), None);
201    }
202
203    #[test]
204    fn constant_series_yields_zero() {
205        // dm flat -> trend never sets to a nonzero sign and vf collapses to 0
206        // for every bar; both EMAs hold at 0 once seeded.
207        let candles: Vec<Candle> = (0..120).map(|i| c(10.0, 10.0, 10.0, 100.0, i)).collect();
208        let mut k = Kvo::new(3, 6).unwrap();
209        for v in k.batch(&candles).into_iter().flatten() {
210            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
211        }
212    }
213
214    #[test]
215    fn warmup_emits_at_slow_plus_one() {
216        let candles: Vec<Candle> = (0..30i64)
217            .map(|i| {
218                let f = i as f64;
219                c(10.0 + f, 8.0 + f, 9.0 + f, 100.0, i)
220            })
221            .collect();
222        let mut k = Kvo::new(3, 5).unwrap();
223        let out = k.batch(&candles);
224        for (i, v) in out.iter().enumerate().take(5) {
225            assert!(v.is_none(), "index {i} must be None during warmup");
226        }
227        // First emission lands at index slow_period (one seed bar + slow EMA seeding from there).
228        assert!(out[5].is_some(), "first value lands at slow_period");
229    }
230
231    #[test]
232    fn batch_equals_streaming() {
233        let candles: Vec<Candle> = (0..100i64)
234            .map(|i| {
235                let f = i as f64;
236                let mid = 100.0 + (f * 0.2).sin() * 4.0;
237                c(mid + 1.0, mid - 1.0, mid, 10.0 + ((i % 5) as f64), i)
238            })
239            .collect();
240        let mut a = Kvo::classic();
241        let mut b = Kvo::classic();
242        assert_eq!(
243            a.batch(&candles),
244            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
245        );
246    }
247
248    #[test]
249    fn reset_clears_state() {
250        let candles: Vec<Candle> = (0..80i64)
251            .map(|i| {
252                let f = i as f64;
253                c(11.0 + f, 9.0 + f, 10.0 + f, 100.0, i)
254            })
255            .collect();
256        let mut k = Kvo::classic();
257        k.batch(&candles);
258        assert!(k.is_ready());
259        k.reset();
260        assert!(!k.is_ready());
261        assert_eq!(k.update(candles[0]), None);
262    }
263}