Skip to main content

wickra_core/indicators/
elder_safezone.rs

1//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct ElderSafeZoneOutput {
12    /// The `SafeZone` stop level — below price when long, above price when short.
13    pub value: f64,
14    /// Trend direction: `+1.0` long, `-1.0` short.
15    pub direction: f64,
16}
17
18/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
19/// **average market noise** away from price.
20///
21/// ```text
22/// long  market noise = average downside penetration = mean( prev_low − low | low < prev_low )
23/// short market noise = average upside  penetration = mean( high − prev_high | high > prev_high )
24/// long  stop = ratchet_up(   low_t  − coeff · avg_down_penetration )
25/// short stop = ratchet_down( high_t + coeff · avg_up_penetration   )
26/// ```
27///
28/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
29/// the previous bar's low (a "downside penetration"). Averaging those
30/// penetrations over a lookback and placing the stop `coeff` multiples below the
31/// current low keeps the stop just outside normal pullbacks while still exiting on
32/// a genuine reversal. The stop trails in the trend's favour and flips when price
33/// closes through it. The average uses only the bars that actually penetrated
34/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
35/// extreme.
36///
37/// The first bar seeds the prior candle; the next `period` bars accumulate the
38/// penetration statistics, so the first stop lands after `period + 1` inputs.
39/// Each `update` is O(1).
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Candle, Indicator, ElderSafeZone};
45///
46/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
47/// let mut last = None;
48/// for i in 0..60 {
49///     let base = 100.0 + f64::from(i);
50///     let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
51///     last = indicator.update(c);
52/// }
53/// assert!(last.is_some());
54/// ```
55#[derive(Debug, Clone)]
56pub struct ElderSafeZone {
57    period: usize,
58    coeff: f64,
59    prev: Option<Candle>,
60    down_pen: VecDeque<f64>,
61    up_pen: VecDeque<f64>,
62    down_sum: f64,
63    up_sum: f64,
64    down_count: usize,
65    up_count: usize,
66    direction: f64,
67    stop: f64,
68    last: Option<ElderSafeZoneOutput>,
69}
70
71impl ElderSafeZone {
72    /// Construct an Elder `SafeZone` stop with the given averaging `period` and
73    /// noise `coeff`icient.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`Error::PeriodZero`] if `period == 0` and
78    /// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
79    pub fn new(period: usize, coeff: f64) -> Result<Self> {
80        if period == 0 {
81            return Err(Error::PeriodZero);
82        }
83        if period > crate::error::MAX_PERIOD {
84            return Err(Error::InvalidPeriod {
85                message: crate::error::PERIOD_ABOVE_MAX,
86            });
87        }
88        if !coeff.is_finite() || coeff <= 0.0 {
89            return Err(Error::NonPositiveMultiplier);
90        }
91        Ok(Self {
92            period,
93            coeff,
94            prev: None,
95            down_pen: VecDeque::with_capacity(period),
96            up_pen: VecDeque::with_capacity(period),
97            down_sum: 0.0,
98            up_sum: 0.0,
99            down_count: 0,
100            up_count: 0,
101            direction: 0.0,
102            stop: 0.0,
103            last: None,
104        })
105    }
106
107    /// Configured `(period, coeff)`.
108    pub const fn params(&self) -> (usize, f64) {
109        (self.period, self.coeff)
110    }
111
112    /// Current value if available.
113    pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
114        self.last
115    }
116
117    fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
118        if window.len() == period {
119            let old = window.pop_front().expect("non-empty");
120            *sum -= old;
121            if old > 0.0 {
122                *count -= 1;
123            }
124        }
125        window.push_back(pen);
126        *sum += pen;
127        if pen > 0.0 {
128            *count += 1;
129        }
130    }
131
132    fn avg(sum: f64, count: usize) -> f64 {
133        if count == 0 {
134            0.0
135        } else {
136            sum / count as f64
137        }
138    }
139}
140
141impl Indicator for ElderSafeZone {
142    type Input = Candle;
143    type Output = ElderSafeZoneOutput;
144
145    fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
146        let Some(prev) = self.prev else {
147            self.prev = Some(candle);
148            return None;
149        };
150        let dp = (prev.low - candle.low).max(0.0);
151        let up = (candle.high - prev.high).max(0.0);
152        self.prev = Some(candle);
153
154        Self::push(
155            &mut self.down_pen,
156            &mut self.down_sum,
157            &mut self.down_count,
158            self.period,
159            dp,
160        );
161        Self::push(
162            &mut self.up_pen,
163            &mut self.up_sum,
164            &mut self.up_count,
165            self.period,
166            up,
167        );
168        if self.down_pen.len() < self.period {
169            return None;
170        }
171
172        let avg_down = Self::avg(self.down_sum, self.down_count);
173        let avg_up = Self::avg(self.up_sum, self.up_count);
174
175        if self.direction == 0.0 {
176            self.direction = 1.0;
177            self.stop = candle.low - self.coeff * avg_down;
178        } else if self.direction > 0.0 {
179            let raw = candle.low - self.coeff * avg_down;
180            self.stop = self.stop.max(raw);
181            if candle.close < self.stop {
182                self.direction = -1.0;
183                self.stop = candle.high + self.coeff * avg_up;
184            }
185        } else {
186            let raw = candle.high + self.coeff * avg_up;
187            self.stop = self.stop.min(raw);
188            if candle.close > self.stop {
189                self.direction = 1.0;
190                self.stop = candle.low - self.coeff * avg_down;
191            }
192        }
193
194        let out = ElderSafeZoneOutput {
195            value: self.stop,
196            direction: self.direction,
197        };
198        self.last = Some(out);
199        Some(out)
200    }
201
202    fn reset(&mut self) {
203        self.prev = None;
204        self.down_pen.clear();
205        self.up_pen.clear();
206        self.down_sum = 0.0;
207        self.up_sum = 0.0;
208        self.down_count = 0;
209        self.up_count = 0;
210        self.direction = 0.0;
211        self.stop = 0.0;
212        self.last = None;
213    }
214
215    #[inline]
216    fn warmup_period(&self) -> usize {
217        self.period + 1
218    }
219
220    #[inline]
221    fn is_ready(&self) -> bool {
222        self.last.is_some()
223    }
224
225    #[inline]
226    fn name(&self) -> &'static str {
227        "ElderSafeZone"
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::traits::BatchExt;
235
236    fn c(high: f64, low: f64, close: f64) -> Candle {
237        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
238    }
239
240    #[test]
241    fn rejects_invalid_params() {
242        assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
243        assert!(matches!(
244            ElderSafeZone::new(14, 0.0),
245            Err(Error::NonPositiveMultiplier)
246        ));
247        assert!(matches!(
248            ElderSafeZone::new(14, -1.0),
249            Err(Error::NonPositiveMultiplier)
250        ));
251    }
252
253    #[test]
254    fn accessors_and_metadata() {
255        let e = ElderSafeZone::new(14, 2.0).unwrap();
256        assert_eq!(e.params(), (14, 2.0));
257        assert_eq!(e.warmup_period(), 15);
258        assert_eq!(e.name(), "ElderSafeZone");
259        assert!(!e.is_ready());
260        assert_eq!(e.value(), None);
261    }
262
263    #[test]
264    fn first_emission_at_warmup_period() {
265        let mut e = ElderSafeZone::new(3, 2.0).unwrap();
266        let candles: Vec<Candle> = (0..8)
267            .map(|i| {
268                let base = 100.0 + f64::from(i);
269                c(base + 1.0, base - 1.0, base)
270            })
271            .collect();
272        let out = e.batch(&candles);
273        let warmup = e.warmup_period(); // 4
274        assert_eq!(warmup, 4);
275        for v in out.iter().take(warmup - 1) {
276            assert!(v.is_none());
277        }
278        assert!(out[warmup - 1].is_some());
279    }
280
281    #[test]
282    fn uptrend_keeps_stop_below_price() {
283        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
284        let candles: Vec<Candle> = (0..60)
285            .map(|i| {
286                let base = 100.0 + 2.0 * f64::from(i);
287                c(base + 1.0, base - 1.0, base + 0.5)
288            })
289            .collect();
290        for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
291            if let Some(o) = o {
292                assert_eq!(o.direction, 1.0);
293                assert!(o.value <= candle.close);
294            }
295        }
296    }
297
298    #[test]
299    fn noiseless_trend_stop_sits_at_low() {
300        // Every bar makes a higher low -> no downside penetration -> avg 0 ->
301        // the stop sits exactly at the bar's low.
302        let mut e = ElderSafeZone::new(3, 2.0).unwrap();
303        let candles: Vec<Candle> = (0..10)
304            .map(|i| {
305                let base = 100.0 + f64::from(i);
306                c(base + 1.0, base - 1.0, base + 0.5)
307            })
308            .collect();
309        let out = e.batch(&candles);
310        let last_candle = candles.last().unwrap();
311        let last = out.last().unwrap().unwrap();
312        assert!((last.value - last_candle.low).abs() < 1e-9);
313    }
314
315    #[test]
316    fn flips_on_reversal() {
317        let mut candles: Vec<Candle> = (0..40)
318            .map(|i| {
319                let base = 100.0 + f64::from(i);
320                c(base + 1.0, base - 1.0, base + 0.5)
321            })
322            .collect();
323        candles.extend((0..40).map(|i| {
324            let base = 140.0 - f64::from(i);
325            c(base + 1.0, base - 1.0, base - 0.5)
326        }));
327        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
328        let dirs: Vec<f64> = e
329            .batch(&candles)
330            .into_iter()
331            .flatten()
332            .map(|o| o.direction)
333            .collect();
334        assert!(dirs.iter().any(|&d| d > 0.0));
335        assert!(dirs.iter().any(|&d| d < 0.0));
336    }
337
338    #[test]
339    fn reset_clears_state() {
340        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
341        let candles: Vec<Candle> = (0..40)
342            .map(|i| {
343                let base = 100.0 + f64::from(i);
344                c(base + 1.0, base - 1.0, base + 0.5)
345            })
346            .collect();
347        e.batch(&candles);
348        assert!(e.is_ready());
349        e.reset();
350        assert!(!e.is_ready());
351        assert_eq!(e.value(), None);
352        assert_eq!(e.update(candles[0]), None);
353    }
354
355    #[test]
356    fn batch_equals_streaming() {
357        let candles: Vec<Candle> = (0..120)
358            .map(|i| {
359                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
360                c(base + 2.0, base - 1.5, base + 0.5)
361            })
362            .collect();
363        let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
364        let mut b = ElderSafeZone::new(14, 2.0).unwrap();
365        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
366        assert_eq!(batch, streamed);
367    }
368}