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 #[inline]
207 fn warmup_period(&self) -> usize {
208 2
209 }
210
211 #[inline]
212 fn is_ready(&self) -> bool {
213 // Match the convention of every other indicator: `is_ready` flips to
214 // `true` only once a real value has been returned. The previous
215 // implementation returned `self.initialised`, which is `true` *after*
216 // the seed candle (which itself returns `None`) — so a streaming
217 // consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }`
218 // would hit a `None` it didn't expect. (Audit finding R6.)
219 self.has_emitted
220 }
221
222 #[inline]
223 fn name(&self) -> &'static str {
224 "PSAR"
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::traits::BatchExt;
232
233 fn c(h: f64, l: f64, cl: f64) -> Candle {
234 Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
235 }
236
237 #[test]
238 fn first_candle_returns_none() {
239 let mut psar = Psar::classic();
240 assert_eq!(psar.update(c(11.0, 9.0, 10.0)), None);
241 }
242
243 #[test]
244 fn pure_uptrend_sar_below_lows() {
245 let candles: Vec<Candle> = (0..40)
246 .map(|i| {
247 let base = 100.0 + f64::from(i);
248 c(base + 0.5, base - 0.5, base)
249 })
250 .collect();
251 let mut psar = Psar::classic();
252 // `all()` with `is_none_or` keeps every reachable arm on the hot path —
253 // the previous filter_map / violation-Vec construction had a cold
254 // "violation found" tuple branch that was unreachable on a clean
255 // uptrend, leaving its line uncovered by Codecov.
256 let ok = psar
257 .batch(&candles)
258 .iter()
259 .enumerate()
260 .all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9));
261 assert!(ok, "SAR sat above a candle's low on a pure uptrend");
262 }
263
264 #[test]
265 fn pure_downtrend_sar_above_highs() {
266 let candles: Vec<Candle> = (0..40)
267 .rev()
268 .map(|i| {
269 let base = 100.0 + f64::from(i);
270 c(base + 0.5, base - 0.5, base)
271 })
272 .collect();
273 let mut psar = Psar::classic();
274 // After the trend establishes downward, SAR should sit above highs.
275 // Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
276 // so the violation-tuple branch never appears as a cold path.
277 let ok = psar
278 .batch(&candles)
279 .iter()
280 .enumerate()
281 .skip(5)
282 .all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9));
283 assert!(ok, "SAR sat below a candle's high on a pure downtrend");
284 }
285
286 #[test]
287 fn batch_equals_streaming() {
288 let candles: Vec<Candle> = (0..60)
289 .map(|i| {
290 let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
291 c(m + 1.0, m - 1.0, m)
292 })
293 .collect();
294 let mut a = Psar::classic();
295 let mut b = Psar::classic();
296 assert_eq!(
297 a.batch(&candles),
298 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
299 );
300 }
301
302 /// Cover the Indicator-impl `warmup_period` (206-208) and `name`
303 /// (220-222). PSAR's warmup is the constant 2 (seed candle + first
304 /// emitting candle); the name is the literal "PSAR".
305 #[test]
306 fn accessors_and_metadata() {
307 let psar = Psar::classic();
308 assert_eq!(psar.warmup_period(), 2);
309 assert_eq!(psar.name(), "PSAR");
310 }
311
312 #[test]
313 fn rejects_invalid_params() {
314 assert!(Psar::new(0.0, 0.02, 0.20).is_err());
315 assert!(Psar::new(0.02, 0.0, 0.20).is_err());
316 assert!(Psar::new(0.30, 0.02, 0.20).is_err());
317 assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
318 }
319
320 #[test]
321 fn is_ready_only_after_first_some_value() {
322 // Audit R6: the previous implementation flipped `is_ready` to true on
323 // the seed candle (which returns `None`), making the convention
324 // `is_ready == last_value.is_some()` a lie. The new gate is
325 // `has_emitted`, set when `update` returns its first `Some`.
326 let mut psar = Psar::classic();
327 assert!(!psar.is_ready(), "fresh PSAR must not be ready");
328 let first = psar.update(c(11.0, 9.0, 10.0));
329 assert!(first.is_none(), "seed candle returns None by design");
330 assert!(
331 !psar.is_ready(),
332 "is_ready must stay false until a Some value is produced"
333 );
334 let second = psar.update(c(12.0, 10.0, 11.0));
335 assert!(second.is_some(), "second candle must emit");
336 assert!(
337 psar.is_ready(),
338 "is_ready must flip to true once a real value has been returned"
339 );
340 }
341
342 #[test]
343 fn reset_allows_clean_reuse() {
344 let candles: Vec<Candle> = (0..40)
345 .map(|i| {
346 let base = 100.0 + f64::from(i);
347 c(base + 0.5, base - 0.5, base)
348 })
349 .collect();
350 let mut psar = Psar::classic();
351 let first = psar.batch(&candles);
352 assert!(psar.is_ready());
353 psar.reset();
354 assert!(!psar.is_ready());
355 // A reset instance must reproduce a pristine run bit for bit.
356 let second = psar.batch(&candles);
357 assert_eq!(first, second);
358 }
359}