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        Ok(Self {
73            period,
74            prev: None,
75            window: VecDeque::with_capacity(period),
76            sum_vm_plus: 0.0,
77            sum_vm_minus: 0.0,
78            sum_tr: 0.0,
79            last: None,
80        })
81    }
82
83    /// Configured period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<VortexOutput> {
90        self.last
91    }
92}
93
94impl Indicator for Vortex {
95    type Input = Candle;
96    type Output = VortexOutput;
97
98    fn update(&mut self, candle: Candle) -> Option<VortexOutput> {
99        let Some(prev) = self.prev else {
100            // The first bar has no predecessor to measure against.
101            self.prev = Some(candle);
102            return None;
103        };
104        let vm_plus = (candle.high - prev.low).abs();
105        let vm_minus = (candle.low - prev.high).abs();
106        let tr = candle.true_range(Some(prev.close));
107        self.prev = Some(candle);
108
109        if self.window.len() == self.period {
110            let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty");
111            self.sum_vm_plus -= old_p;
112            self.sum_vm_minus -= old_m;
113            self.sum_tr -= old_tr;
114        }
115        self.window.push_back((vm_plus, vm_minus, tr));
116        self.sum_vm_plus += vm_plus;
117        self.sum_vm_minus += vm_minus;
118        self.sum_tr += tr;
119
120        if self.window.len() < self.period {
121            return None;
122        }
123        let out = if self.sum_tr == 0.0 {
124            // A perfectly flat window has no range to normalise against.
125            VortexOutput {
126                plus: 0.0,
127                minus: 0.0,
128            }
129        } else {
130            VortexOutput {
131                plus: self.sum_vm_plus / self.sum_tr,
132                minus: self.sum_vm_minus / self.sum_tr,
133            }
134        };
135        self.last = Some(out);
136        Some(out)
137    }
138
139    fn reset(&mut self) {
140        self.prev = None;
141        self.window.clear();
142        self.sum_vm_plus = 0.0;
143        self.sum_vm_minus = 0.0;
144        self.sum_tr = 0.0;
145        self.last = None;
146    }
147
148    fn warmup_period(&self) -> usize {
149        // The first VM/TR triple needs a previous bar, then the window fills.
150        self.period + 1
151    }
152
153    fn is_ready(&self) -> bool {
154        self.last.is_some()
155    }
156
157    fn name(&self) -> &'static str {
158        "Vortex"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166    use approx::assert_relative_eq;
167
168    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
169        Candle::new(open, high, low, close, 1.0, ts).unwrap()
170    }
171
172    #[test]
173    fn new_rejects_zero_period() {
174        assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
175    }
176
177    /// Cover the const accessors `period` / `value` (84-91) and the
178    /// Indicator-impl `name` body (157-159). `warmup_period` is covered
179    /// elsewhere.
180    #[test]
181    fn accessors_and_metadata() {
182        let mut v = Vortex::new(14).unwrap();
183        assert_eq!(v.period(), 14);
184        assert_eq!(v.name(), "Vortex");
185        assert!(v.value().is_none());
186        let warmup = i64::try_from(v.warmup_period()).unwrap();
187        let candles: Vec<Candle> = (0..warmup)
188            .map(|i| {
189                let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
190                Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
191            })
192            .collect();
193        for c in &candles {
194            v.update(*c);
195        }
196        assert!(v.value().is_some());
197    }
198
199    #[test]
200    fn reference_values() {
201        // Vortex(2) over three explicit candles (high, low, close):
202        //   c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12).
203        // bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3.
204        // bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2.
205        // window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4.
206        let candles = [
207            candle(9.0, 10.0, 8.0, 9.0, 0),
208            candle(10.0, 12.0, 9.0, 11.0, 1),
209            candle(12.0, 13.0, 11.0, 12.0, 2),
210        ];
211        let mut v = Vortex::new(2).unwrap();
212        let out = v.batch(&candles);
213        assert_eq!(v.warmup_period(), 3);
214        assert_eq!(out[0], None);
215        assert_eq!(out[1], None);
216        let o = out[2].unwrap();
217        assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12);
218        assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12);
219    }
220
221    #[test]
222    fn perfectly_flat_market_yields_zero() {
223        let mut v = Vortex::new(5).unwrap();
224        let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
225        for o in v.batch(&candles).into_iter().flatten() {
226            assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12);
227            assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12);
228        }
229    }
230
231    #[test]
232    fn outputs_are_non_negative() {
233        let mut v = Vortex::new(14).unwrap();
234        let candles: Vec<Candle> = (0..120)
235            .map(|i| {
236                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
237                candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
238            })
239            .collect();
240        for o in v.batch(&candles).into_iter().flatten() {
241            assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}");
242        }
243    }
244
245    #[test]
246    fn reset_clears_state() {
247        let mut v = Vortex::new(5).unwrap();
248        let candles: Vec<Candle> = (0..20)
249            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
250            .collect();
251        v.batch(&candles);
252        assert!(v.is_ready());
253        v.reset();
254        assert!(!v.is_ready());
255        assert_eq!(v.update(candles[0]), None);
256    }
257
258    #[test]
259    fn batch_equals_streaming() {
260        let candles: Vec<Candle> = (0..80)
261            .map(|i| {
262                let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
263                candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
264            })
265            .collect();
266        let batch = Vortex::new(14).unwrap().batch(&candles);
267        let mut b = Vortex::new(14).unwrap();
268        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
269        assert_eq!(batch, streamed);
270    }
271}