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/// Derives buying/selling volume delta from bar price action (or direct volume delta).
8#[derive(Debug, Clone)]
9pub struct CvdEngine {
10    cum_cvd: f64,
11}
12
13impl CvdEngine {
14    pub fn new() -> Self {
15        Self { cum_cvd: 0.0 }
16    }
17}
18
19impl Default for CvdEngine {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl Indicator for CvdEngine {
26    fn name(&self) -> &str {
27        "cvd"
28    }
29
30    fn warmup_period(&self) -> usize {
31        1
32    }
33
34    fn reset(&mut self) {
35        self.cum_cvd = 0.0;
36    }
37
38    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
39        let range = (bar.high - bar.low).max(1e-8);
40        // Estimate buy/sell volume fraction from bar close location within range
41        let buy_pct = (bar.close - bar.low) / range;
42        let buy_vol = bar.volume * buy_pct;
43        let sell_vol = bar.volume * (1.0 - buy_pct);
44
45        let delta = buy_vol - sell_vol;
46        self.cum_cvd += delta;
47
48        let mut extra = HashMap::new();
49        extra.insert("delta".to_string(), delta);
50        extra.insert("buy_volume".to_string(), buy_vol);
51        extra.insert("sell_volume".to_string(), sell_vol);
52
53        Some(IndicatorOutput::with_extra(self.cum_cvd, extra))
54    }
55
56    fn alerts(&self) -> Vec<IndicatorAlert> {
57        Vec::new()
58    }
59}
60
61/// Klinger Volume Force Engine.
62#[derive(Debug, Clone)]
63pub struct KlingerVolumeForceEngine {
64    fast_len: usize,
65    slow_len: usize,
66    signal_len: usize,
67    fast_ema: Ema,
68    slow_ema: Ema,
69    signal_ema: Ema,
70    prev_hlc_sum: Option<f64>,
71    prev_trend: f64,
72    prev_dm: f64,
73    cumulative_measurement: f64,
74    count: usize,
75}
76
77impl KlingerVolumeForceEngine {
78    pub fn new(fast_len: usize, slow_len: usize, signal_len: usize) -> Self {
79        Self {
80            fast_len,
81            slow_len,
82            signal_len,
83            fast_ema: Ema::new(fast_len),
84            slow_ema: Ema::new(slow_len),
85            signal_ema: Ema::new(signal_len),
86            prev_hlc_sum: None,
87            prev_trend: 1.0,
88            prev_dm: 0.0,
89            cumulative_measurement: 0.0,
90            count: 0,
91        }
92    }
93
94    pub fn with_defaults() -> Self {
95        Self::new(34, 55, 13)
96    }
97
98    pub fn fast_len(&self) -> usize {
99        self.fast_len
100    }
101}
102
103impl Indicator for KlingerVolumeForceEngine {
104    fn name(&self) -> &str {
105        "klinger"
106    }
107
108    fn warmup_period(&self) -> usize {
109        self.slow_len + self.signal_len
110    }
111
112    fn reset(&mut self) {
113        self.fast_ema.reset();
114        self.slow_ema.reset();
115        self.signal_ema.reset();
116        self.prev_hlc_sum = None;
117        self.prev_trend = 1.0;
118        self.prev_dm = 0.0;
119        self.cumulative_measurement = 0.0;
120        self.count = 0;
121    }
122
123    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
124        self.count += 1;
125        let hlc_sum = bar.high + bar.low + bar.close;
126        let trend = match self.prev_hlc_sum {
127            Some(previous) => {
128                if hlc_sum > previous {
129                    1.0
130                } else if hlc_sum < previous {
131                    -1.0
132                } else {
133                    self.prev_trend
134                }
135            }
136            None => 1.0,
137        };
138        let dm = bar.high - bar.low;
139        self.cumulative_measurement = if self.prev_hlc_sum.is_none() {
140            dm
141        } else if trend == self.prev_trend {
142            self.cumulative_measurement + dm
143        } else {
144            self.prev_dm + dm
145        };
146        let ratio = if self.cumulative_measurement.abs() > f64::EPSILON {
147            dm / self.cumulative_measurement
148        } else {
149            0.0
150        };
151        let vf = bar.volume * (2.0 * ratio - 1.0).abs() * trend * 100.0;
152        self.prev_hlc_sum = Some(hlc_sum);
153        self.prev_trend = trend;
154        self.prev_dm = dm;
155
156        let fast_v = self.fast_ema.update(vf);
157        let slow_v = self.slow_ema.update(vf);
158
159        if self.count < self.slow_len {
160            return None;
161        }
162
163        let kvo = fast_v - slow_v;
164        let sig = self.signal_ema.update(kvo);
165
166        let mut extra = HashMap::new();
167        extra.insert("volume_force".to_string(), vf);
168        extra.insert("kvo".to_string(), kvo);
169        extra.insert("signal".to_string(), sig);
170        extra.insert("hist".to_string(), kvo - sig);
171
172        Some(IndicatorOutput::with_extra(kvo, extra))
173    }
174
175    fn alerts(&self) -> Vec<IndicatorAlert> {
176        Vec::new()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_cvd_accumulation() {
186        let mut cvd = CvdEngine::new();
187        let bar1 = Bar::new(1, 100.0, 105.0, 95.0, 105.0, 1000.0); // Close at High -> 100% buy vol
188        let out1 = cvd.on_bar(&bar1).unwrap();
189        assert_eq!(out1.value, 1000.0);
190    }
191
192    #[test]
193    fn klinger_matches_original_volume_force_formula() {
194        let mut klinger = KlingerVolumeForceEngine::new(1, 2, 2);
195        let first = Bar::new(1, 1.0, 3.0, 1.0, 2.0, 100.0);
196        assert!(klinger.on_bar(&first).is_none());
197
198        let second = Bar::new(2, 2.0, 3.0, 1.0, 2.0, 100.0);
199        let output = klinger.on_bar(&second).unwrap();
200        assert_eq!(output.extra["volume_force"], 0.0);
201        assert!((output.value + 3_333.333_333_333_333).abs() < 1e-9);
202    }
203}