Skip to main content

wickra_core/indicators/
pivot_reversal.rs

1//! Pivot Reversal — a breakout signal off the most recent confirmed swing pivots.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Pivot Reversal — emits a reversal **breakout signal** when price closes through
10/// the most recently confirmed swing pivot.
11///
12/// ```text
13/// pivot high: a bar whose high is strictly above the `left` bars before and the
14///             `right` bars after it (confirmed `right` bars late)
15/// pivot low : the mirror on lows
16/// signal = +1 when close crosses above the last confirmed pivot high
17/// signal = −1 when close crosses below the last confirmed pivot low
18/// signal =  0 otherwise
19/// ```
20///
21/// Unlike [`WilliamsFractals`](crate::WilliamsFractals), which merely *marks* the
22/// swing points, Pivot Reversal turns them into an actionable entry: once a swing
23/// high is confirmed it becomes a breakout trigger — a close back above it signals
24/// a bullish reversal — and likewise a close below a confirmed swing low signals a
25/// bearish reversal. This is the logic of the classic "Pivot Reversal" strategy.
26/// Signals fire only on the **crossing** bar, not while price sits beyond the
27/// level.
28///
29/// The first signal can appear once `left + right + 1` bars exist (a pivot needs
30/// neighbours on both sides). The output is `+1` / `0` / `−1`. Each `update` is
31/// O(`left + right`).
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, PivotReversal};
37///
38/// let mut indicator = PivotReversal::new(2, 2).unwrap();
39/// let mut fired = false;
40/// for i in 0..60 {
41///     let base = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
42///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
43///     match indicator.update(c) {
44///         Some(s) if s != 0.0 => fired = true,
45///         _ => {}
46///     }
47/// }
48/// let _ = fired;
49/// ```
50#[derive(Debug, Clone)]
51pub struct PivotReversal {
52    left: usize,
53    right: usize,
54    window: VecDeque<Candle>,
55    pivot_high: Option<f64>,
56    pivot_low: Option<f64>,
57    prev_close: Option<f64>,
58    last: Option<f64>,
59}
60
61impl PivotReversal {
62    /// Construct a Pivot Reversal with `left` bars before and `right` bars after
63    /// the pivot.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::PeriodZero`] if `left` or `right` is `0`.
68    pub fn new(left: usize, right: usize) -> Result<Self> {
69        if left == 0 || right == 0 {
70            return Err(Error::PeriodZero);
71        }
72        Ok(Self {
73            left,
74            right,
75            window: VecDeque::with_capacity(left + right + 1),
76            pivot_high: None,
77            pivot_low: None,
78            prev_close: None,
79            last: None,
80        })
81    }
82
83    /// Configured `(left, right)` strengths.
84    pub const fn params(&self) -> (usize, usize) {
85        (self.left, self.right)
86    }
87
88    /// Most recent confirmed pivot-high level, if any.
89    pub const fn pivot_high(&self) -> Option<f64> {
90        self.pivot_high
91    }
92
93    /// Most recent confirmed pivot-low level, if any.
94    pub const fn pivot_low(&self) -> Option<f64> {
95        self.pivot_low
96    }
97
98    /// Current value if available.
99    pub const fn value(&self) -> Option<f64> {
100        self.last
101    }
102}
103
104impl Indicator for PivotReversal {
105    type Input = Candle;
106    type Output = f64;
107
108    fn update(&mut self, candle: Candle) -> Option<f64> {
109        let close = candle.close;
110        if self.window.len() == self.left + self.right + 1 {
111            self.window.pop_front();
112        }
113        self.window.push_back(candle);
114        if self.window.len() < self.left + self.right + 1 {
115            self.prev_close = Some(close);
116            return None;
117        }
118
119        // Confirm the pivot candidate sitting `right` bars back.
120        let cand = self.window[self.left];
121        let is_high = self
122            .window
123            .iter()
124            .enumerate()
125            .all(|(i, c)| i == self.left || c.high < cand.high);
126        let is_low = self
127            .window
128            .iter()
129            .enumerate()
130            .all(|(i, c)| i == self.left || c.low > cand.low);
131        if is_high {
132            self.pivot_high = Some(cand.high);
133        }
134        if is_low {
135            self.pivot_low = Some(cand.low);
136        }
137
138        // Breakout crossing of the latest confirmed pivots by the current close.
139        let mut signal = 0.0;
140        if let (Some(ph), Some(prev)) = (self.pivot_high, self.prev_close) {
141            if close > ph && prev <= ph {
142                signal = 1.0;
143            }
144        }
145        if let (Some(pl), Some(prev)) = (self.pivot_low, self.prev_close) {
146            if close < pl && prev >= pl {
147                signal = -1.0;
148            }
149        }
150        self.prev_close = Some(close);
151        self.last = Some(signal);
152        Some(signal)
153    }
154
155    fn reset(&mut self) {
156        self.window.clear();
157        self.pivot_high = None;
158        self.pivot_low = None;
159        self.prev_close = None;
160        self.last = None;
161    }
162
163    fn warmup_period(&self) -> usize {
164        self.left + self.right + 1
165    }
166
167    fn is_ready(&self) -> bool {
168        self.last.is_some()
169    }
170
171    fn name(&self) -> &'static str {
172        "PivotReversal"
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::traits::BatchExt;
180
181    fn c(high: f64, low: f64, close: f64) -> Candle {
182        Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
183    }
184
185    #[test]
186    fn rejects_zero_params() {
187        assert!(matches!(PivotReversal::new(0, 2), Err(Error::PeriodZero)));
188        assert!(matches!(PivotReversal::new(2, 0), Err(Error::PeriodZero)));
189    }
190
191    #[test]
192    fn accessors_and_metadata() {
193        let p = PivotReversal::new(2, 2).unwrap();
194        assert_eq!(p.params(), (2, 2));
195        assert_eq!(p.warmup_period(), 5);
196        assert_eq!(p.name(), "PivotReversal");
197        assert!(!p.is_ready());
198        assert_eq!(p.value(), None);
199        assert_eq!(p.pivot_high(), None);
200        assert_eq!(p.pivot_low(), None);
201    }
202
203    #[test]
204    fn first_emission_at_warmup_period() {
205        let mut p = PivotReversal::new(1, 1).unwrap();
206        let out = p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
207        assert!(out[0].is_none());
208        assert!(out[1].is_none());
209        assert!(out[2].is_some());
210    }
211
212    #[test]
213    fn confirms_pivot_high() {
214        // bar1 is a local high; once bar2 arrives it is confirmed.
215        let mut p = PivotReversal::new(1, 1).unwrap();
216        p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
217        assert_eq!(p.pivot_high(), Some(12.0));
218    }
219
220    #[test]
221    fn confirms_pivot_low() {
222        let mut p = PivotReversal::new(1, 1).unwrap();
223        p.batch(&[c(12.0, 11.0, 11.5), c(10.0, 8.0, 8.5), c(12.0, 11.0, 11.5)]);
224        assert_eq!(p.pivot_low(), Some(8.0));
225    }
226
227    #[test]
228    fn breakout_above_pivot_high_signals_plus_one() {
229        let mut p = PivotReversal::new(1, 1).unwrap();
230        // Form a pivot high at 12, then a close above 12 crosses it.
231        let candles = [
232            c(10.0, 9.0, 9.5),   // index 0
233            c(12.0, 11.0, 11.5), // pivot-high candidate
234            c(10.0, 9.0, 9.5),   // confirms pivot high = 12
235            c(11.0, 9.0, 9.0),   // close 9.0 (below 12)
236            c(14.0, 12.5, 13.0), // close 13.0 > 12 and prev 9.0 <= 12 -> +1
237        ];
238        let out = p.batch(&candles);
239        assert_eq!(out.last().unwrap(), &Some(1.0));
240    }
241
242    #[test]
243    fn breakdown_below_pivot_low_signals_minus_one() {
244        let mut p = PivotReversal::new(1, 1).unwrap();
245        let candles = [
246            c(12.0, 11.0, 11.5),
247            c(10.0, 8.0, 8.5),   // pivot-low candidate
248            c(12.0, 11.0, 11.5), // confirms pivot low = 8
249            c(12.0, 9.0, 11.0),  // close 11 (above 8)
250            c(9.0, 6.0, 7.0),    // close 7 < 8 and prev 11 >= 8 -> -1
251        ];
252        let out = p.batch(&candles);
253        assert_eq!(out.last().unwrap(), &Some(-1.0));
254    }
255
256    #[test]
257    fn no_break_is_zero() {
258        let mut p = PivotReversal::new(1, 1).unwrap();
259        let candles = [
260            c(10.0, 9.0, 9.5),
261            c(12.0, 11.0, 11.5),
262            c(10.0, 9.0, 9.5),
263            c(10.5, 9.0, 9.8),
264        ];
265        let out = p.batch(&candles);
266        assert_eq!(out.last().unwrap(), &Some(0.0));
267    }
268
269    #[test]
270    fn reset_clears_state() {
271        let mut p = PivotReversal::new(1, 1).unwrap();
272        p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
273        assert!(p.is_ready());
274        p.reset();
275        assert!(!p.is_ready());
276        assert_eq!(p.value(), None);
277        assert_eq!(p.pivot_high(), None);
278    }
279
280    #[test]
281    fn batch_equals_streaming() {
282        let candles: Vec<Candle> = (0..80)
283            .map(|i| {
284                let base = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
285                c(base + 1.0, base - 1.0, base)
286            })
287            .collect();
288        let batch = PivotReversal::new(2, 2).unwrap().batch(&candles);
289        let mut b = PivotReversal::new(2, 2).unwrap();
290        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
291        assert_eq!(batch, streamed);
292    }
293}