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