Skip to main content

quantwave_core/indicators/
vpn.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::SMA;
3use crate::indicators::ultimate_smoother::UltimateSmoother;
4use crate::indicators::volatility::ATR;
5use crate::traits::Next;
6
7/// Volume Positive Negative (VPN) Indicator
8///
9/// Developed by Markos Katsanos, the VPN indicator attempts to identify high-volume breakouts
10/// by comparing volume on "up" days versus "down" days. It is normalized to oscillate
11/// between -100 and 100.
12///
13/// Formula:
14/// TypicalPrice = (High + Low + Close) / 3
15/// MF = TypicalPrice - TypicalPrice[1]
16/// MC = 0.1 * ATR(Period)
17/// VMP = If MF > MC then Volume else 0
18/// VMN = If MF < -MC then Volume else 0
19/// VP = Sum(VMP, Period)
20/// VN = Sum(VMN, Period)
21/// MAV = Average(Volume, Period)
22/// VPN = (VP - VN) / (MAV * Period) * 100
23///
24/// This implementation uses an UltimateSmoother for the final VPN value to minimize lag,
25/// as per QuantWave's preference for Ehlers-style smoothing.
26#[derive(Debug, Clone)]
27pub struct VPNIndicator {
28    period: usize,
29    atr: ATR,
30    vp_sma: SMA,
31    vn_sma: SMA,
32    vol_sma: SMA,
33    smoother: UltimateSmoother,
34    prev_tp: Option<f64>,
35}
36
37impl VPNIndicator {
38    pub fn new(period: usize, smooth_period: usize) -> Self {
39        Self {
40            period,
41            atr: ATR::new(period),
42            vp_sma: SMA::new(period),
43            vn_sma: SMA::new(period),
44            vol_sma: SMA::new(period),
45            smoother: UltimateSmoother::new(smooth_period),
46            prev_tp: None,
47        }
48    }
49}
50
51impl Next<(f64, f64, f64, f64)> for VPNIndicator {
52    type Output = f64;
53
54    fn next(&mut self, (high, low, close, volume): (f64, f64, f64, f64)) -> Self::Output {
55        let tp = (high + low + close) / 3.0;
56        let atr = self.atr.next((high, low, close));
57        let mc = 0.1 * atr;
58
59        let (vmp, vmn) = match self.prev_tp {
60            Some(ptp) => {
61                let mf = tp - ptp;
62                if mf > mc {
63                    (volume, 0.0)
64                } else if mf < -mc {
65                    (0.0, volume)
66                } else {
67                    (0.0, 0.0)
68                }
69            }
70            None => (0.0, 0.0),
71        };
72
73        self.prev_tp = Some(tp);
74
75        let vp_avg = self.vp_sma.next(vmp);
76        let vn_avg = self.vn_sma.next(vmn);
77        let vol_avg = self.vol_sma.next(volume);
78
79        let mav = if vol_avg <= 0.0 { 1.0 } else { vol_avg };
80
81        // VPN = (VP - VN) / (MAV * Period) * 100
82        // (vp_avg * period - vn_avg * period) / (mav * period) * 100
83        // = (vp_avg - vn_avg) / mav * 100
84        let vpn = (vp_avg - vn_avg) / mav * 100.0;
85
86        self.smoother.next(vpn)
87    }
88}
89
90pub const VPN_METADATA: IndicatorMetadata = IndicatorMetadata {
91    name: "Volume Positive Negative",
92    description: "Detects high-volume breakouts by comparing volume on up days vs down days, normalized between -100 and 100.",
93    usage: "Use to confirm breakouts. A VPN value crossing above a critical threshold (e.g., 10) signals a high-volume positive breakout.",
94    keywords: &["volume", "breakout", "katsanos", "vpn", "momentum"],
95    ehlers_summary: "While originally using EMA for smoothing, this implementation employs the UltimateSmoother to further reduce lag in detecting volume-driven trend shifts, aligning with modern DSP standards for technical indicators.",
96    params: &[
97        ParamDef {
98            name: "period",
99            default: "30",
100            description: "Calculation period for volume sums and ATR",
101        },
102        ParamDef {
103            name: "smooth_period",
104            default: "3",
105            description: "Smoothing period for the final VPN value",
106        },
107    ],
108    formula_source: "https://www.traders.com/Documentation/FEEDbk_docs/2021/04/TradersTips.html",
109    formula_latex: r#"
110\[
111TP = \frac{High + Low + Close}{3}
112\]
113\[
114MF = TP - TP_{t-1}
115\]
116\[
117MC = 0.1 \times ATR(Period)
118\]
119\[
120VP = \sum_{i=0}^{Period-1} (\text{if } MF_{t-i} > MC_{t-i} \text{ then } Volume_{t-i} \text{ else } 0)
121\]
122\[
123VN = \sum_{i=0}^{Period-1} (\text{if } MF_{t-i} < -MC_{t-i} \text{ then } Volume_{t-i} \text{ else } 0)
124\]
125\[
126MAV = \text{Average}(Volume, Period)
127\]
128\[
129VPN = \frac{VP - VN}{MAV \times Period} \times 100
130\]
131"#,
132    gold_standard_file: "vpn.json",
133    category: "Volume",
134};
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::traits::Next;
140    use proptest::prelude::*;
141
142    #[test]
143    fn test_vpn_basic() {
144        let mut vpn = VPNIndicator::new(30, 3);
145        // Provide some increasing price and volume data
146        for i in 0..40 {
147            let val = 100.0 + i as f64;
148            let res = vpn.next((val + 1.0, val - 1.0, val, 1000.0));
149            if i > 35 {
150                assert!(res > 0.0);
151            }
152        }
153    }
154
155    proptest! {
156        #[test]
157        fn test_vpn_parity(
158            inputs in prop::collection::vec((10.0..20.0, 5.0..10.0, 7.0..15.0, 1000.0..5000.0), 50..100),
159        ) {
160            let period = 30;
161            let smooth_period = 3;
162            let mut vpn_ind = VPNIndicator::new(period, smooth_period);
163
164            let mut streaming_results = Vec::with_capacity(inputs.len());
165            for &val in &inputs {
166                streaming_results.push(vpn_ind.next(val));
167            }
168
169            // Reference implementation
170            let mut atr = ATR::new(period);
171            let mut vp_sma = SMA::new(period);
172            let mut vn_sma = SMA::new(period);
173            let mut vol_sma = SMA::new(period);
174            let mut smoother = UltimateSmoother::new(smooth_period);
175            let mut prev_tp = None;
176            let mut batch_results = Vec::with_capacity(inputs.len());
177
178            for &(h, l, c, v) in &inputs {
179                let tp = (h + l + c) / 3.0;
180                let cur_atr = atr.next((h, l, c));
181                let mc = 0.1 * cur_atr;
182
183                let (vmp, vmn) = match prev_tp {
184                    Some(ptp) => {
185                        let mf = tp - ptp;
186                        if mf > mc {
187                            (v, 0.0)
188                        } else if mf < -mc {
189                            (0.0, v)
190                        } else {
191                            (0.0, 0.0)
192                        }
193                    }
194                    None => (0.0, 0.0),
195                };
196                prev_tp = Some(tp);
197
198                let vp_avg = vp_sma.next(vmp);
199                let vn_avg = vn_sma.next(vmn);
200                let vol_avg = vol_sma.next(v);
201
202                let mav = if vol_avg <= 0.0 { 1.0 } else { vol_avg };
203                let vpn = (vp_avg - vn_avg) / mav * 100.0;
204                batch_results.push(smoother.next(vpn));
205            }
206
207            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
208                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
209            }
210        }
211    }
212}