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