Skip to content

PSAR (Parabolic SAR)

Wilder's parabolic Stop-And-Reverse: a state-machine trailing stop that accelerates toward price as a trend extends and flips sides on a penetration of the SAR line.

Quick reference

ItemValue
FamilyTrailing Stops
Input typeCandle (uses high, low)
Output typef64
Output rangeunbounded; bracketed by the prior two highs/lows
Default parametersaf_start = 0.02, af_step = 0.02, af_max = 0.20 (Wilder)
Warmup period2 (state machine seeds on the 2nd candle)
Interpretationtrailing stop that "flips" sides on penetration; never tied to a fixed bar count

Formula

PSAR is a two-state machine — Up (long bias) and Down (short bias). Each bar updates three pieces of state:

EP_t  = extreme price reached so far in the current trend (max high in Up,
        min low in Down)
AF_t  = acceleration factor, bumped by af_step each time EP makes a new
        extreme, capped at af_max
SAR_t = stop-and-reverse level

The transition is:

SAR_t = SAR_{t-1} + AF_{t-1} * (EP_{t-1} - SAR_{t-1})

# Wilder rule: SAR cannot penetrate today's or yesterday's range
if Up:    SAR_t = min(SAR_t, low_{t-1}, low_t)
if Down:  SAR_t = max(SAR_t, high_{t-1}, high_t)

# Reversal test
if Up and low_t <= SAR_t:   flip to Down, SAR_t = EP_{t-1}, reset AF
if Down and high_t >= SAR_t: flip to Up,   SAR_t = EP_{t-1}, reset AF

The exact step-by-step is crates/wickra-core/src/indicators/psar.rs:75-141.

Parameters

NameTypeDefaultConstraintSource
af_startf640.02finite, > 0, ≤ af_maxPsar::new (psar.rs:39-50)
af_stepf640.02finite, > 0Psar::new (psar.rs:39-50)
af_maxf640.20finite, > 0Psar::new (psar.rs:39-50)

Python defaults from #[pyo3(signature = (af_start=0.02, af_step=0.02, af_max=0.20))] in bindings/python/src/lib.rs. Psar::classic() returns the same triple.

Validation errors:

  • non-finite or non-positive AF parameter → Error::NonPositiveMultiplier
  • af_start > af_maxError::InvalidPeriod { message: "af_start must be <= af_max" }

Inputs / Outputs

rust
use wickra::{Indicator, Psar, Candle};
// Psar: Input = Candle, Output = f64
const _: fn(&mut Psar, Candle) -> Option<f64> = <Psar as Indicator>::update;
  • Python streaming. psar.update(candle) returns float | None.
  • Python batch. PSAR.batch(high, low, close) returns a 1-D np.ndarray; the first row is NaN (warmup) and every subsequent row holds the SAR level for that bar.
  • Node streaming. psar.update(high, low, close) returns number | null.
  • Node batch. psar.batch(high, low, close) returns Array<number> with NaN for the first row.
  • WASM streaming. psar.update(high, low, close) returns number | null once warm.
  • WASM batch. psar.batch(high, low, close) returns a Float64Array with NaN for the first row.
  • isReady convention. psar.is_ready() flips to true only once the first non-None SAR has been produced (i.e. from the second candle onwards). The first (seed) candle returns None and is_ready() stays false, matching every other indicator in the library. Previous releases flipped the flag after the seed candle even though it produced no value — consumers that wrote if psar.is_ready() { use(psar.update(c)?) } would hit an unexpected None on the first post-seed update; that's now fixed.

Warmup

warmup_period() == 2. The very first candle seeds internal state (prev_high, prev_low, sar = low, ep = high, trend = Up, af = af_start) and returns None. The second candle produces the first SAR value.

The seed trend is always Up (psar.rs:83); the indicator will reverse to Down on the first qualifying penetration. There is no look-ahead at the second candle's close — the seed is purely structural.

