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 !coeff.is_finite() || coeff <= 0.0 {
84            return Err(Error::NonPositiveMultiplier);
85        }
86        Ok(Self {
87            period,
88            coeff,
89            prev: None,
90            down_pen: VecDeque::with_capacity(period),
91            up_pen: VecDeque::with_capacity(period),
92            down_sum: 0.0,
93            up_sum: 0.0,
94            down_count: 0,
95            up_count: 0,
96            direction: 0.0,
97            stop: 0.0,
98            last: None,
99        })
100    }
101
102    /// Configured `(period, coeff)`.
103    pub const fn params(&self) -> (usize, f64) {
104        (self.period, self.coeff)
105    }
106
107    /// Current value if available.
108    pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
109        self.last
110    }
111
112    fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
113        if window.len() == period {
114            let old = window.pop_front().expect("non-empty");
115            *sum -= old;
116            if old > 0.0 {
117                *count -= 1;
118            }
119        }
120        window.push_back(pen);
121        *sum += pen;
122        if pen > 0.0 {
123            *count += 1;
124        }
125    }
126
127    fn avg(sum: f64, count: usize) -> f64 {
128        if count == 0 {
129            0.0
130        } else {
131            sum / count as f64
132        }
133    }
134}
135
136impl Indicator for ElderSafeZone {
137    type Input = Candle;
138    type Output = ElderSafeZoneOutput;
139
140    fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
141        let Some(prev) = self.prev else {
142            self.prev = Some(candle);
143            return None;
144        };
145        let dp = (prev.low - candle.low).max(0.0);
146        let up = (candle.high - prev.high).max(0.0);
147        self.prev = Some(candle);
148
149        Self::push(
150            &mut self.down_pen,
151            &mut self.down_sum,
152            &mut self.down_count,
153            self.period,
154            dp,
155        );
156        Self::push(
157            &mut self.up_pen,
158            &mut self.up_sum,
159            &mut self.up_count,
160            self.period,
161            up,
162        );
163        if self.down_pen.len() < self.period {
164            return None;
165        }
166
167        let avg_down = Self::avg(self.down_sum, self.down_count);
168        let avg_up = Self::avg(self.up_sum, self.up_count);
169
170        if self.direction == 0.0 {
171            self.direction = 1.0;
172            self.stop = candle.low - self.coeff * avg_down;
173        } else if self.direction > 0.0 {
174            let raw = candle.low - self.coeff * avg_down;
175            self.stop = self.stop.max(raw);
176            if candle.close < self.stop {
177                self.direction = -1.0;
178                self.stop = candle.high + self.coeff * avg_up;
179            }
180        } else {
181            let raw = candle.high + self.coeff * avg_up;
182            self.stop = self.stop.min(raw);
183            if candle.close > self.stop {
184                self.direction = 1.0;
185                self.stop = candle.low - self.coeff * avg_down;
186            }
187        }
188
189        let out = ElderSafeZoneOutput {
190            value: self.stop,
191            direction: self.direction,
192        };
193        self.last = Some(out);
194        Some(out)
195    }
196
197    fn reset(&mut self) {
198        self.prev = None;
199        self.down_pen.clear();
200        self.up_pen.clear();
201        self.down_sum = 0.0;
202        self.up_sum = 0.0;
203        self.down_count = 0;
204        self.up_count = 0;
205        self.direction = 0.0;
206        self.stop = 0.0;
207        self.last = None;
208    }
209
210    fn warmup_period(&self) -> usize {
211        self.period + 1
212    }
213
214    fn is_ready(&self) -> bool {
215        self.last.is_some()
216    }
217
218    fn name(&self) -> &'static str {
219        "ElderSafeZone"
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::traits::BatchExt;
227
228    fn c(high: f64, low: f64, close: f64) -> Candle {
229        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
230    }
231
232    #[test]
233    fn rejects_invalid_params() {
234        assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
235        assert!(matches!(
236            ElderSafeZone::new(14, 0.0),
237            Err(Error::NonPositiveMultiplier)
238        ));
239        assert!(matches!(
240            ElderSafeZone::new(14, -1.0),
241            Err(Error::NonPositiveMultiplier)
242        ));
243    }
244
245    #[test]
246    fn accessors_and_metadata() {
247        let e = ElderSafeZone::new(14, 2.0).unwrap();
248        assert_eq!(e.params(), (14, 2.0));
249        assert_eq!(e.warmup_period(), 15);
250        assert_eq!(e.name(), "ElderSafeZone");
251        assert!(!e.is_ready());
252        assert_eq!(e.value(), None);
253    }
254
255    #[test]
256    fn first_emission_at_warmup_period() {
257        let mut e = ElderSafeZone::new(3, 2.0).unwrap();
258        let candles: Vec<Candle> = (0..8)
259            .map(|i| {
260                let base = 100.0 + f64::from(i);
261                c(base + 1.0, base - 1.0, base)
262            })
263            .collect();
264        let out = e.batch(&candles);
265        let warmup = e.warmup_period(); // 4
266        assert_eq!(warmup, 4);
267        for v in out.iter().take(warmup - 1) {
268            assert!(v.is_none());
269        }
270        assert!(out[warmup - 1].is_some());
271    }
272
273    #[test]
274    fn uptrend_keeps_stop_below_price() {
275        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
276        let candles: Vec<Candle> = (0..60)
277            .map(|i| {
278                let base = 100.0 + 2.0 * f64::from(i);
279                c(base + 1.0, base - 1.0, base + 0.5)
280            })
281            .collect();
282        for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
283            if let Some(o) = o {
284                assert_eq!(o.direction, 1.0);
285                assert!(o.value <= candle.close);
286            }
287        }
288    }
289
290    #[test]
291    fn noiseless_trend_stop_sits_at_low() {
292        // Every bar makes a higher low -> no downside penetration -> avg 0 ->
293        // the stop sits exactly at the bar's low.
294        let mut e = ElderSafeZone::new(3, 2.0).unwrap();
295        let candles: Vec<Candle> = (0..10)
296            .map(|i| {
297                let base = 100.0 + f64::from(i);
298                c(base + 1.0, base - 1.0, base + 0.5)
299            })
300            .collect();
301        let out = e.batch(&candles);
302        let last_candle = candles.last().unwrap();
303        let last = out.last().unwrap().unwrap();
304        assert!((last.value - last_candle.low).abs() < 1e-9);
305    }
306
307    #[test]
308    fn flips_on_reversal() {
309        let mut candles: Vec<Candle> = (0..40)
310            .map(|i| {
311                let base = 100.0 + f64::from(i);
312                c(base + 1.0, base - 1.0, base + 0.5)
313            })
314            .collect();
315        candles.extend((0..40).map(|i| {
316            let base = 140.0 - f64::from(i);
317            c(base + 1.0, base - 1.0, base - 0.5)
318        }));
319        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
320        let dirs: Vec<f64> = e
321            .batch(&candles)
322            .into_iter()
323            .flatten()
324            .map(|o| o.direction)
325            .collect();
326        assert!(dirs.iter().any(|&d| d > 0.0));
327        assert!(dirs.iter().any(|&d| d < 0.0));
328    }
329
330    #[test]
331    fn reset_clears_state() {
332        let mut e = ElderSafeZone::new(5, 2.0).unwrap();
333        let candles: Vec<Candle> = (0..40)
334            .map(|i| {
335                let base = 100.0 + f64::from(i);
336                c(base + 1.0, base - 1.0, base + 0.5)
337            })
338            .collect();
339        e.batch(&candles);
340        assert!(e.is_ready());
341        e.reset();
342        assert!(!e.is_ready());
343        assert_eq!(e.value(), None);
344        assert_eq!(e.update(candles[0]), None);
345    }
346
347    #[test]
348    fn batch_equals_streaming() {
349        let candles: Vec<Candle> = (0..120)
350            .map(|i| {
351                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
352                c(base + 2.0, base - 1.5, base + 0.5)
353            })
354            .collect();
355        let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
356        let mut b = ElderSafeZone::new(14, 2.0).unwrap();
357        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
358        assert_eq!(batch, streamed);
359    }
360}