Skip to main content

quantwave_core/indicators/
vfi.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::{EMA, SMA};
3use crate::indicators::statistics::StandardDeviation;
4use crate::traits::Next;
5use crate::utils::RingBuffer as VecDeque;
6
7pub const METADATA: IndicatorMetadata = IndicatorMetadata {
8    name: "VFI",
9    description: "Volume Flow Indicator - a volume-based indicator that uses price and volume relative to a cutoff to measure money flow.",
10    usage: "Used to identify trend direction and potential reversals. Values above 0 are bullish, below 0 are bearish. Extreme readings and divergences are also significant.",
11    keywords: &["volume", "vfi", "money-flow", "katsanos", "oscillator"],
12    ehlers_summary: "Katsanos' Volume Flow Indicator (VFI) is based on the popular On Balance Volume (OBV) but with three main modifications: it is bounded, it filters out small price changes, and it caps volume extremes. It provides a more balanced view of buying and selling pressure by accounting for price volatility and volume outliers. — TASC June 2004",
13    params: &[
14        ParamDef {
15            name: "period",
16            default: "130",
17            description: "Lookback period for Vave and Summation",
18        },
19        ParamDef {
20            name: "coef",
21            default: "0.2",
22            description: "Coefficient for minimal price cut-off",
23        },
24        ParamDef {
25            name: "vcoef",
26            default: "2.5",
27            description: "Coefficient for volume cut-off",
28        },
29        ParamDef {
30            name: "smoothing_period",
31            default: "3",
32            description: "EMA period for final smoothing",
33        },
34    ],
35    formula_source: "https://www.traders.com/Documentation/FEEDbk_docs/2022/04/TradersTips.html",
36    formula_latex: r#"
37\begin{aligned}
38TP &= \frac{H+L+C}{3} \\
39Inter &= \ln(TP) - \ln(TP_{t-1}) \\
40VInter &= StdDev(Inter, 30) \\
41Cutoff &= Coef \cdot VInter \cdot Close \\
42Vave &= SMA(Volume, Period)_{t-1} \\
43Vmax &= Vave \cdot Vcoef \\
44VC &= \min(Volume, Vmax) \\
45MF &= TP - TP_{t-1} \\
46VCP &= \begin{cases} VC, & \text{if } MF > Cutoff \\ -VC, & \text{if } MF < -Cutoff \\ 0, & \text{otherwise} \end{cases} \\
47VFI_{raw} &= \frac{\sum_{i=0}^{Period-1} VCP_{t-i}}{Vave} \\
48VFI &= EMA(VFI_{raw}, 3)
49\end{aligned}
50"#,
51    gold_standard_file: "vfi.json",
52    category: "Volume Indicators",
53};
54
55/// Volume Flow Indicator (VFI)
56///
57/// Based on Markos Katsanos' Volume Flow Indicator.
58#[derive(Debug, Clone)]
59pub struct Vfi {
60    period: usize,
61    coef: f64,
62    vcoef: f64,
63    prev_tp: Option<f64>,
64    stddev: StandardDeviation,
65    v_sma: SMA,
66    prev_v_ave: f64,
67    dir_vol_window: VecDeque<f64>,
68    dir_vol_sum: f64,
69    ema: EMA,
70}
71
72impl Vfi {
73    pub fn new(period: usize, coef: f64, vcoef: f64, smoothing_period: usize) -> Self {
74        Self {
75            period,
76            coef,
77            vcoef,
78            prev_tp: None,
79            stddev: StandardDeviation::new(30),
80            v_sma: SMA::new(period),
81            prev_v_ave: 0.0,
82            dir_vol_window: VecDeque::with_capacity(period),
83            dir_vol_sum: 0.0,
84            ema: EMA::new(smoothing_period),
85        }
86    }
87}
88
89impl Next<(f64, f64, f64, f64)> for Vfi {
90    type Output = f64;
91
92    fn next(&mut self, (high, low, close, volume): (f64, f64, f64, f64)) -> Self::Output {
93        let tp = (high + low + close) / 3.0;
94
95        let inter = match self.prev_tp {
96            Some(prev) if tp > 0.0 && prev > 0.0 => tp.ln() - prev.ln(),
97            _ => 0.0,
98        };
99
100        let v_inter = self.stddev.next(inter);
101        let cutoff = self.coef * v_inter * close;
102
103        // VAve = Average(V, Period)[1]
104        let v_ave = if self.prev_v_ave == 0.0 {
105            // On initialization, if we don't have a previous average, we use current
106            // This is a common way to handle the startup of lagged indicators.
107            self.v_sma.next(volume)
108        } else {
109            self.prev_v_ave
110        };
111        // Update prev_v_ave for next bar
112        self.prev_v_ave = self.v_sma.next(volume);
113
114        let v_max = v_ave * self.vcoef;
115        let vc = volume.min(v_max);
116
117        let mf = match self.prev_tp {
118            Some(prev) => tp - prev,
119            None => 0.0,
120        };
121
122        let dir_vol = if mf > cutoff {
123            vc
124        } else if mf < -cutoff {
125            -vc
126        } else {
127            0.0
128        };
129
130        // Rolling sum of DirectionalVolume
131        self.dir_vol_window.push_back(dir_vol);
132        self.dir_vol_sum += dir_vol;
133        if self.dir_vol_window.len() > self.period
134            && let Some(oldest) = self.dir_vol_window.pop_front()
135        {
136            self.dir_vol_sum -= oldest;
137        }
138
139        let vfi_raw = if v_ave != 0.0 {
140            self.dir_vol_sum / v_ave
141        } else {
142            0.0
143        };
144
145        self.prev_tp = Some(tp);
146
147        self.ema.next(vfi_raw)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use proptest::prelude::*;
155
156    #[test]
157    fn test_vfi_basic() {
158        let mut vfi = Vfi::new(130, 0.2, 2.5, 3);
159        let inputs = vec![
160            (10.0, 10.0, 10.0, 1000.0),
161            (11.0, 11.0, 11.0, 1000.0),
162            (12.0, 12.0, 12.0, 1000.0),
163            (11.0, 11.0, 11.0, 1000.0),
164        ];
165
166        for input in inputs {
167            let res = vfi.next(input);
168            assert!(!res.is_nan());
169        }
170    }
171
172    /// quantwave-qkft blast radius: VFI feeds log-returns through
173    /// `StandardDeviation`. After swapping in the stable accumulator, batch and
174    /// chunked-streaming must remain bit-identical.
175    #[test]
176    fn vfi_batch_streaming_bitwise_parity() {
177        let bars: Vec<(f64, f64, f64, f64)> = (0..500)
178            .map(|i| {
179                let c = 2980.0 + (i as f64 * 0.31).sin() * 40.0;
180                (
181                    c + 2.0,
182                    c - 2.0,
183                    c,
184                    1000.0 + (i as f64 * 0.07).cos() * 250.0,
185                )
186            })
187            .collect();
188
189        let mut batch = Vfi::new(130, 0.2, 2.5, 3);
190        let batch_out: Vec<f64> = bars.iter().map(|&b| batch.next(b)).collect();
191
192        for chunk in [1usize, 7, 64] {
193            let mut streaming = Vfi::new(130, 0.2, 2.5, 3);
194            let mut out = Vec::with_capacity(bars.len());
195            for part in bars.chunks(chunk) {
196                for &b in part {
197                    out.push(streaming.next(b));
198                }
199            }
200            for (i, (b, s)) in batch_out.iter().zip(out.iter()).enumerate() {
201                assert_eq!(
202                    b.to_bits(),
203                    s.to_bits(),
204                    "bar {i} chunk {chunk}: {b} vs {s}"
205                );
206            }
207        }
208    }
209
210    proptest! {
211        #[test]
212        fn test_vfi_parity(
213            inputs in prop::collection::vec((1.0..100.0, 1.0..100.0, 1.0..100.0, 1.0..1000.0), 10..100),
214        ) {
215            let period = 130;
216            let coef = 0.2;
217            let vcoef = 2.5;
218            let smoothing = 3;
219            let mut vfi = Vfi::new(period, coef, vcoef, smoothing);
220
221            // Manual calculation for parity check
222            let mut prev_tp: Option<f64> = None;
223            let mut stddev = StandardDeviation::new(30);
224            let mut v_sma = SMA::new(period);
225            let mut prev_v_ave = 0.0;
226            let mut dir_vol_window = VecDeque::with_capacity(period);
227            let mut dir_vol_sum = 0.0;
228            let mut ema = EMA::new(smoothing);
229
230            for (h, l, c, v) in inputs {
231                let h: f64 = h;
232                let l: f64 = l;
233                let c: f64 = c;
234                let high = h.max(l).max(c);
235                let low = h.min(l).min(c);
236                let tp = (high + low + c) / 3.0;
237
238                let inter = match prev_tp {
239                    Some(p) if tp > 0.0 && p > 0.0 => tp.ln() - p.ln(),
240                    _ => 0.0,
241                };
242                let v_inter = stddev.next(inter);
243                let cutoff = coef * v_inter * c;
244
245                let v_ave = if prev_v_ave == 0.0 {
246                    v_sma.next(v)
247                } else {
248                    prev_v_ave
249                };
250                prev_v_ave = v_sma.next(v);
251
252                let v_max = v_ave * vcoef;
253                let vc = v.min(v_max);
254
255                let mf = match prev_tp {
256                    Some(p) => tp - p,
257                    None => 0.0,
258                };
259
260                let dir_vol = if mf > cutoff {
261                    vc
262                } else if mf < -cutoff {
263                    -vc
264                } else {
265                    0.0
266                };
267
268                dir_vol_window.push_back(dir_vol);
269                dir_vol_sum += dir_vol;
270                if dir_vol_window.len() > period {
271                    dir_vol_sum -= dir_vol_window.pop_front().unwrap();
272                }
273
274                let vfi_raw = if v_ave != 0.0 {
275                    dir_vol_sum / v_ave
276                } else {
277                    0.0
278                };
279
280                let expected_vfi = ema.next(vfi_raw);
281                let actual_vfi = vfi.next((high, low, c, v));
282
283                approx::assert_relative_eq!(actual_vfi, expected_vfi, epsilon = 1e-10);
284                prev_tp = Some(tp);
285            }
286        }
287    }
288}