Skip to main content

wickra_core/indicators/
andrews_pitchfork.rs

1//! Andrews Pitchfork — median line and parallels off the last three swing pivots.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the
10/// current bar.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct AndrewsPitchforkOutput {
13    /// The median line — from the handle pivot through the midpoint of the other two.
14    pub median: f64,
15    /// The upper parallel (through the higher of the two anchor pivots).
16    pub upper: f64,
17    /// The lower parallel (through the lower of the two anchor pivots).
18    pub lower: f64,
19}
20
21/// A confirmed swing pivot: its bar index and price.
22#[derive(Debug, Clone, Copy)]
23struct Pivot {
24    index: f64,
25    price: f64,
26    is_high: bool,
27}
28
29/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most
30/// recent **swing pivots**, projected forward to the current bar.
31///
32/// ```text
33/// detect alternating swing highs/lows with a `strength`-bar fractal
34/// P0 = handle (oldest of the last three), P1, P2 = the next two
35/// M  = midpoint of P1 and P2
36/// median(t) = P0 + slope·(t − t0)          slope = (M − P0) / (M_t − t0)
37/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor
38/// ```
39///
40/// The pitchfork projects a "fork" of three parallel lines: a central **median
41/// line** drawn from a starting pivot through the midpoint of a later swing, plus
42/// two parallels passing through that swing's high and low. Price tends to
43/// oscillate around the median line and find support/resistance at the parallels.
44/// This streaming version detects the pivots automatically with a symmetric
45/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars
46/// late) and keeps the three most recent alternating swings.
47///
48/// Because it depends on swing structure, readiness is **data-dependent**: the
49/// first output appears once three alternating pivots have been confirmed.
50/// `warmup_period` returns the minimum bars to confirm a single pivot. Each
51/// `update` is O(`strength`).
52///
53/// # Example
54///
55/// ```
56/// use wickra_core::{Candle, Indicator, AndrewsPitchfork};
57///
58/// let mut indicator = AndrewsPitchfork::new(2).unwrap();
59/// let mut last = None;
60/// for i in 0..120 {
61///     let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0;
62///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
63///     last = indicator.update(c);
64/// }
65/// // A swinging series eventually establishes a pitchfork.
66/// let _ = last;
67/// ```
68#[derive(Debug, Clone)]
69pub struct AndrewsPitchfork {
70    strength: usize,
71    window: VecDeque<Candle>,
72    pivots: Vec<Pivot>,
73    count: usize,
74    last: Option<AndrewsPitchforkOutput>,
75}
76
77impl AndrewsPitchfork {
78    /// Construct an Andrews Pitchfork with the given fractal `strength` (bars on
79    /// each side of a pivot).
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error::PeriodZero`] if `strength == 0`.
84    pub fn new(strength: usize) -> Result<Self> {
85        if strength == 0 {
86            return Err(Error::PeriodZero);
87        }
88        Ok(Self {
89            strength,
90            window: VecDeque::with_capacity(2 * strength + 1),
91            pivots: Vec::new(),
92            count: 0,
93            last: None,
94        })
95    }
96
97    /// Configured fractal strength.
98    pub const fn strength(&self) -> usize {
99        self.strength
100    }
101
102    /// Current value if available.
103    pub const fn value(&self) -> Option<AndrewsPitchforkOutput> {
104        self.last
105    }
106
107    /// Record a freshly confirmed pivot, keeping the last three alternating swings.
108    fn record_pivot(&mut self, pivot: Pivot) {
109        if let Some(last) = self.pivots.last_mut() {
110            if last.is_high == pivot.is_high {
111                // Same kind: keep the more extreme one (and its index).
112                let more_extreme = if pivot.is_high {
113                    pivot.price > last.price
114                } else {
115                    pivot.price < last.price
116                };
117                if more_extreme {
118                    *last = pivot;
119                }
120                return;
121            }
122        }
123        self.pivots.push(pivot);
124        if self.pivots.len() > 3 {
125            self.pivots.remove(0);
126        }
127    }
128
129    fn project(&self, tc: f64) -> Option<AndrewsPitchforkOutput> {
130        let [p0, p1, p2] = self.pivots.as_slice() else {
131            return None;
132        };
133        let mid_t = f64::midpoint(p1.index, p2.index);
134        let mid_p = f64::midpoint(p1.price, p2.price);
135        let slope = (mid_p - p0.price) / (mid_t - p0.index);
136        let median = p0.price + slope * (tc - p0.index);
137        let off1 = p1.price - (p0.price + slope * (p1.index - p0.index));
138        let off2 = p2.price - (p0.price + slope * (p2.index - p0.index));
139        Some(AndrewsPitchforkOutput {
140            median,
141            upper: median + off1.max(off2),
142            lower: median + off1.min(off2),
143        })
144    }
145}
146
147impl Indicator for AndrewsPitchfork {
148    type Input = Candle;
149    type Output = AndrewsPitchforkOutput;
150
151    fn update(&mut self, candle: Candle) -> Option<AndrewsPitchforkOutput> {
152        self.count += 1;
153        let span = 2 * self.strength + 1;
154        if self.window.len() == span {
155            self.window.pop_front();
156        }
157        self.window.push_back(candle);
158        if self.window.len() == span {
159            let center = self.window[self.strength];
160            let is_high = self
161                .window
162                .iter()
163                .enumerate()
164                .all(|(i, c)| i == self.strength || c.high < center.high);
165            let is_low = self
166                .window
167                .iter()
168                .enumerate()
169                .all(|(i, c)| i == self.strength || c.low > center.low);
170            // Absolute index of the center bar (1-based count minus the right span).
171            let center_index = (self.count - 1 - self.strength) as f64;
172            if is_high && !is_low {
173                self.record_pivot(Pivot {
174                    index: center_index,
175                    price: center.high,
176                    is_high: true,
177                });
178            } else if is_low && !is_high {
179                self.record_pivot(Pivot {
180                    index: center_index,
181                    price: center.low,
182                    is_high: false,
183                });
184            }
185        }
186        let tc = (self.count - 1) as f64;
187        if let Some(out) = self.project(tc) {
188            self.last = Some(out);
189            return Some(out);
190        }
191        None
192    }
193
194    fn reset(&mut self) {
195        self.window.clear();
196        self.pivots.clear();
197        self.count = 0;
198        self.last = None;
199    }
200
201    fn warmup_period(&self) -> usize {
202        2 * self.strength + 1
203    }
204
205    fn is_ready(&self) -> bool {
206        self.last.is_some()
207    }
208
209    fn name(&self) -> &'static str {
210        "AndrewsPitchfork"
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::traits::BatchExt;
218
219    fn c(high: f64, low: f64) -> Candle {
220        Candle::new_unchecked(
221            f64::midpoint(high, low),
222            high,
223            low,
224            f64::midpoint(high, low),
225            1_000.0,
226            0,
227        )
228    }
229
230    /// A clean zig-zag that prints alternating swing highs and lows.
231    fn zigzag() -> Vec<Candle> {
232        let mut out = Vec::new();
233        for i in 0..120 {
234            let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
235            out.push(c(base + 1.0, base - 1.0));
236        }
237        out
238    }
239
240    #[test]
241    fn rejects_zero_strength() {
242        assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero)));
243    }
244
245    #[test]
246    fn accessors_and_metadata() {
247        let p = AndrewsPitchfork::new(2).unwrap();
248        assert_eq!(p.strength(), 2);
249        assert_eq!(p.warmup_period(), 5);
250        assert_eq!(p.name(), "AndrewsPitchfork");
251        assert!(!p.is_ready());
252        assert_eq!(p.value(), None);
253    }
254
255    #[test]
256    fn none_before_three_pivots() {
257        let mut p = AndrewsPitchfork::new(2).unwrap();
258        // Too few bars to ever confirm three alternating pivots.
259        let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]);
260        assert!(out.iter().all(Option::is_none));
261    }
262
263    #[test]
264    fn eventually_emits_on_swings() {
265        let mut p = AndrewsPitchfork::new(2).unwrap();
266        let out = p.batch(&zigzag());
267        assert!(
268            out.iter().any(Option::is_some),
269            "a swinging series should form a pitchfork"
270        );
271        assert!(p.is_ready());
272    }
273
274    #[test]
275    fn upper_at_or_above_lower() {
276        let mut p = AndrewsPitchfork::new(2).unwrap();
277        for o in p.batch(&zigzag()).into_iter().flatten() {
278            assert!(
279                o.upper >= o.lower,
280                "upper {} below lower {}",
281                o.upper,
282                o.lower
283            );
284        }
285    }
286
287    #[test]
288    fn reset_clears_state() {
289        let mut p = AndrewsPitchfork::new(2).unwrap();
290        p.batch(&zigzag());
291        assert!(p.is_ready());
292        p.reset();
293        assert!(!p.is_ready());
294        assert_eq!(p.value(), None);
295        assert_eq!(p.strength(), 2);
296    }
297
298    #[test]
299    fn record_pivot_keeps_more_extreme_same_kind() {
300        let mut p = AndrewsPitchfork::new(2).unwrap();
301        p.record_pivot(Pivot {
302            index: 0.0,
303            price: 100.0,
304            is_high: true,
305        });
306        // A higher high of the same kind replaces the stored one.
307        p.record_pivot(Pivot {
308            index: 1.0,
309            price: 105.0,
310            is_high: true,
311        });
312        assert_eq!(p.pivots.len(), 1);
313        assert_eq!(p.pivots[0].price, 105.0);
314        // A lower high of the same kind is ignored.
315        p.record_pivot(Pivot {
316            index: 2.0,
317            price: 102.0,
318            is_high: true,
319        });
320        assert_eq!(p.pivots.len(), 1);
321        assert_eq!(p.pivots[0].price, 105.0);
322        // A low pivot of the other kind is appended.
323        p.record_pivot(Pivot {
324            index: 3.0,
325            price: 90.0,
326            is_high: false,
327        });
328        assert_eq!(p.pivots.len(), 2);
329        // A lower low of the same kind replaces the stored low.
330        p.record_pivot(Pivot {
331            index: 4.0,
332            price: 85.0,
333            is_high: false,
334        });
335        assert_eq!(p.pivots[1].price, 85.0);
336        // A higher low of the same kind is ignored.
337        p.record_pivot(Pivot {
338            index: 5.0,
339            price: 88.0,
340            is_high: false,
341        });
342        assert_eq!(p.pivots[1].price, 85.0);
343    }
344
345    #[test]
346    fn batch_equals_streaming() {
347        let candles = zigzag();
348        let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles);
349        let mut b = AndrewsPitchfork::new(2).unwrap();
350        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
351        assert_eq!(batch, streamed);
352    }
353}