Edge cases

  • First bar. Always returns None; downstream code must tolerate the first row being absent without crashing.
  • Pure uptrend. With monotonically rising highs and lows, the SAR remains below the lows and accelerates toward price as the EP makes successive new highs. The pinned test pure_uptrend_sar_below_lows asserts SAR ≤ low on every emitted bar of a 40-bar ramp.
  • Pure downtrend. Symmetrically, with monotonically falling highs, the SAR sits above the highs after the trend establishes. pure_downtrend_sar_above_highs covers this.
  • Reversal mechanics. When the trend flips, SAR is set to the previous EP (not the calculated parabola value), AF is reset to af_start, and the new EP is the current bar's high (Down→Up) or low (Up→Down).
  • Choppy regime. Frequent reversals cause many AF resets; SAR becomes a poor stop in mean-reverting regimes and whipsaws.
  • NaN / infinity. Candle::new rejects non-finite OHLC values. Psar::new rejects non-finite AF parameters.
  • Reset. reset() clears the initialised flag and resets af to af_start, sar to 0.0, ep to 0.0; the next update re-seeds.

Examples

Rust

rust
use wickra::{BatchExt, Candle, Indicator, Psar};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let candles: Vec<Candle> = (0..8)
        .map(|i| {
            let base = 100.0 + f64::from(i);
            Candle::new(base, base + 0.5, base - 0.5, base + 0.25, 1.0, 0).unwrap()
        })
        .collect();
    let mut p = Psar::classic(); // (0.02, 0.02, 0.20)
    for (i, v) in p.batch(&candles).into_iter().enumerate() {
        println!("i={i} -> {:?}", v);
    }
    Ok(())
}

Output:

i=0 -> None
i=1 -> Some(99.5)
i=2 -> Some(99.58)
i=3 -> Some(99.7552)
i=4 -> Some(100.054784)
i=5 -> Some(100.4993056)
i=6 -> Some(101.099388928)
i=7 -> Some(101.85547447808)

The SAR starts at 99.5 (the first candle's low) and accelerates upward toward price as the EP makes new highs on every bar.

Python

python
import numpy as np
import wickra as ta

p = ta.PSAR()  # defaults (0.02, 0.02, 0.20)
h  = np.array([100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5])
l  = np.array([ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5])
cl = np.array([100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25])
print(p.batch(h, l, cl))

Output:

[         nan  99.5         99.58        99.7552     100.054784
 100.4993056  101.09938893 101.85547448]

Node

js
const w = require('wickra');

const p = new w.PSAR(0.02, 0.02, 0.20);
console.log(p.batch(
  [100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5],
  [ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5],
  [100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25],
));

Output:

[
  NaN,
  99.5,
  99.58,
  99.7552,
  100.054784,
  100.4993056,
  101.099388928,
  101.85547447808
]

Interpretation

  • Stop & reverse. PSAR is a trailing stop, not a signal generator in isolation: a long is exited (and a short is initiated) the bar that price penetrates the SAR line.
  • Acceleration. The further a trend extends without making new extremes, the slower the SAR rises (or falls). When EP makes a new extreme, AF bumps by af_step and the SAR closes the distance to price more aggressively.
  • Whipsaw risk. In sideways markets PSAR flips repeatedly; pair it with a trend filter (ADX, slope of EMA) to skip trades when the underlying isn't actually trending.

Common pitfalls

  • The first bar always returns None. Code that pre-allocates a vector and does out[i] = psar.update(c).unwrap() will panic on the very first input. Use if let Some(...) or skip the first row explicitly.
  • Initial trend is hard-coded to Up. The seed bar always sets trend = Up, regardless of whether the data is in a downtrend. Expect a near-immediate reversal to Down if you feed PSAR a decisively bearish series — the first emitted SAR may look "wrong" because it is the prior EP from the artificial Up seed, not from a real bullish run.
  • Acceleration cap matters. af_max = 0.20 is Wilder's choice; raising it produces an extremely tight stop near tops/bottoms but exits good trends prematurely. Lowering it produces a forgiving stop that gives back more open profit. Always re-validate strategy PnL when you change af_max.

References

  • J. Welles Wilder Jr., New Concepts in Technical Trading Systems, Trend Research, 1978. Chapter on the Parabolic SAR introduces the state-machine recursion and the default (0.02, 0.02, 0.20) parameters.

See also

  • ATR — sister indicator from the same Wilder text.
  • Donchian Channels — alternative breakout-style trailing stop based on rolling extrema.
  • Keltner Channels — envelope you can use as a smoother stop boundary than PSAR in choppy regimes.