wickra_core/indicators/
kvo.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[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 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 pub fn classic() -> Self {
86 Self::new(34, 55).expect("classic Klinger periods are valid")
87 }
88
89 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 self.prev_dm = Some(dm);
105 return None;
106 };
107
108 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 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 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 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 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 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 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}