Skip to main content

wickra_core/indicators/
vortex.rs

1//! Vortex Indicator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Vortex Indicator output: the two directional movement lines.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct VortexOutput {
12    /// `VI+` — strength of upward (positive) vortex movement.
13    pub plus: f64,
14    /// `VI−` — strength of downward (negative) vortex movement.
15    pub minus: f64,
16}
17
18/// Vortex Indicator — Botes & Siepman's pair of oscillators (`VI+`, `VI−`) that
19/// capture the relationship between two consecutive bars.
20///
21/// Two "vortex movements" measure how far price travelled against the opposite
22/// extreme of the previous bar; each is normalised by the summed true range:
23///
24/// ```text
25/// VM+_t = |high_t − low_{t−1}|
26/// VM−_t = |low_t  − high_{t−1}|
27/// VI+   = Σ VM+ over n / Σ TR over n
28/// VI−   = Σ VM− over n / Σ TR over n
29/// ```
30///
31/// `VI+` crossing above `VI−` is a bullish signal, the reverse a bearish one;
32/// the wider the gap, the stronger the trend. A fully flat window (zero true
33/// range) reports `(0, 0)`.
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Candle, Indicator, Vortex};
39///
40/// let mut indicator = Vortex::new(14).unwrap();
41/// let mut last = None;
42/// for i in 0..80 {
43///     let base = 100.0 + i as f64;
44///     let candle =
45///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
46///     last = indicator.update(candle);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct Vortex {
52    period: usize,
53    prev: Option<Candle>,
54    /// Rolling window of `(VM+, VM−, TR)` triples.
55    window: VecDeque<(f64, f64, f64)>,
56    sum_vm_plus: f64,
57    sum_vm_minus: f64,
58    sum_tr: f64,
59    last: Option<VortexOutput>,
60}
61
62impl Vortex {
63    /// Construct a new Vortex Indicator with the given period.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::PeriodZero`] if `period == 0`.
68    pub fn new(period: usize) -> Result<Self> {
69        if period == 0 {
70            return Err(Error::PeriodZero);
71        }
72        if period > crate::error::MAX_PERIOD {
73            return Err(Error::InvalidPeriod {
74                message: crate::error::PERIOD_ABOVE_MAX,
75            });
76        }
77        Ok(Self {
78            period,
79            prev: None,
80            window: VecDeque::with_capacity(period),
81            sum_vm_plus: 0.0,
82            sum_vm_minus: 0.0,
83            sum_tr: 0.0,
84            last: None,
85        })
86    }
87
88    /// Configured period.
89    pub const fn period(&self) -> usize {
90        self.period
91    }
92
93    /// Current value if available.
94    pub const fn value(&self) -> Option<VortexOutput> {
95        self.last
96    }
97}
98
99impl Indicator for Vortex {
100    type Input = Candle;
101    type Output = VortexOutput;
102
103    #[inline]
104    fn update(&mut self, candle: Candle) -> Option<VortexOutput> {
105        let Some(prev) = self.prev else {
106            // The first bar has no predecessor to measure against.
107            self.prev = Some(candle);
108            return None;
109        };
110        let vm_plus = (candle.high - prev.low).abs();
111        let vm_minus = (candle.low - prev.high).abs();
112        let tr = candle.true_range(Some(prev.close));
113        self.prev = Some(candle);
114
115        if self.window.len() == self.period {
116            let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty");
117            self.sum_vm_plus -= old_p;
118            self.sum_vm_minus -= old_m;
119            self.sum_tr -= old_tr;
120        }
121        self.window.push_back((vm_plus, vm_minus, tr));
122        self.sum_vm_plus += vm_plus;
123        self.sum_vm_minus += vm_minus;
124        self.sum_tr += tr;
125
126        if self.window.len() < self.period {
127            return None;
128        }
129        let out = if self.sum_tr == 0.0 {
130            // A perfectly flat window has no range to normalise against.
131            VortexOutput {
132                plus: 0.0,
133                minus: 0.0,
134            }
135        } else {
136            VortexOutput {
137                plus: self.sum_vm_plus / self.sum_tr,
138                minus: self.sum_vm_minus / self.sum_tr,
139            }
140        };
141        self.last = Some(out);
142        Some(out)
143    }
144
145    fn reset(&mut self) {
146        self.prev = None;
147        self.window.clear();
148        self.sum_vm_plus = 0.0;
149        self.sum_vm_minus = 0.0;
150        self.sum_tr = 0.0;
151        self.last = None;
152    }
153
154    #[inline]
155    fn warmup_period(&self) -> usize {
156        // The first VM/TR triple needs a previous bar, then the window fills.
157        self.period + 1
158    }
159
160    #[inline]
161    fn is_ready(&self) -> bool {
162        self.last.is_some()
163    }
164
165    #[inline]
166    fn name(&self) -> &'static str {
167        "Vortex"
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::traits::BatchExt;
175    use approx::assert_relative_eq;
176
177    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
178        Candle::new(open, high, low, close, 1.0, ts).unwrap()
179    }
180
181    #[test]
182    fn new_rejects_zero_period() {
183        assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
184    }
185
186    /// Cover the const accessors `period` / `value` (84-91) and the
187    /// Indicator-impl `name` body (157-159). `warmup_period` is covered
188    /// elsewhere.
189    #[test]
190    fn accessors_and_metadata() {
191        let mut v = Vortex::new(14).unwrap();
192        assert_eq!(v.period(), 14);
193        assert_eq!(v.name(), "Vortex");
194        assert!(v.value().is_none());
195        let warmup = i64::try_from(v.warmup_period()).unwrap();
196        let candles: Vec<Candle> = (0..warmup)
197            .map(|i| {
198                let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
199                Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
200            })
201            .collect();
202        for c in &candles {
203            v.update(*c);
204        }
205        assert!(v.value().is_some());
206    }
207
208    #[test]
209    fn reference_values() {
210        // Vortex(2) over three explicit candles (high, low, close):
211        //   c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12).
212        // bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3.
213        // bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2.
214        // window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4.
215        let candles = [
216            candle(9.0, 10.0, 8.0, 9.0, 0),
217            candle(10.0, 12.0, 9.0, 11.0, 1),
218            candle(12.0, 13.0, 11.0, 12.0, 2),
219        ];
220        let mut v = Vortex::new(2).unwrap();
221        let out = v.batch(&candles);
222        assert_eq!(v.warmup_period(), 3);
223        assert_eq!(out[0], None);
224        assert_eq!(out[1], None);
225        let o = out[2].unwrap();
226        assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12);
227        assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12);
228    }
229
230    #[test]
231    fn perfectly_flat_market_yields_zero() {
232        let mut v = Vortex::new(5).unwrap();
233        let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
234        for o in v.batch(&candles).into_iter().flatten() {
235            assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12);
236            assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12);
237        }
238    }
239
240    #[test]
241    fn outputs_are_non_negative() {
242        let mut v = Vortex::new(14).unwrap();
243        let candles: Vec<Candle> = (0..120)
244            .map(|i| {
245                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
246                candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
247            })
248            .collect();
249        for o in v.batch(&candles).into_iter().flatten() {
250            assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}");
251        }
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let mut v = Vortex::new(5).unwrap();
257        let candles: Vec<Candle> = (0..20)
258            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
259            .collect();
260        v.batch(&candles);
261        assert!(v.is_ready());
262        v.reset();
263        assert!(!v.is_ready());
264        assert_eq!(v.update(candles[0]), None);
265    }
266
267    #[test]
268    fn batch_equals_streaming() {
269        let candles: Vec<Candle> = (0..80)
270            .map(|i| {
271                let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
272                candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
273            })
274            .collect();
275        let batch = Vortex::new(14).unwrap().batch(&candles);
276        let mut b = Vortex::new(14).unwrap();
277        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
278        assert_eq!(batch, streamed);
279    }
280}