quantwave_core/indicators/
vpn.rs1use 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#[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 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 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 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}