Skip to main content

wickra_core/indicators/
step_trailing_stop.rs

1//! Step Trailing Stop.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Step Trailing Stop — a stop that ratchets in fixed-size discrete steps and
7/// flips to the opposite side on a close-through.
8///
9/// ```text
10/// long:   target = close − step_size
11///         stop_t = max(stop_{t−1}, floor(target / step_size) · step_size)
12///                  while close ≥ stop_{t−1}
13/// short:  target = close + step_size
14///         stop_t = min(stop_{t−1}, ceil(target / step_size) · step_size)
15///                  while close ≤ stop_{t−1}
16/// flip-to-long  on close > prev short-stop -> stop = floor((close − step) / step) · step
17/// flip-to-short on close < prev long-stop  -> stop = ceil((close + step) / step) · step
18/// ```
19///
20/// Quantising the stop to a multiple of `step_size` keeps the level on a
21/// round-number grid, which mirrors how many discretionary traders move stops
22/// by hand (in $0.50, $1, or 10-pip increments). The first input seeds a long
23/// stop one step below the snapped close.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, StepTrailingStop};
29///
30/// let mut indicator = StepTrailingStop::new(1.0).unwrap();
31/// let mut last = None;
32/// for i in 0..20 {
33///     last = indicator.update(100.0 + f64::from(i));
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct StepTrailingStop {
39    step_size: f64,
40    prev_stop: Option<f64>,
41    long: bool,
42}
43
44impl StepTrailingStop {
45    /// Construct a Step Trailing Stop with an explicit step size.
46    ///
47    /// # Errors
48    /// Returns [`Error::NonPositiveMultiplier`] if `step_size` is not strictly
49    /// positive and finite.
50    pub fn new(step_size: f64) -> Result<Self> {
51        if !step_size.is_finite() || step_size <= 0.0 {
52            return Err(Error::NonPositiveMultiplier);
53        }
54        Ok(Self {
55            step_size,
56            prev_stop: None,
57            long: true,
58        })
59    }
60
61    /// A common configuration: a `1.0` step size.
62    pub fn classic() -> Self {
63        Self::new(1.0).expect("classic step is valid")
64    }
65
66    /// Configured step size.
67    pub const fn step_size(&self) -> f64 {
68        self.step_size
69    }
70
71    /// Snap `value` down to the nearest `step_size`-grid line below it.
72    fn snap_long(&self, close: f64) -> f64 {
73        ((close - self.step_size) / self.step_size).floor() * self.step_size
74    }
75
76    /// Snap `value` up to the nearest `step_size`-grid line above it.
77    fn snap_short(&self, close: f64) -> f64 {
78        ((close + self.step_size) / self.step_size).ceil() * self.step_size
79    }
80}
81
82impl Indicator for StepTrailingStop {
83    type Input = f64;
84    type Output = f64;
85
86    #[inline]
87    fn update(&mut self, close: f64) -> Option<f64> {
88        if !close.is_finite() {
89            return None;
90        }
91        let stop = match self.prev_stop {
92            Some(prev) => {
93                if self.long {
94                    if close < prev {
95                        self.long = false;
96                        self.snap_short(close)
97                    } else {
98                        prev.max(self.snap_long(close))
99                    }
100                } else if close > prev {
101                    self.long = true;
102                    self.snap_long(close)
103                } else {
104                    prev.min(self.snap_short(close))
105                }
106            }
107            None => self.snap_long(close),
108        };
109        self.prev_stop = Some(stop);
110        Some(stop)
111    }
112
113    fn reset(&mut self) {
114        self.prev_stop = None;
115        self.long = true;
116    }
117
118    #[inline]
119    fn warmup_period(&self) -> usize {
120        1
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.prev_stop.is_some()
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "StepTrailingStop"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_invalid_step() {
142        assert!(StepTrailingStop::new(0.0).is_err());
143        assert!(StepTrailingStop::new(-1.0).is_err());
144        assert!(StepTrailingStop::new(f64::NAN).is_err());
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let s = StepTrailingStop::classic();
150        assert_relative_eq!(s.step_size(), 1.0, epsilon = 1e-12);
151        assert_eq!(s.name(), "StepTrailingStop");
152        assert_eq!(s.warmup_period(), 1);
153    }
154
155    #[test]
156    fn first_value_snaps_below_price() {
157        let mut s = StepTrailingStop::new(1.0).unwrap();
158        // floor((100.4 - 1) / 1) · 1 = 99.
159        assert_relative_eq!(s.update(100.4).unwrap(), 99.0, epsilon = 1e-12);
160    }
161
162    #[test]
163    fn long_stop_ratchets_in_discrete_steps() {
164        let mut s = StepTrailingStop::new(1.0).unwrap();
165        let out: Vec<f64> = [100.0, 100.5, 101.0, 102.0, 103.5]
166            .iter()
167            .map(|&p| s.update(p).unwrap())
168            .collect();
169        // 100 -> 99, 100.5 -> 99 (no advance), 101 -> 100, 102 -> 101, 103.5 -> 102.
170        assert_relative_eq!(out[0], 99.0, epsilon = 1e-9);
171        assert_relative_eq!(out[1], 99.0, epsilon = 1e-9);
172        assert_relative_eq!(out[2], 100.0, epsilon = 1e-9);
173        assert_relative_eq!(out[3], 101.0, epsilon = 1e-9);
174        assert_relative_eq!(out[4], 102.0, epsilon = 1e-9);
175    }
176
177    #[test]
178    fn flips_to_short_on_close_through_and_back() {
179        let mut s = StepTrailingStop::new(1.0).unwrap();
180        s.update(100.0); // 99
181        s.update(105.0); // 104
182        let flipped = s.update(50.0).unwrap();
183        // ceil((50+1)/1)·1 = 51.
184        assert_relative_eq!(flipped, 51.0, epsilon = 1e-9);
185        // Rally back through 51 -> flip long at 99.
186        let back = s.update(100.0).unwrap();
187        assert_relative_eq!(back, 99.0, epsilon = 1e-9);
188    }
189
190    #[test]
191    fn short_stop_ratchets_down() {
192        let mut s = StepTrailingStop::new(1.0).unwrap();
193        s.update(100.0);
194        s.update(50.0); // short at 51
195        let v = s.update(40.0).unwrap();
196        // ceil((40+1)/1)·1 = 41 -> min(51, 41) = 41.
197        assert_relative_eq!(v, 41.0, epsilon = 1e-9);
198    }
199
200    #[test]
201    fn constant_series_holds_stop() {
202        let mut s = StepTrailingStop::new(1.0).unwrap();
203        let out = s.batch(&[100.0; 30]);
204        for v in out.into_iter().flatten() {
205            assert_relative_eq!(v, 99.0, epsilon = 1e-12);
206        }
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut s = StepTrailingStop::new(1.0).unwrap();
212        s.update(100.0);
213        s.update(50.0);
214        assert!(s.is_ready());
215        s.reset();
216        assert!(!s.is_ready());
217        assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12);
218    }
219
220    #[test]
221    fn batch_equals_streaming() {
222        let prices: Vec<f64> = (0..80)
223            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
224            .collect();
225        let mut a = StepTrailingStop::classic();
226        let mut b = StepTrailingStop::classic();
227        assert_eq!(
228            a.batch(&prices),
229            prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
230        );
231    }
232}