Skip to main content

wickra_core/indicators/
psar.rs

1//! Parabolic SAR (Wilder).
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Trade direction in the SAR state machine.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum Trend {
10    Up,
11    Down,
12}
13
14/// Parabolic Stop And Reverse.
15///
16/// Implementation follows Wilder's original recursion: each step computes a new
17/// SAR from the previous SAR, extreme point (EP) and acceleration factor (AF);
18/// the trend flips when price crosses the SAR.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Candle, Indicator, Psar};
24///
25/// let mut indicator = Psar::new(0.02, 0.02, 0.2).unwrap();
26/// let mut last = None;
27/// for i in 0..80 {
28///     let base = 100.0 + f64::from(i);
29///     let candle =
30///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
31///     last = indicator.update(candle);
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct Psar {
37    af_start: f64,
38    af_step: f64,
39    af_max: f64,
40
41    /// `true` once the first candle has been observed and the seed values
42    /// (`prev_high`, `prev_low`, `sar`, `ep`) are valid. `false` is the
43    /// constructor / `reset()` state in which the compute-fields hold
44    /// `f64::NAN` sentinels.
45    initialised: bool,
46    /// `true` once `update` has returned the first `Some(sar)`. Drives
47    /// [`Indicator::is_ready`] so it matches the convention of every other
48    /// indicator: `is_ready() == true` ↔ the most recent `update` produced
49    /// (or could produce) a real value. PSAR's seed candle returns `None`
50    /// while `initialised` flips to `true`, which is why `is_ready` cannot
51    /// just mirror `initialised`.
52    has_emitted: bool,
53    prev_high: f64,
54    prev_low: f64,
55    trend: Trend,
56    sar: f64,
57    ep: f64,
58    af: f64,
59}
60
61impl Psar {
62    /// Construct PSAR with explicit acceleration parameters.
63    ///
64    /// # Errors
65    /// Returns [`Error::NonPositiveMultiplier`] / [`Error::InvalidPeriod`] for invalid params.
66    pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Result<Self> {
67        if !af_start.is_finite() || !af_step.is_finite() || !af_max.is_finite() {
68            return Err(Error::NonPositiveMultiplier);
69        }
70        if af_start <= 0.0 || af_step <= 0.0 || af_max <= 0.0 {
71            return Err(Error::NonPositiveMultiplier);
72        }
73        if af_start > af_max {
74            return Err(Error::InvalidPeriod {
75                message: "af_start must be <= af_max",
76            });
77        }
78        Ok(Self {
79            af_start,
80            af_step,
81            af_max,
82            initialised: false,
83            has_emitted: false,
84            // NaN sentinels: any read of these fields before the seed candle
85            // overwrites them is a logic bug. The `initialised` flag gates
86            // every read, and the `debug_assert!` in `update` makes the
87            // invariant explicit so a future refactor cannot silently treat a
88            // sentinel as a real price.
89            prev_high: f64::NAN,
90            prev_low: f64::NAN,
91            trend: Trend::Up,
92            sar: f64::NAN,
93            ep: f64::NAN,
94            af: af_start,
95        })
96    }
97
98    /// Wilder's defaults: `(0.02, 0.02, 0.20)`.
99    pub fn classic() -> Self {
100        Self::new(0.02, 0.02, 0.20).expect("classic PSAR params are valid")
101    }
102}
103
104impl Indicator for Psar {
105    type Input = Candle;
106    type Output = f64;
107
108    fn update(&mut self, candle: Candle) -> Option<f64> {
109        if !self.initialised {
110            // Seed on the first candle; the first SAR is emitted on the second.
111            // The initial trend is assumed Up — PSAR's reversal logic flips it
112            // within the first few bars if the market is actually falling.
113            self.prev_high = candle.high;
114            self.prev_low = candle.low;
115            self.sar = candle.low;
116            self.ep = candle.high;
117            self.trend = Trend::Up;
118            self.af = self.af_start;
119            self.initialised = true;
120            // `has_emitted` stays false — this is the seed bar; the first
121            // `Some` lands on the next call.
122            return None;
123        }
124
125        // After `initialised` flips to `true`, every compute field is guaranteed
126        // finite. This guards against a future refactor that changes the seed
127        // gate but leaves a NaN sentinel reachable.
128        debug_assert!(
129            self.prev_high.is_finite()
130                && self.prev_low.is_finite()
131                && self.sar.is_finite()
132                && self.ep.is_finite(),
133            "PSAR seed state must be finite once initialised"
134        );
135
136        // Predicted SAR for this period (before clamping to prior two extremes).
137        let mut new_sar = self.sar + self.af * (self.ep - self.sar);
138
139        // Wilder rule: SAR cannot penetrate today's or yesterday's range.
140        let prev_h = self.prev_high;
141        let prev_l = self.prev_low;
142        new_sar = match self.trend {
143            Trend::Up => new_sar.min(prev_l).min(candle.low),
144            Trend::Down => new_sar.max(prev_h).max(candle.high),
145        };
146
147        let mut output_sar = new_sar;
148
149        // Check for trend reversal.
150        let reversed = match self.trend {
151            Trend::Up => candle.low <= new_sar,
152            Trend::Down => candle.high >= new_sar,
153        };
154
155        if reversed {
156            // Flip trend, reset AF and EP, place SAR at prior EP.
157            output_sar = self.ep;
158            self.trend = match self.trend {
159                Trend::Up => Trend::Down,
160                Trend::Down => Trend::Up,
161            };
162            self.ep = match self.trend {
163                Trend::Up => candle.high,
164                Trend::Down => candle.low,
165            };
166            self.af = self.af_start;
167        } else {
168            // Update EP and AF if a new extreme has been reached.
169            match self.trend {
170                Trend::Up => {
171                    if candle.high > self.ep {
172                        self.ep = candle.high;
173                        self.af = (self.af + self.af_step).min(self.af_max);
174                    }
175                }
176                Trend::Down => {
177                    if candle.low < self.ep {
178                        self.ep = candle.low;
179                        self.af = (self.af + self.af_step).min(self.af_max);
180                    }
181                }
182            }
183        }
184
185        self.sar = output_sar;
186        self.prev_high = candle.high;
187        self.prev_low = candle.low;
188        self.has_emitted = true;
189        Some(output_sar)
190    }
191
192    fn reset(&mut self) {
193        // Restore every field to its constructor state. The compute fields
194        // return to `f64::NAN` sentinels so a future refactor that reads them
195        // before re-seeding cannot silently treat `0.0` as a real price.
196        self.initialised = false;
197        self.has_emitted = false;
198        self.prev_high = f64::NAN;
199        self.prev_low = f64::NAN;
200        self.trend = Trend::Up;
201        self.sar = f64::NAN;
202        self.ep = f64::NAN;
203        self.af = self.af_start;
204    }
205
206    fn warmup_period(&self) -> usize {
207        2
208    }
209
210    fn is_ready(&self) -> bool {
211        // Match the convention of every other indicator: `is_ready` flips to
212        // `true` only once a real value has been returned. The previous
213        // implementation returned `self.initialised`, which is `true` *after*
214        // the seed candle (which itself returns `None`) — so a streaming
215        // consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }`
216        // would hit a `None` it didn't expect. (Audit finding R6.)
217        self.has_emitted
218    }
219
220    fn name(&self) -> &'static str {
221        "PSAR"
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::traits::BatchExt;
229
230    fn c(h: f64, l: f64, cl: f64) -> Candle {
231        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
232    }
233
234    #[test]
235    fn first_candle_returns_none() {
236        let mut psar = Psar::classic();
237        assert_eq!(psar.update(c(11.0, 9.0, 10.0)), None);
238    }
239
240    #[test]
241    fn pure_uptrend_sar_below_lows() {
242        let candles: Vec<Candle> = (0..40)
243            .map(|i| {
244                let base = 100.0 + f64::from(i);
245                c(base + 0.5, base - 0.5, base)
246            })
247            .collect();
248        let mut psar = Psar::classic();
249        // `all()` with `is_none_or` keeps every reachable arm on the hot path —
250        // the previous filter_map / violation-Vec construction had a cold
251        // "violation found" tuple branch that was unreachable on a clean
252        // uptrend, leaving its line uncovered by Codecov.
253        let ok = psar
254            .batch(&candles)
255            .iter()
256            .enumerate()
257            .all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9));
258        assert!(ok, "SAR sat above a candle's low on a pure uptrend");
259    }
260
261    #[test]
262    fn pure_downtrend_sar_above_highs() {
263        let candles: Vec<Candle> = (0..40)
264            .rev()
265            .map(|i| {
266                let base = 100.0 + f64::from(i);
267                c(base + 0.5, base - 0.5, base)
268            })
269            .collect();
270        let mut psar = Psar::classic();
271        // After the trend establishes downward, SAR should sit above highs.
272        // Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
273        // so the violation-tuple branch never appears as a cold path.
274        let ok = psar
275            .batch(&candles)
276            .iter()
277            .enumerate()
278            .skip(5)
279            .all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9));
280        assert!(ok, "SAR sat below a candle's high on a pure downtrend");
281    }
282
283    #[test]
284    fn batch_equals_streaming() {
285        let candles: Vec<Candle> = (0..60)
286            .map(|i| {
287                let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
288                c(m + 1.0, m - 1.0, m)
289            })
290            .collect();
291        let mut a = Psar::classic();
292        let mut b = Psar::classic();
293        assert_eq!(
294            a.batch(&candles),
295            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
296        );
297    }
298
299    /// Cover the Indicator-impl `warmup_period` (206-208) and `name`
300    /// (220-222). PSAR's warmup is the constant 2 (seed candle + first
301    /// emitting candle); the name is the literal "PSAR".
302    #[test]
303    fn accessors_and_metadata() {
304        let psar = Psar::classic();
305        assert_eq!(psar.warmup_period(), 2);
306        assert_eq!(psar.name(), "PSAR");
307    }
308
309    #[test]
310    fn rejects_invalid_params() {
311        assert!(Psar::new(0.0, 0.02, 0.20).is_err());
312        assert!(Psar::new(0.02, 0.0, 0.20).is_err());
313        assert!(Psar::new(0.30, 0.02, 0.20).is_err());
314        assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
315    }
316
317    #[test]
318    fn is_ready_only_after_first_some_value() {
319        // Audit R6: the previous implementation flipped `is_ready` to true on
320        // the seed candle (which returns `None`), making the convention
321        // `is_ready == last_value.is_some()` a lie. The new gate is
322        // `has_emitted`, set when `update` returns its first `Some`.
323        let mut psar = Psar::classic();
324        assert!(!psar.is_ready(), "fresh PSAR must not be ready");
325        let first = psar.update(c(11.0, 9.0, 10.0));
326        assert!(first.is_none(), "seed candle returns None by design");
327        assert!(
328            !psar.is_ready(),
329            "is_ready must stay false until a Some value is produced"
330        );
331        let second = psar.update(c(12.0, 10.0, 11.0));
332        assert!(second.is_some(), "second candle must emit");
333        assert!(
334            psar.is_ready(),
335            "is_ready must flip to true once a real value has been returned"
336        );
337    }
338
339    #[test]
340    fn reset_allows_clean_reuse() {
341        let candles: Vec<Candle> = (0..40)
342            .map(|i| {
343                let base = 100.0 + f64::from(i);
344                c(base + 0.5, base - 0.5, base)
345            })
346            .collect();
347        let mut psar = Psar::classic();
348        let first = psar.batch(&candles);
349        assert!(psar.is_ready());
350        psar.reset();
351        assert!(!psar.is_ready());
352        // A reset instance must reproduce a pristine run bit for bit.
353        let second = psar.batch(&candles);
354        assert_eq!(first, second);
355    }
356}