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    #[inline]
164    fn warmup_period(&self) -> usize {
165        self.left + self.right + 1
166    }
167
168    #[inline]
169    fn is_ready(&self) -> bool {
170        self.last.is_some()
171    }
172
173    #[inline]
174    fn name(&self) -> &'static str {
175        "PivotReversal"
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::traits::BatchExt;
183
184    fn c(high: f64, low: f64, close: f64) -> Candle {
185        Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
186    }
187
188    #[test]
189    fn rejects_zero_params() {
190        assert!(matches!(PivotReversal::new(0, 2), Err(Error::PeriodZero)));
191        assert!(matches!(PivotReversal::new(2, 0), Err(Error::PeriodZero)));
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let p = PivotReversal::new(2, 2).unwrap();
197        assert_eq!(p.params(), (2, 2));
198        assert_eq!(p.warmup_period(), 5);
199        assert_eq!(p.name(), "PivotReversal");
200        assert!(!p.is_ready());
201        assert_eq!(p.value(), None);
202        assert_eq!(p.pivot_high(), None);
203        assert_eq!(p.pivot_low(), None);
204    }
205
206    #[test]
207    fn first_emission_at_warmup_period() {
208        let mut p = PivotReversal::new(1, 1).unwrap();
209        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)]);
210        assert!(out[0].is_none());
211        assert!(out[1].is_none());
212        assert!(out[2].is_some());
213    }
214
215    #[test]
216    fn confirms_pivot_high() {
217        // bar1 is a local high; once bar2 arrives it is confirmed.
218        let mut p = PivotReversal::new(1, 1).unwrap();
219        p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
220        assert_eq!(p.pivot_high(), Some(12.0));
221    }
222
223    #[test]
224    fn confirms_pivot_low() {
225        let mut p = PivotReversal::new(1, 1).unwrap();
226        p.batch(&[c(12.0, 11.0, 11.5), c(10.0, 8.0, 8.5), c(12.0, 11.0, 11.5)]);
227        assert_eq!(p.pivot_low(), Some(8.0));
228    }
229
230    #[test]
231    fn breakout_above_pivot_high_signals_plus_one() {
232        let mut p = PivotReversal::new(1, 1).unwrap();
233        // Form a pivot high at 12, then a close above 12 crosses it.
234        let candles = [
235            c(10.0, 9.0, 9.5),   // index 0
236            c(12.0, 11.0, 11.5), // pivot-high candidate
237            c(10.0, 9.0, 9.5),   // confirms pivot high = 12
238            c(11.0, 9.0, 9.0),   // close 9.0 (below 12)
239            c(14.0, 12.5, 13.0), // close 13.0 > 12 and prev 9.0 <= 12 -> +1
240        ];
241        let out = p.batch(&candles);
242        assert_eq!(out.last().unwrap(), &Some(1.0));
243    }
244
245    #[test]
246    fn breakdown_below_pivot_low_signals_minus_one() {
247        let mut p = PivotReversal::new(1, 1).unwrap();
248        let candles = [
249            c(12.0, 11.0, 11.5),
250            c(10.0, 8.0, 8.5),   // pivot-low candidate
251            c(12.0, 11.0, 11.5), // confirms pivot low = 8
252            c(12.0, 9.0, 11.0),  // close 11 (above 8)
253            c(9.0, 6.0, 7.0),    // close 7 < 8 and prev 11 >= 8 -> -1
254        ];
255        let out = p.batch(&candles);
256        assert_eq!(out.last().unwrap(), &Some(-1.0));
257    }
258
259    #[test]
260    fn no_break_is_zero() {
261        let mut p = PivotReversal::new(1, 1).unwrap();
262        let candles = [
263            c(10.0, 9.0, 9.5),
264            c(12.0, 11.0, 11.5),
265            c(10.0, 9.0, 9.5),
266            c(10.5, 9.0, 9.8),
267        ];
268        let out = p.batch(&candles);
269        assert_eq!(out.last().unwrap(), &Some(0.0));
270    }
271
272    #[test]
273    fn reset_clears_state() {
274        let mut p = PivotReversal::new(1, 1).unwrap();
275        p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
276        assert!(p.is_ready());
277        p.reset();
278        assert!(!p.is_ready());
279        assert_eq!(p.value(), None);
280        assert_eq!(p.pivot_high(), None);
281    }
282
283    #[test]
284    fn batch_equals_streaming() {
285        let candles: Vec<Candle> = (0..80)
286            .map(|i| {
287                let base = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
288                c(base + 1.0, base - 1.0, base)
289            })
290            .collect();
291        let batch = PivotReversal::new(2, 2).unwrap().batch(&candles);
292        let mut b = PivotReversal::new(2, 2).unwrap();
293        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
294        assert_eq!(batch, streamed);
295    }
296}