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    prev2_high: f64,
56    prev2_low: f64,
57    trend: Trend,
58    sar: f64,
59    ep: f64,
60    af: f64,
61}
62
63impl Psar {
64    /// Construct PSAR with explicit acceleration parameters.
65    ///
66    /// # Errors
67    /// Returns [`Error::NonPositiveMultiplier`] / [`Error::InvalidPeriod`] for invalid params.
68    pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Result<Self> {
69        if !af_start.is_finite() || !af_step.is_finite() || !af_max.is_finite() {
70            return Err(Error::NonPositiveMultiplier);
71        }
72        if af_start <= 0.0 || af_step <= 0.0 || af_max <= 0.0 {
73            return Err(Error::NonPositiveMultiplier);
74        }
75        if af_start > af_max {
76            return Err(Error::InvalidPeriod {
77                message: "af_start must be <= af_max",
78            });
79        }
80        Ok(Self {
81            af_start,
82            af_step,
83            af_max,
84            initialised: false,
85            has_emitted: false,
86            // NaN sentinels: any read of these fields before the seed candle
87            // overwrites them is a logic bug. The `initialised` flag gates
88            // every read, and the `debug_assert!` in `update` makes the
89            // invariant explicit so a future refactor cannot silently treat a
90            // sentinel as a real price.
91            prev_high: f64::NAN,
92            prev_low: f64::NAN,
93            prev2_high: f64::NAN,
94            prev2_low: f64::NAN,
95            trend: Trend::Up,
96            sar: f64::NAN,
97            ep: f64::NAN,
98            af: af_start,
99        })
100    }
101
102    /// Wilder's defaults: `(0.02, 0.02, 0.20)`.
103    pub fn classic() -> Self {
104        Self::new(0.02, 0.02, 0.20).expect("classic PSAR params are valid")
105    }
106}
107
108impl Indicator for Psar {
109    type Input = Candle;
110    type Output = f64;
111
112    fn update(&mut self, candle: Candle) -> Option<f64> {
113        if !self.initialised {
114            // The first candle only seeds the state; the first SAR is emitted
115            // on the second.
116            self.prev_high = candle.high;
117            self.prev_low = candle.low;
118            self.initialised = true;
119            return None;
120        }
121
122        let new_sar = if self.has_emitted {
123            // Predicted SAR for this period, clamped so it never sits inside
124            // the ranges of the two bars before it (Wilder's rule; TA-Lib
125            // clamps tomorrow's SAR with today's and yesterday's extremes --
126            // the same rule, one bar earlier).
127            let predicted = self.sar + self.af * (self.ep - self.sar);
128            match self.trend {
129                Trend::Up => predicted.min(self.prev_low).min(self.prev2_low),
130                Trend::Down => predicted.max(self.prev_high).max(self.prev2_high),
131            }
132        } else {
133            // Second candle: TA-Lib's seed. The direction comes from the
134            // one-bar directional movement of the first two candles (short
135            // when the down move dominates), the SAR starts at the first
136            // candle's opposite extreme and the extreme point at this
137            // candle's. TA-Lib's first step treats this candle as both today
138            // and yesterday, so it is also the "bar before last" of the next
139            // clamp.
140            let up_move = candle.high - self.prev_high;
141            let down_move = self.prev_low - candle.low;
142            if down_move > 0.0 && down_move > up_move {
143                self.trend = Trend::Down;
144                self.sar = self.prev_high;
145                self.ep = candle.low;
146            } else {
147                self.trend = Trend::Up;
148                self.sar = self.prev_low;
149                self.ep = candle.high;
150            }
151            self.prev_high = candle.high;
152            self.prev_low = candle.low;
153            self.sar
154        };
155        let prev_h = self.prev_high;
156        let prev_l = self.prev_low;
157
158        let mut output_sar = new_sar;
159
160        // Check for trend reversal.
161        let reversed = match self.trend {
162            Trend::Up => candle.low <= new_sar,
163            Trend::Down => candle.high >= new_sar,
164        };
165
166        if reversed {
167            // Flip trend, reset AF and EP, place SAR at the prior EP -- moved
168            // outside this bar's and the previous bar's range if the reversal
169            // bar reached past it (TA-Lib's reversal clamp).
170            output_sar = match self.trend {
171                Trend::Up => self.ep.max(prev_h).max(candle.high),
172                Trend::Down => self.ep.min(prev_l).min(candle.low),
173            };
174            self.trend = match self.trend {
175                Trend::Up => Trend::Down,
176                Trend::Down => Trend::Up,
177            };
178            self.ep = match self.trend {
179                Trend::Up => candle.high,
180                Trend::Down => candle.low,
181            };
182            self.af = self.af_start;
183        } else {
184            // Update EP and AF if a new extreme has been reached.
185            match self.trend {
186                Trend::Up => {
187                    if candle.high > self.ep {
188                        self.ep = candle.high;
189                        self.af = (self.af + self.af_step).min(self.af_max);
190                    }
191                }
192                Trend::Down => {
193                    if candle.low < self.ep {
194                        self.ep = candle.low;
195                        self.af = (self.af + self.af_step).min(self.af_max);
196                    }
197                }
198            }
199        }
200
201        self.sar = output_sar;
202        self.prev2_high = self.prev_high;
203        self.prev2_low = self.prev_low;
204        self.prev_high = candle.high;
205        self.prev_low = candle.low;
206        self.has_emitted = true;
207        Some(output_sar)
208    }
209
210    fn reset(&mut self) {
211        // Restore every field to its constructor state. The compute fields
212        // return to `f64::NAN` sentinels so a future refactor that reads them
213        // before re-seeding cannot silently treat `0.0` as a real price.
214        self.initialised = false;
215        self.has_emitted = false;
216        self.prev_high = f64::NAN;
217        self.prev_low = f64::NAN;
218        self.prev2_high = f64::NAN;
219        self.prev2_low = f64::NAN;
220        self.trend = Trend::Up;
221        self.sar = f64::NAN;
222        self.ep = f64::NAN;
223        self.af = self.af_start;
224    }
225
226    #[inline]
227    fn warmup_period(&self) -> usize {
228        2
229    }
230
231    #[inline]
232    fn is_ready(&self) -> bool {
233        // Match the convention of every other indicator: `is_ready` flips to
234        // `true` only once a real value has been returned. The previous
235        // implementation returned `self.initialised`, which is `true` *after*
236        // the seed candle (which itself returns `None`) — so a streaming
237        // consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }`
238        // would hit a `None` it didn't expect. (Audit finding R6.)
239        self.has_emitted
240    }
241
242    #[inline]
243    fn name(&self) -> &'static str {
244        "PSAR"
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::traits::BatchExt;
252
253    fn c(h: f64, l: f64, cl: f64) -> Candle {
254        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
255    }
256
257    #[test]
258    fn first_candle_returns_none() {
259        let mut psar = Psar::classic();
260        assert_eq!(psar.update(c(11.0, 9.0, 10.0)), None);
261    }
262
263    #[test]
264    fn pure_uptrend_sar_below_lows() {
265        let candles: Vec<Candle> = (0..40)
266            .map(|i| {
267                let base = 100.0 + f64::from(i);
268                c(base + 0.5, base - 0.5, base)
269            })
270            .collect();
271        let mut psar = Psar::classic();
272        // `all()` with `is_none_or` keeps every reachable arm on the hot path —
273        // the previous filter_map / violation-Vec construction had a cold
274        // "violation found" tuple branch that was unreachable on a clean
275        // uptrend, leaving its line uncovered by Codecov.
276        let ok = psar
277            .batch(&candles)
278            .iter()
279            .enumerate()
280            .all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9));
281        assert!(ok, "SAR sat above a candle's low on a pure uptrend");
282    }
283
284    #[test]
285    fn pure_downtrend_sar_above_highs() {
286        let candles: Vec<Candle> = (0..40)
287            .rev()
288            .map(|i| {
289                let base = 100.0 + f64::from(i);
290                c(base + 0.5, base - 0.5, base)
291            })
292            .collect();
293        let mut psar = Psar::classic();
294        // After the trend establishes downward, SAR should sit above highs.
295        // Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
296        // so the violation-tuple branch never appears as a cold path.
297        let ok = psar
298            .batch(&candles)
299            .iter()
300            .enumerate()
301            .skip(5)
302            .all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9));
303        assert!(ok, "SAR sat below a candle's high on a pure downtrend");
304    }
305
306    #[test]
307    fn batch_equals_streaming() {
308        let candles: Vec<Candle> = (0..60)
309            .map(|i| {
310                let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
311                c(m + 1.0, m - 1.0, m)
312            })
313            .collect();
314        let mut a = Psar::classic();
315        let mut b = Psar::classic();
316        assert_eq!(
317            a.batch(&candles),
318            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
319        );
320    }
321
322    /// Cover the Indicator-impl `warmup_period` (206-208) and `name`
323    /// (220-222). PSAR's warmup is the constant 2 (seed candle + first
324    /// emitting candle); the name is the literal "PSAR".
325    #[test]
326    fn accessors_and_metadata() {
327        let psar = Psar::classic();
328        assert_eq!(psar.warmup_period(), 2);
329        assert_eq!(psar.name(), "PSAR");
330    }
331
332    #[test]
333    fn rejects_invalid_params() {
334        assert!(Psar::new(0.0, 0.02, 0.20).is_err());
335        assert!(Psar::new(0.02, 0.0, 0.20).is_err());
336        assert!(Psar::new(0.30, 0.02, 0.20).is_err());
337        assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
338    }
339
340    #[test]
341    fn is_ready_only_after_first_some_value() {
342        // Audit R6: the previous implementation flipped `is_ready` to true on
343        // the seed candle (which returns `None`), making the convention
344        // `is_ready == last_value.is_some()` a lie. The new gate is
345        // `has_emitted`, set when `update` returns its first `Some`.
346        let mut psar = Psar::classic();
347        assert!(!psar.is_ready(), "fresh PSAR must not be ready");
348        let first = psar.update(c(11.0, 9.0, 10.0));
349        assert!(first.is_none(), "seed candle returns None by design");
350        assert!(
351            !psar.is_ready(),
352            "is_ready must stay false until a Some value is produced"
353        );
354        let second = psar.update(c(12.0, 10.0, 11.0));
355        assert!(second.is_some(), "second candle must emit");
356        assert!(
357            psar.is_ready(),
358            "is_ready must flip to true once a real value has been returned"
359        );
360    }
361
362    #[test]
363    fn reset_allows_clean_reuse() {
364        let candles: Vec<Candle> = (0..40)
365            .map(|i| {
366                let base = 100.0 + f64::from(i);
367                c(base + 0.5, base - 0.5, base)
368            })
369            .collect();
370        let mut psar = Psar::classic();
371        let first = psar.batch(&candles);
372        assert!(psar.is_ready());
373        psar.reset();
374        assert!(!psar.is_ready());
375        // A reset instance must reproduce a pristine run bit for bit.
376        let second = psar.batch(&candles);
377        assert_eq!(first, second);
378    }
379
380    fn hl(high: f64, low: f64) -> Candle {
381        c(high, low, f64::midpoint(high, low))
382    }
383
384    fn run(bars: &[(f64, f64)]) -> Vec<Option<f64>> {
385        let candles: Vec<Candle> = bars.iter().map(|&(h, l)| hl(h, l)).collect();
386        Psar::classic().batch(&candles)
387    }
388
389    fn assert_series(got: &[Option<f64>], expected: &[Option<f64>]) {
390        assert_eq!(got.len(), expected.len());
391        for (g, e) in got.iter().zip(expected) {
392            assert_eq!(g.is_some(), e.is_some());
393            if let (Some(g), Some(e)) = (g, e) {
394                approx::assert_relative_eq!(*g, *e, epsilon = 1e-12);
395            }
396        }
397    }
398
399    #[test]
400    fn rejects_every_invalid_parameter() {
401        assert!(matches!(
402            Psar::new(f64::NAN, 0.02, 0.2),
403            Err(Error::NonPositiveMultiplier)
404        ));
405        assert!(matches!(
406            Psar::new(0.02, f64::INFINITY, 0.2),
407            Err(Error::NonPositiveMultiplier)
408        ));
409        assert!(matches!(
410            Psar::new(0.02, 0.02, f64::NAN),
411            Err(Error::NonPositiveMultiplier)
412        ));
413        assert!(matches!(
414            Psar::new(-0.02, 0.02, 0.2),
415            Err(Error::NonPositiveMultiplier)
416        ));
417        assert!(matches!(
418            Psar::new(0.02, -0.02, 0.2),
419            Err(Error::NonPositiveMultiplier)
420        ));
421        assert!(matches!(
422            Psar::new(0.02, 0.02, 0.0),
423            Err(Error::NonPositiveMultiplier)
424        ));
425        assert!(matches!(
426            Psar::new(0.3, 0.02, 0.2),
427            Err(Error::InvalidPeriod { .. })
428        ));
429        assert!(Psar::new(0.2, 0.02, 0.2).is_ok());
430    }
431
432    #[test]
433    fn first_value_lands_at_index_one() {
434        let out = run(&[(10.0, 8.0), (11.0, 9.0), (12.0, 10.0)]);
435        assert_eq!(Psar::classic().warmup_period(), 2);
436        assert!(out[0].is_none());
437        assert!(out[1..].iter().all(Option::is_some));
438    }
439
440    #[test]
441    fn hand_computed_long_seed() {
442        // b1: up move 1 > down move −1 -> long. SAR = low0 = 8, EP = high1 = 11.
443        //     low 9 > 8, no reversal; high 11 is not above EP: AF stays 0.02.
444        // b2: 8 + 0.02·(11 − 8) = 8.06, clamp min(9, 9) keeps 8.06; EP 12, AF 0.04.
445        // b3: 8.06 + 0.04·(12 − 8.06) = 8.2176, clamp min(10, 9); EP 13, AF 0.06.
446        // b4: 8.2176 + 0.06·(13 − 8.2176) = 8.504544, clamp min(11, 10).
447        let out = run(&[
448            (10.0, 8.0),
449            (11.0, 9.0),
450            (12.0, 10.0),
451            (13.0, 11.0),
452            (14.0, 12.0),
453        ]);
454        assert_series(
455            &out,
456            &[None, Some(8.0), Some(8.06), Some(8.2176), Some(8.504_544)],
457        );
458    }
459
460    #[test]
461    fn hand_computed_short_seed() {
462        // b1: down move 10 − 9 = 1 > 0 and > up move 11 − 12 = −1 -> short.
463        //     SAR = high0 = 12, EP = low1 = 9. high 11 < 12, no reversal.
464        // b2: 12 + 0.02·(9 − 12) = 11.94, clamp max(11, 11) keeps it; EP 8, AF 0.04.
465        // b3: 11.94 + 0.04·(8 − 11.94) = 11.7824, clamp max(10, 11) keeps it.
466        let out = run(&[(12.0, 10.0), (11.0, 9.0), (10.0, 8.0), (9.0, 7.0)]);
467        assert_series(&out, &[None, Some(12.0), Some(11.94), Some(11.7824)]);
468    }
469
470    #[test]
471    fn equal_moves_seed_long() {
472        // Outside bar: up move 1 == down move 1 -> not short, so long with
473        // SAR 8 / EP 11; low 7 <= 8 reverses immediately to the EP, clamped
474        // to max(11, high1 11, high 11) = 11.
475        let out = run(&[(10.0, 8.0), (11.0, 7.0)]);
476        assert_series(&out, &[None, Some(11.0)]);
477    }
478
479    #[test]
480    fn hand_computed_immediate_reversal_long_to_short_then_back() {
481        // b1: up −1, down 0 (not > 0) -> long, SAR 8, EP 9. low 8 <= 8 reverses:
482        //     SAR = max(EP 9, prev high 9 (b1 itself), high 9) = 9; short, EP 8.
483        // b2: 9 + 0.02·(8 − 9) = 8.98, clamp max(9, 9) = 9 (b1 is also the bar
484        //     before last). high 9.5 >= 9 reverses: SAR = min(EP 8, prev low 8,
485        //     low 7.5) = 7.5 (the clamp applies), long, EP 9.5, AF 0.02.
486        // b3: 7.5 + 0.02·(9.5 − 7.5) = 7.54, clamp min(7.5, 8) = 7.5.
487        let out = run(&[(10.0, 8.0), (9.0, 8.0), (9.5, 7.5), (10.5, 9.0)]);
488        assert_series(&out, &[None, Some(9.0), Some(7.5), Some(7.5)]);
489    }
490
491    #[test]
492    fn hand_computed_immediate_reversal_short_to_long() {
493        // b1: up 0, down 1 -> short, SAR = high0 10, EP 7. high 10 >= 10
494        //     reverses: SAR = min(EP 7, prev low 7, low 7) = 7; long, EP 10.
495        // b2: 7 + 0.02·(10 − 7) = 7.06, clamp min(7, 7) = 7; EP 10.5.
496        // b3: 7 + 0.04·(10.5 − 7) = 7.14, clamp min(8, 7) = 7.
497        let out = run(&[(10.0, 8.0), (10.0, 7.0), (10.5, 8.0), (11.0, 9.0)]);
498        assert_series(&out, &[None, Some(7.0), Some(7.0), Some(7.0)]);
499    }
500
501    #[test]
502    fn hand_computed_reversal_clamped_above_the_extreme_point() {
503        // Long seed as in `hand_computed_long_seed` up to b2 (SAR 8.06, EP 12, AF 0.04).
504        // b3 (13, 8): 8.06 + 0.04·(12 − 8.06) = 8.2176; low 8 <= 8.2176
505        //     reverses. The bar's high 13 is above EP 12, so SAR =
506        //     max(12, prev high 12, 13) = 13. Short, EP 8, AF 0.02.
507        // b4 (12, 7): 13 + 0.02·(8 − 13) = 12.9, clamp max(13, 12) = 13; EP 7, AF 0.04.
508        // b5 (11.5, 6.5): 13 + 0.04·(7 − 13) = 12.76, clamp max(12, 13) = 13.
509        let out = run(&[
510            (10.0, 8.0),
511            (11.0, 9.0),
512            (12.0, 10.0),
513            (13.0, 8.0),
514            (12.0, 7.0),
515            (11.5, 6.5),
516        ]);
517        assert_series(
518            &out,
519            &[
520                None,
521                Some(8.0),
522                Some(8.06),
523                Some(13.0),
524                Some(13.0),
525                Some(13.0),
526            ],
527        );
528    }
529
530    #[test]
531    fn acceleration_factor_caps_at_max() {
532        // AF (0.1, 0.1, 0.2): b2 AF 0.1 -> 0.2, then capped at 0.2.
533        //   b1 SAR 8, EP 11.
534        //   b2 8 + 0.1·3 = 8.3; EP 12, AF 0.2.
535        //   b3 8.3 + 0.2·(12 − 8.3) = 9.04, clamped to min(b2 low 10, b1 low 9) = 9;
536        //      EP 13, AF min(0.3, 0.2) = 0.2.
537        //   b4 9 + 0.2·(13 − 9) = 9.8, clamp min(11, 10) keeps it.
538        let candles: Vec<Candle> = [
539            (10.0, 8.0),
540            (11.0, 9.0),
541            (12.0, 10.0),
542            (13.0, 11.0),
543            (14.0, 12.0),
544        ]
545        .iter()
546        .map(|&(h, l)| hl(h, l))
547        .collect();
548        let out = Psar::new(0.1, 0.1, 0.2).unwrap().batch(&candles);
549        assert_series(&out, &[None, Some(8.0), Some(8.3), Some(9.0), Some(9.8)]);
550    }
551
552    #[test]
553    fn reset_matches_a_fresh_instance_and_batch_nan_into() {
554        let candles: Vec<Candle> = (0..60)
555            .map(|i| {
556                let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
557                c(m + 1.0, m - 1.0, m)
558            })
559            .collect();
560        let mut psar = Psar::classic();
561        let _ = psar.batch(&candles);
562        psar.reset();
563        let after_reset = psar.batch(&candles);
564        assert_eq!(after_reset, Psar::classic().batch(&candles));
565        let expected: Vec<u64> = after_reset
566            .iter()
567            .map(|v| v.unwrap_or(f64::NAN).to_bits())
568            .collect();
569        let mut out = vec![0.0; candles.len()];
570        Psar::classic().batch_nan_into(&candles, &mut out);
571        let got: Vec<u64> = out.iter().map(|v| v.to_bits()).collect();
572        assert_eq!(got, expected);
573    }
574}