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
| Item | Value |
|---|---|
| Family | Trailing Stops |
| Input type | Candle (uses high, low) |
| Output type | f64 |
| Output range | unbounded; bracketed by the prior two highs/lows |
| Default parameters | af_start = 0.02, af_step = 0.02, af_max = 0.20 (Wilder) |
| Warmup period | 2 (state machine seeds on the 2nd candle) |
| Interpretation | trailing 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 levelThe 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 AFThe exact step-by-step is crates/wickra-core/src/indicators/psar.rs:75-141.
Parameters
| Name | Type | Default | Constraint | Source |
|---|---|---|---|---|
af_start | f64 | 0.02 | finite, > 0, ≤ af_max | Psar::new (psar.rs:39-50) |
af_step | f64 | 0.02 | finite, > 0 | Psar::new (psar.rs:39-50) |
af_max | f64 | 0.20 | finite, > 0 | Psar::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_max→Error::InvalidPeriod { message: "af_start must be <= af_max" }
Inputs / Outputs
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)returnsfloat | None. - Python batch.
PSAR.batch(high, low, close)returns a 1-Dnp.ndarray; the first row isNaN(warmup) and every subsequent row holds the SAR level for that bar. - Node streaming.
psar.update(high, low, close)returnsnumber | null. - Node batch.
psar.batch(high, low, close)returnsArray<number>withNaNfor the first row. - WASM streaming.
psar.update(high, low, close)returnsnumber | nullonce warm. - WASM batch.
psar.batch(high, low, close)returns aFloat64ArraywithNaNfor the first row. isReadyconvention.psar.is_ready()flips totrueonly once the first non-NoneSAR has been produced (i.e. from the second candle onwards). The first (seed) candle returnsNoneandis_ready()staysfalse, matching every other indicator in the library. Previous releases flipped the flag after the seed candle even though it produced no value — consumers that wroteif psar.is_ready() { use(psar.update(c)?) }would hit an unexpectedNoneon 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_lowsassertsSAR ≤ lowon 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_highscovers this. - Reversal mechanics. When the trend flips,
SARis set to the previous EP (not the calculated parabola value), AF is reset toaf_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::newrejects non-finite OHLC values.Psar::newrejects non-finite AF parameters. - Reset.
reset()clears the initialised flag and resetsaftoaf_start,sarto0.0,epto0.0; the nextupdatere-seeds.
Examples
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
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
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_stepand 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 doesout[i] = psar.update(c).unwrap()will panic on the very first input. Useif let Some(...)or skip the first row explicitly. - Initial trend is hard-coded to
Up. The seed bar always setstrend = Up, regardless of whether the data is in a downtrend. Expect a near-immediate reversal toDownif you feed PSAR a decisively bearish series — the first emitted SAR may look "wrong" because it is the prior EP from the artificialUpseed, not from a real bullish run. - Acceleration cap matters.
af_max = 0.20is 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 changeaf_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.