Skip to main content

kestrel_chartkit/indicator/
volume_flow.rs

1use super::smoothing::Ema;
2use super::{Indicator, IndicatorAlert, IndicatorOutput};
3use crate::model::Bar;
4use std::collections::HashMap;
5
6/// Cumulative Volume Delta (CVD) Engine.
7///
8/// Derives the buying/selling split from the shape of each bar — where the close sits inside its
9/// range — which is [`super::cvd_intrabar::DeltaProvenance::BarShape`]. It looks at no individual
10/// trade and cannot tell aggressive buying from selling into absorption: both can close at the
11/// high. That is a documented estimate, not a measurement of the aggressor, and it remains this
12/// crate's default. [`super::cvd_intrabar::IntrabarCvd`] offers the finer estimate from
13/// lower-timeframe bars without replacing this one.
14///
15/// Per bar, with the range floored at `1e-8`: `share = (close - low) / (high - low)`,
16/// `buy = volume * share`, `sell = volume * (1 - share)`, `delta = buy - sell`. `value` is the
17/// running total of the deltas; `extra` carries this bar's `delta`, `buy_volume` and
18/// `sell_volume`. First output: with the first bar. [`Indicator::reset`] returns the total to
19/// zero.
20#[derive(Debug, Clone)]
21pub struct CvdEngine {
22    cum_cvd: f64,
23}
24
25impl CvdEngine {
26    pub fn new() -> Self {
27        Self { cum_cvd: 0.0 }
28    }
29}
30
31impl Default for CvdEngine {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Indicator for CvdEngine {
38    fn name(&self) -> &str {
39        "cvd"
40    }
41
42    fn warmup_period(&self) -> usize {
43        1
44    }
45
46    fn reset(&mut self) {
47        self.cum_cvd = 0.0;
48    }
49
50    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
51        let range = (bar.high - bar.low).max(1e-8);
52        // Estimate buy/sell volume fraction from bar close location within range
53        let buy_pct = (bar.close - bar.low) / range;
54        let buy_vol = bar.volume * buy_pct;
55        let sell_vol = bar.volume * (1.0 - buy_pct);
56
57        let delta = buy_vol - sell_vol;
58        self.cum_cvd += delta;
59
60        let mut extra = HashMap::new();
61        extra.insert("delta".to_string(), delta);
62        extra.insert("buy_volume".to_string(), buy_vol);
63        extra.insert("sell_volume".to_string(), sell_vol);
64
65        Some(IndicatorOutput::with_extra(self.cum_cvd, extra))
66    }
67
68    fn alerts(&self) -> Vec<IndicatorAlert> {
69        Vec::new()
70    }
71}
72
73/// Klinger Volume Oscillator (KVO) over the volume force.
74///
75/// Per bar, with `dm = high - low`:
76///
77/// ```text
78/// trend = +1 if high + low + close rose against the previous bar, -1 if it fell,
79///         the previous trend if unchanged, +1 on the first bar
80/// cm    = dm on the first bar; cm + dm while the trend holds; prev_dm + dm when it flips
81/// vf    = volume * |2 * dm / cm - 1| * trend * 100          (the ratio is 0 for cm ~ 0)
82/// KVO   = Ema(fast_len)(vf) - Ema(slow_len)(vf)
83/// ```
84///
85/// Both averages are the shared [`Ema`] with its first-sample seed and run from the first bar;
86/// the line is published from the `slow_len`-th bar on. `extra["signal"]` is an
87/// `Ema(signal_len)` over the published KVO values, seeded with the first of them;
88/// `extra["hist"]` is `KVO - signal` and `extra["volume_force"]` this bar's `vf`. Defaults
89/// `34`/`55`/`13`.
90///
91/// First output: with the `slow_len`-th bar. [`Indicator::reset`] clears all state.
92#[derive(Debug, Clone)]
93pub struct KlingerVolumeForceEngine {
94    fast_len: usize,
95    slow_len: usize,
96    signal_len: usize,
97    fast_ema: Ema,
98    slow_ema: Ema,
99    signal_ema: Ema,
100    prev_hlc_sum: Option<f64>,
101    prev_trend: f64,
102    prev_dm: f64,
103    cumulative_measurement: f64,
104    count: usize,
105}
106
107impl KlingerVolumeForceEngine {
108    pub fn new(fast_len: usize, slow_len: usize, signal_len: usize) -> Self {
109        Self {
110            fast_len,
111            slow_len,
112            signal_len,
113            fast_ema: Ema::new(fast_len),
114            slow_ema: Ema::new(slow_len),
115            signal_ema: Ema::new(signal_len),
116            prev_hlc_sum: None,
117            prev_trend: 1.0,
118            prev_dm: 0.0,
119            cumulative_measurement: 0.0,
120            count: 0,
121        }
122    }
123
124    pub fn with_defaults() -> Self {
125        Self::new(34, 55, 13)
126    }
127
128    pub fn fast_len(&self) -> usize {
129        self.fast_len
130    }
131}
132
133impl Indicator for KlingerVolumeForceEngine {
134    fn name(&self) -> &str {
135        "klinger"
136    }
137
138    fn warmup_period(&self) -> usize {
139        self.slow_len + self.signal_len
140    }
141
142    fn reset(&mut self) {
143        self.fast_ema.reset();
144        self.slow_ema.reset();
145        self.signal_ema.reset();
146        self.prev_hlc_sum = None;
147        self.prev_trend = 1.0;
148        self.prev_dm = 0.0;
149        self.cumulative_measurement = 0.0;
150        self.count = 0;
151    }
152
153    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
154        self.count += 1;
155        let hlc_sum = bar.high + bar.low + bar.close;
156        let trend = match self.prev_hlc_sum {
157            Some(previous) => {
158                if hlc_sum > previous {
159                    1.0
160                } else if hlc_sum < previous {
161                    -1.0
162                } else {
163                    self.prev_trend
164                }
165            }
166            None => 1.0,
167        };
168        let dm = bar.high - bar.low;
169        self.cumulative_measurement = if self.prev_hlc_sum.is_none() {
170            dm
171        } else if trend == self.prev_trend {
172            self.cumulative_measurement + dm
173        } else {
174            self.prev_dm + dm
175        };
176        let ratio = if self.cumulative_measurement.abs() > f64::EPSILON {
177            dm / self.cumulative_measurement
178        } else {
179            0.0
180        };
181        let vf = bar.volume * (2.0 * ratio - 1.0).abs() * trend * 100.0;
182        self.prev_hlc_sum = Some(hlc_sum);
183        self.prev_trend = trend;
184        self.prev_dm = dm;
185
186        let fast_v = self.fast_ema.update(vf)?;
187        let slow_v = self.slow_ema.update(vf)?;
188
189        if self.count < self.slow_len {
190            return None;
191        }
192
193        let kvo = fast_v - slow_v;
194        let sig = self.signal_ema.update(kvo)?;
195
196        let mut extra = HashMap::new();
197        extra.insert("volume_force".to_string(), vf);
198        extra.insert("kvo".to_string(), kvo);
199        extra.insert("signal".to_string(), sig);
200        extra.insert("hist".to_string(), kvo - sig);
201
202        Some(IndicatorOutput::with_extra(kvo, extra))
203    }
204
205    fn alerts(&self) -> Vec<IndicatorAlert> {
206        Vec::new()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_cvd_accumulation() {
216        let mut cvd = CvdEngine::new();
217        let bar1 = Bar::new(1, 100.0, 105.0, 95.0, 105.0, 1000.0); // Close at High -> 100% buy vol
218        let out1 = cvd.on_bar(&bar1).unwrap();
219        assert_eq!(out1.value, 1000.0);
220    }
221
222    #[test]
223    fn klinger_matches_original_volume_force_formula() {
224        let mut klinger = KlingerVolumeForceEngine::new(1, 2, 2);
225        let first = Bar::new(1, 1.0, 3.0, 1.0, 2.0, 100.0);
226        assert!(klinger.on_bar(&first).is_none());
227
228        let second = Bar::new(2, 2.0, 3.0, 1.0, 2.0, 100.0);
229        let output = klinger.on_bar(&second).unwrap();
230        assert_eq!(output.extra["volume_force"], 0.0);
231        assert!((output.value + 3_333.333_333_333_333).abs() < 1e-9);
232    }
233}