Skip to main content

quantwave_core/indicators/
vortex.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4
5/// Vortex Indicator
6/// VI+ = Sum(VM+) / Sum(TR)
7/// VI- = Sum(VM-) / Sum(TR)
8/// where:
9/// VM+ = |High - prevLow|
10/// VM- = |Low - prevHigh|
11/// TR = True Range
12#[derive(Debug, Clone)]
13pub struct VortexIndicator {
14    period: usize,
15    vm_plus: VecDeque<f64>,
16    vm_minus: VecDeque<f64>,
17    tr: VecDeque<f64>,
18    sum_vm_plus: f64,
19    sum_vm_minus: f64,
20    sum_tr: f64,
21    prev_high: Option<f64>,
22    prev_low: Option<f64>,
23    prev_close: Option<f64>,
24}
25
26impl VortexIndicator {
27    pub fn new(period: usize) -> Self {
28        Self {
29            period,
30            vm_plus: VecDeque::with_capacity(period),
31            vm_minus: VecDeque::with_capacity(period),
32            tr: VecDeque::with_capacity(period),
33            sum_vm_plus: 0.0,
34            sum_vm_minus: 0.0,
35            sum_tr: 0.0,
36            prev_high: None,
37            prev_low: None,
38            prev_close: None,
39        }
40    }
41}
42
43impl Next<(f64, f64, f64)> for VortexIndicator {
44    type Output = (f64, f64);
45
46    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
47        let (vmp, vmm, tr) = match (self.prev_high, self.prev_low, self.prev_close) {
48            (Some(ph), Some(pl), Some(pc)) => {
49                let vmp = (high - pl).abs();
50                let vmm = (low - ph).abs();
51                let tr = (high - low).max((high - pc).abs()).max((low - pc).abs());
52                (vmp, vmm, tr)
53            }
54            _ => (0.0, 0.0, 0.0), // Warmup
55        };
56
57        self.vm_plus.push_back(vmp);
58        self.vm_minus.push_back(vmm);
59        self.tr.push_back(tr);
60        self.sum_vm_plus += vmp;
61        self.sum_vm_minus += vmm;
62        self.sum_tr += tr;
63
64        if self.vm_plus.len() > self.period {
65            if let Some(old_vmp) = self.vm_plus.pop_front() {
66                self.sum_vm_plus -= old_vmp;
67            }
68            if let Some(old_vmm) = self.vm_minus.pop_front() {
69                self.sum_vm_minus -= old_vmm;
70            }
71            if let Some(old_tr) = self.tr.pop_front() {
72                self.sum_tr -= old_tr;
73            }
74        }
75
76        self.prev_high = Some(high);
77        self.prev_low = Some(low);
78        self.prev_close = Some(close);
79
80        if self.sum_tr == 0.0 {
81            (1.0, 1.0)
82        } else {
83            (
84                self.sum_vm_plus / self.sum_tr,
85                self.sum_vm_minus / self.sum_tr,
86            )
87        }
88    }
89}
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use proptest::prelude::*;
94    use serde::Deserialize;
95    use std::fs;
96    use std::path::Path;
97
98    #[derive(Debug, Deserialize)]
99    struct VortexCase {
100        high: Vec<f64>,
101        low: Vec<f64>,
102        close: Vec<f64>,
103        expected_plus: Vec<f64>,
104        expected_minus: Vec<f64>,
105    }
106
107    #[test]
108    fn test_vortex_gold_standard() {
109        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
110        let manifest_path = Path::new(&manifest_dir);
111        let path = manifest_path.join("tests/gold_standard/vortex_14.json");
112        let path = if path.exists() {
113            path
114        } else {
115            manifest_path
116                .parent()
117                .unwrap()
118                .join("tests/gold_standard/vortex_14.json")
119        };
120        let content = fs::read_to_string(path).unwrap();
121        let case: VortexCase = serde_json::from_str(&content).unwrap();
122
123        let mut vi = VortexIndicator::new(14);
124        for i in 0..case.high.len() {
125            let (plus, minus) = vi.next((case.high[i], case.low[i], case.close[i]));
126            approx::assert_relative_eq!(plus, case.expected_plus[i], epsilon = 1e-6);
127            approx::assert_relative_eq!(minus, case.expected_minus[i], epsilon = 1e-6);
128        }
129    }
130
131    fn vortex_batch(data: Vec<(f64, f64, f64)>, period: usize) -> Vec<(f64, f64)> {
132        let mut vi = VortexIndicator::new(period);
133        data.into_iter().map(|x| vi.next(x)).collect()
134    }
135
136    proptest! {
137        #[test]
138        fn test_vortex_parity(input in prop::collection::vec((0.0..100.0, 0.0..100.0, 0.0..100.0), 1..100)) {
139            let mut adj_input = Vec::with_capacity(input.len());
140            for (h, l, c) in input {
141                let h_f: f64 = h;
142                let l_f: f64 = l;
143                let c_f: f64 = c;
144                let high = h_f.max(l_f).max(c_f);
145                let low = l_f.min(h_f).min(c_f);
146                adj_input.push((high, low, c_f));
147            }
148
149            let period = 14;
150            let mut vi = VortexIndicator::new(period);
151            let mut streaming_results = Vec::with_capacity(adj_input.len());
152            for &val in &adj_input {
153                streaming_results.push(vi.next(val));
154            }
155
156            let batch_results = vortex_batch(adj_input, period);
157
158            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
159                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-6);
160                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-6);
161            }
162        }
163    }
164
165    #[test]
166    fn test_vortex_basic() {
167        let mut vi = VortexIndicator::new(3);
168        // Bar 0: H=10, L=8, C=9. No prev. vmp=0, vmm=0, tr=0. Output (1,1)
169        // Bar 1: H=12, L=10, C=11. Prev H=10, L=8, C=9.
170        // vmp = |12-8|=4, vmm=|10-10|=0, tr=max(2, |12-9|=3, |10-9|=1)=3
171        // sum_vmp=4, sum_vmm=0, sum_tr=3. Output (4/3, 0/3) = (1.333, 0)
172
173        let (p0, m0) = vi.next((10.0, 8.0, 9.0));
174        assert_eq!(p0, 1.0);
175        assert_eq!(m0, 1.0);
176
177        let (p1, m1) = vi.next((12.0, 10.0, 11.0));
178        approx::assert_relative_eq!(p1, 1.3333333333, epsilon = 1e-6);
179        assert_eq!(m1, 0.0);
180    }
181}
182
183pub const VORTEX_METADATA: IndicatorMetadata = IndicatorMetadata {
184    name: "Vortex Indicator",
185    description: "The Vortex Indicator helps identify the start of a new trend or the continuation of an existing one.",
186    params: &[ParamDef {
187        name: "period",
188        default: "14",
189        description: "Period",
190    }],
191    formula_source: "https://www.investopedia.com/terms/v/vortex-indicator-vi.asp",
192    formula_latex: r#"
193\[
194VI+ = \frac{\sum VM+}{\sum TR} \\ VI- = \frac{\sum VM-}{\sum TR}
195\]
196"#,
197    gold_standard_file: "vortex.json",
198    category: "Classic",
199};