wickra_core/indicators/
time_based_stop.rs1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
42pub struct TimeBasedStop {
43 max_bars: usize,
44 bars_held: usize,
45 last: Option<f64>,
46}
47
48impl TimeBasedStop {
49 pub fn new(max_bars: usize) -> Result<Self> {
55 if max_bars == 0 {
56 return Err(Error::PeriodZero);
57 }
58 if max_bars > crate::error::MAX_PERIOD {
59 return Err(Error::InvalidPeriod {
60 message: crate::error::PERIOD_ABOVE_MAX,
61 });
62 }
63 Ok(Self {
64 max_bars,
65 bars_held: 0,
66 last: None,
67 })
68 }
69
70 pub const fn max_bars(&self) -> usize {
72 self.max_bars
73 }
74
75 pub const fn bars_held(&self) -> usize {
77 self.bars_held
78 }
79
80 pub const fn triggered(&self) -> bool {
82 self.bars_held >= self.max_bars
83 }
84
85 pub const fn value(&self) -> Option<f64> {
87 self.last
88 }
89}
90
91impl Indicator for TimeBasedStop {
92 type Input = Candle;
93 type Output = f64;
94
95 #[inline]
96 fn update(&mut self, _candle: Candle) -> Option<f64> {
97 self.bars_held += 1;
98 let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
99 self.last = Some(progress);
100 Some(progress)
101 }
102
103 fn reset(&mut self) {
104 self.bars_held = 0;
105 self.last = None;
106 }
107
108 #[inline]
109 fn warmup_period(&self) -> usize {
110 1
111 }
112
113 #[inline]
114 fn is_ready(&self) -> bool {
115 self.last.is_some()
116 }
117
118 #[inline]
119 fn name(&self) -> &'static str {
120 "TimeBasedStop"
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::traits::BatchExt;
128 use approx::assert_relative_eq;
129
130 fn c() -> Candle {
131 Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
132 }
133
134 #[test]
135 fn rejects_zero_max_bars() {
136 assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
137 }
138
139 #[test]
140 fn accessors_and_metadata() {
141 let t = TimeBasedStop::new(5).unwrap();
142 assert_eq!(t.max_bars(), 5);
143 assert_eq!(t.bars_held(), 0);
144 assert!(!t.triggered());
145 assert_eq!(t.warmup_period(), 1);
146 assert_eq!(t.name(), "TimeBasedStop");
147 assert!(!t.is_ready());
148 assert_eq!(t.value(), None);
149 }
150
151 #[test]
152 fn progress_climbs_to_one() {
153 let mut t = TimeBasedStop::new(4).unwrap();
154 let out = t.batch(&[c(), c(), c(), c()]);
155 assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
156 assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
157 assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
158 assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
159 }
160
161 #[test]
162 fn triggers_after_max_bars() {
163 let mut t = TimeBasedStop::new(3).unwrap();
164 t.update(c());
165 assert!(!t.triggered());
166 t.update(c());
167 assert!(!t.triggered());
168 t.update(c());
169 assert!(t.triggered());
170 }
171
172 #[test]
173 fn progress_saturates_at_one() {
174 let mut t = TimeBasedStop::new(2).unwrap();
176 let out = t.batch(&[c(), c(), c(), c()]);
177 assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
178 assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
179 }
180
181 #[test]
182 fn reset_restarts_timer() {
183 let mut t = TimeBasedStop::new(3).unwrap();
184 t.batch(&[c(), c(), c()]);
185 assert!(t.triggered());
186 t.reset();
187 assert!(!t.is_ready());
188 assert_eq!(t.bars_held(), 0);
189 assert!(!t.triggered());
190 assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
191 }
192
193 #[test]
194 fn batch_equals_streaming() {
195 let candles = [c(); 10];
196 let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
197 let mut b = TimeBasedStop::new(4).unwrap();
198 let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
199 assert_eq!(batch, streamed);
200 }
201}