Skip to main content

wickra_core/indicators/
time_based_stop.rs

1//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
8/// price.
9///
10/// ```text
11/// bars_held increments by 1 each bar (since the last reset)
12/// progress  = min(bars_held / max_bars, 1.0)     in [0, 1]
13/// stop fires when progress == 1.0  (bars_held >= max_bars)
14/// ```
15///
16/// Some setups should not be given unlimited time to work: a mean-reversion entry
17/// that has not reverted within `max_bars`, or an event trade whose catalyst has
18/// passed, is best closed regardless of price. This indicator is a pure timer —
19/// it ignores the candle's prices entirely and reports the fraction of the
20/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
21/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
22/// restarts from the position open.
23///
24/// Each `update` is O(1) and the first bar already emits a value
25/// (`1 / max_bars`).
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, TimeBasedStop};
31///
32/// let mut indicator = TimeBasedStop::new(5).unwrap();
33/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
34/// // Five bars reach the stop.
35/// let mut last = 0.0;
36/// for _ in 0..5 {
37///     last = indicator.update(c).unwrap();
38/// }
39/// assert_eq!(last, 1.0);
40/// ```
41#[derive(Debug, Clone)]
42pub struct TimeBasedStop {
43    max_bars: usize,
44    bars_held: usize,
45    last: Option<f64>,
46}
47
48impl TimeBasedStop {
49    /// Construct a time-based stop that fires after `max_bars` bars.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if `max_bars == 0`.
54    pub fn new(max_bars: usize) -> Result<Self> {
55        if max_bars == 0 {
56            return Err(Error::PeriodZero);
57        }
58        Ok(Self {
59            max_bars,
60            bars_held: 0,
61            last: None,
62        })
63    }
64
65    /// Configured maximum holding period in bars.
66    pub const fn max_bars(&self) -> usize {
67        self.max_bars
68    }
69
70    /// Number of bars held since the last reset.
71    pub const fn bars_held(&self) -> usize {
72        self.bars_held
73    }
74
75    /// Whether the stop has fired (the holding period has fully elapsed).
76    pub const fn triggered(&self) -> bool {
77        self.bars_held >= self.max_bars
78    }
79
80    /// Current value if available.
81    pub const fn value(&self) -> Option<f64> {
82        self.last
83    }
84}
85
86impl Indicator for TimeBasedStop {
87    type Input = Candle;
88    type Output = f64;
89
90    fn update(&mut self, _candle: Candle) -> Option<f64> {
91        self.bars_held += 1;
92        let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
93        self.last = Some(progress);
94        Some(progress)
95    }
96
97    fn reset(&mut self) {
98        self.bars_held = 0;
99        self.last = None;
100    }
101
102    fn warmup_period(&self) -> usize {
103        1
104    }
105
106    fn is_ready(&self) -> bool {
107        self.last.is_some()
108    }
109
110    fn name(&self) -> &'static str {
111        "TimeBasedStop"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    fn c() -> Candle {
122        Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
123    }
124
125    #[test]
126    fn rejects_zero_max_bars() {
127        assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
128    }
129
130    #[test]
131    fn accessors_and_metadata() {
132        let t = TimeBasedStop::new(5).unwrap();
133        assert_eq!(t.max_bars(), 5);
134        assert_eq!(t.bars_held(), 0);
135        assert!(!t.triggered());
136        assert_eq!(t.warmup_period(), 1);
137        assert_eq!(t.name(), "TimeBasedStop");
138        assert!(!t.is_ready());
139        assert_eq!(t.value(), None);
140    }
141
142    #[test]
143    fn progress_climbs_to_one() {
144        let mut t = TimeBasedStop::new(4).unwrap();
145        let out = t.batch(&[c(), c(), c(), c()]);
146        assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
147        assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
148        assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
149        assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
150    }
151
152    #[test]
153    fn triggers_after_max_bars() {
154        let mut t = TimeBasedStop::new(3).unwrap();
155        t.update(c());
156        assert!(!t.triggered());
157        t.update(c());
158        assert!(!t.triggered());
159        t.update(c());
160        assert!(t.triggered());
161    }
162
163    #[test]
164    fn progress_saturates_at_one() {
165        // Beyond max_bars the progress stays clamped at 1.0.
166        let mut t = TimeBasedStop::new(2).unwrap();
167        let out = t.batch(&[c(), c(), c(), c()]);
168        assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
169        assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
170    }
171
172    #[test]
173    fn reset_restarts_timer() {
174        let mut t = TimeBasedStop::new(3).unwrap();
175        t.batch(&[c(), c(), c()]);
176        assert!(t.triggered());
177        t.reset();
178        assert!(!t.is_ready());
179        assert_eq!(t.bars_held(), 0);
180        assert!(!t.triggered());
181        assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn batch_equals_streaming() {
186        let candles = [c(); 10];
187        let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
188        let mut b = TimeBasedStop::new(4).unwrap();
189        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
190        assert_eq!(batch, streamed);
191    }
192}