PSAR — the wiki page still claimed "Node streaming. Not exposed in the
Node binding." That was true for an early release but is no longer:
the Node binding has exposed `psar.update(high, low, close)` since the
B1 fix in todo-detailed.md, and the WASM binding now exposes the same
streaming surface (audit finding R3, this branch). The page lists all
three streaming + batch shapes and adds a paragraph on the new
`is_ready` convention (audit finding R6 — flips on the first non-None
SAR, not on the seed candle).
HistoricalVolatility — the "Non-positive prices" edge-case note still
described the old behaviour ("that return is treated as 0"). The new
behaviour skips the bad tick entirely (audit finding R13): the
indicator's state is left untouched, the previous valid value is
returned, and the next real tick re-anchors against the previous
*valid* price. Updated the note to describe the new behaviour and
explain why (silently treating bad ticks as "no movement" underreports
realised volatility).
258 lines
9.6 KiB
Markdown
258 lines
9.6 KiB
Markdown
# 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 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
|
|
|
|
| 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
|
|
|
|
```rust
|
|
impl Indicator for Psar {
|
|
type Input = Candle;
|
|
type Output = f64;
|
|
fn update(&mut self, candle: Candle) -> Option<f64>;
|
|
fn warmup_period(&self) -> usize { 2 }
|
|
}
|
|
```
|
|
|
|
- **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](../volatility-bands/Indicator-Atr.md) — sister indicator from the same Wilder text.
|
|
- [Donchian Channels](../volatility-bands/Indicator-Donchian.md) — alternative breakout-style
|
|
trailing stop based on rolling extrema.
|
|
- [Keltner Channels](../volatility-bands/Indicator-Keltner.md) — envelope you can use as a
|
|
smoother stop boundary than PSAR in choppy regimes.
|