Skip to main content

mantis_ta/indicators/trend/
parabolic_sar.rs

1use crate::indicators::Indicator;
2use crate::types::Candle;
3
4/// Wilder's Parabolic SAR (Stop And Reverse) trend-following indicator.
5///
6/// Tracks a trailing stop level that accelerates toward price as a trend
7/// persists, and flips to the opposite side of price when the trend
8/// reverses. `af_start` is the initial acceleration factor, `af_step` is
9/// how much it grows each time a new extreme is reached, and `af_max`
10/// caps it.
11///
12/// # Examples
13/// ```rust
14/// use mantis_ta::indicators::{Indicator, ParabolicSar};
15/// use mantis_ta::types::Candle;
16///
17/// let candles: Vec<Candle> = (0..30)
18///     .map(|i| {
19///         let price = 100.0 + i as f64;
20///         Candle {
21///             timestamp: i as i64,
22///             open: price,
23///             high: price + 1.0,
24///             low: price - 1.0,
25///             close: price,
26///             volume: 0.0,
27///         }
28///     })
29///     .collect();
30///
31/// let out = ParabolicSar::new(0.02, 0.02, 0.2).calculate(&candles);
32/// // Warmup period = 2: index 0 is None, index 1 is the first Some.
33/// assert!(out[0].is_none());
34/// assert!(out[1].is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct ParabolicSar {
38    af_start: f64,
39    af_step: f64,
40    af_max: f64,
41    bar_count: usize,
42    first_high: f64,
43    first_low: f64,
44    first_close: f64,
45    is_uptrend: bool,
46    sar: f64,
47    ep: f64,
48    af: f64,
49    prev1_high: f64,
50    prev1_low: f64,
51    prev2_high: f64,
52    prev2_low: f64,
53}
54
55impl ParabolicSar {
56    pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Self {
57        assert!(af_start > 0.0, "af_start must be > 0");
58        assert!(af_step > 0.0, "af_step must be > 0");
59        assert!(af_max >= af_start, "af_max must be >= af_start");
60        Self {
61            af_start,
62            af_step,
63            af_max,
64            bar_count: 0,
65            first_high: 0.0,
66            first_low: 0.0,
67            first_close: 0.0,
68            is_uptrend: true,
69            sar: 0.0,
70            ep: 0.0,
71            af: af_start,
72            prev1_high: 0.0,
73            prev1_low: 0.0,
74            prev2_high: 0.0,
75            prev2_low: 0.0,
76        }
77    }
78
79    #[inline]
80    fn update(&mut self, high: f64, low: f64, close: f64) -> Option<f64> {
81        self.bar_count += 1;
82
83        if self.bar_count == 1 {
84            self.first_high = high;
85            self.first_low = low;
86            self.first_close = close;
87            return None;
88        }
89
90        if self.bar_count == 2 {
91            self.is_uptrend = close >= self.first_close;
92            if self.is_uptrend {
93                self.sar = self.first_low;
94                self.ep = high.max(self.first_high);
95            } else {
96                self.sar = self.first_high;
97                self.ep = low.min(self.first_low);
98            }
99            self.af = self.af_start;
100            // The first recurrence step clamps only against this bar (not the
101            // seed bar), matching TA-Lib's bootstrap: prev2 == prev1 here so
102            // the two-bar clamp below degenerates to a one-bar clamp.
103            self.prev2_high = high;
104            self.prev2_low = low;
105            self.prev1_high = high;
106            self.prev1_low = low;
107            return Some(self.sar);
108        }
109
110        let mut new_sar = self.sar + self.af * (self.ep - self.sar);
111        if self.is_uptrend {
112            new_sar = new_sar.min(self.prev1_low).min(self.prev2_low);
113        } else {
114            new_sar = new_sar.max(self.prev1_high).max(self.prev2_high);
115        }
116
117        if self.is_uptrend && low < new_sar {
118            new_sar = self.ep.max(high).max(self.prev1_high);
119            self.is_uptrend = false;
120            self.ep = low;
121            self.af = self.af_start;
122        } else if !self.is_uptrend && high > new_sar {
123            new_sar = self.ep.min(low).min(self.prev1_low);
124            self.is_uptrend = true;
125            self.ep = high;
126            self.af = self.af_start;
127        } else if self.is_uptrend {
128            if high > self.ep {
129                self.ep = high;
130                self.af = (self.af + self.af_step).min(self.af_max);
131            }
132        } else if low < self.ep {
133            self.ep = low;
134            self.af = (self.af + self.af_step).min(self.af_max);
135        }
136
137        self.sar = new_sar;
138        self.prev2_high = self.prev1_high;
139        self.prev2_low = self.prev1_low;
140        self.prev1_high = high;
141        self.prev1_low = low;
142
143        Some(self.sar)
144    }
145}
146
147impl Indicator for ParabolicSar {
148    type Output = f64;
149
150    fn next(&mut self, candle: &Candle) -> Option<Self::Output> {
151        self.update(candle.high, candle.low, candle.close)
152    }
153
154    fn reset(&mut self) {
155        self.bar_count = 0;
156        self.first_high = 0.0;
157        self.first_low = 0.0;
158        self.first_close = 0.0;
159        self.is_uptrend = true;
160        self.sar = 0.0;
161        self.ep = 0.0;
162        self.af = self.af_start;
163        self.prev1_high = 0.0;
164        self.prev1_low = 0.0;
165        self.prev2_high = 0.0;
166        self.prev2_low = 0.0;
167    }
168
169    fn warmup_period(&self) -> usize {
170        2
171    }
172
173    fn clone_boxed(&self) -> Box<dyn Indicator<Output = Self::Output>> {
174        Box::new(self.clone())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn candle(i: i64, high: f64, low: f64, close: f64) -> Candle {
183        Candle {
184            timestamp: i,
185            open: close,
186            high,
187            low,
188            close,
189            volume: 0.0,
190        }
191    }
192
193    #[test]
194    fn parabolic_sar_emits_after_warmup() {
195        let candles: Vec<Candle> = (0..10)
196            .map(|i| {
197                let price = 100.0 + i as f64;
198                candle(i, price + 1.0, price - 1.0, price)
199            })
200            .collect();
201
202        let out = ParabolicSar::new(0.02, 0.02, 0.2).calculate(&candles);
203        assert!(out[0].is_none());
204        assert!(out.iter().skip(1).all(|v| v.is_some()));
205    }
206
207    #[test]
208    fn parabolic_sar_warmup_period_is_two() {
209        let sar = ParabolicSar::new(0.02, 0.02, 0.2);
210        assert_eq!(sar.warmup_period(), 2);
211    }
212
213    #[test]
214    fn parabolic_sar_reset_restores_fresh_state() {
215        let candles: Vec<Candle> = (0..5)
216            .map(|i| {
217                let price = 100.0 + i as f64;
218                candle(i, price + 1.0, price - 1.0, price)
219            })
220            .collect();
221
222        let mut sar = ParabolicSar::new(0.02, 0.02, 0.2);
223        assert!(sar.next(&candles[0]).is_none());
224        assert!(sar.next(&candles[1]).is_some());
225
226        sar.reset();
227        assert_eq!(sar.next(&candles[0]), None);
228    }
229
230    #[test]
231    #[should_panic(expected = "af_start must be > 0")]
232    fn parabolic_sar_rejects_non_positive_af_start() {
233        ParabolicSar::new(0.0, 0.02, 0.2);
234    }
235
236    #[test]
237    #[should_panic(expected = "af_max must be >= af_start")]
238    fn parabolic_sar_rejects_af_max_below_af_start() {
239        ParabolicSar::new(0.1, 0.02, 0.05);
240    }
241}