wickra_core/indicators/
jump_indicator.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
48pub struct JumpIndicator {
49 period: usize,
50 threshold: f64,
51 prev_price: Option<f64>,
52 window: VecDeque<f64>,
54 moments: ShiftedMoments,
55 last: Option<f64>,
56}
57
58impl JumpIndicator {
59 pub fn new(period: usize, threshold: f64) -> Result<Self> {
69 if period < 2 {
70 return Err(Error::InvalidPeriod {
71 message: "jump indicator needs period >= 2",
72 });
73 }
74 if period > crate::error::MAX_PERIOD {
75 return Err(Error::InvalidPeriod {
76 message: crate::error::PERIOD_ABOVE_MAX,
77 });
78 }
79 if !threshold.is_finite() || threshold <= 0.0 {
80 return Err(Error::InvalidParameter {
81 message: "jump indicator threshold must be finite and positive",
82 });
83 }
84 Ok(Self {
85 period,
86 threshold,
87 prev_price: None,
88 window: VecDeque::with_capacity(period),
89 moments: ShiftedMoments::new(),
90 last: None,
91 })
92 }
93
94 pub const fn params(&self) -> (usize, f64) {
96 (self.period, self.threshold)
97 }
98}
99
100impl Indicator for JumpIndicator {
101 type Input = f64;
102 type Output = f64;
103
104 fn update(&mut self, input: f64) -> Option<f64> {
105 if !input.is_finite() || input <= 0.0 {
106 return None;
107 }
108 let Some(prev) = self.prev_price else {
109 self.prev_price = Some(input);
110 return None;
111 };
112 self.prev_price = Some(input);
113 let r = (input / prev).ln();
114 if self.window.len() < self.period {
115 self.window.push_back(r);
117 self.moments.push(r);
118 return None;
119 }
120 let mean = self.moments.mean(self.period);
123 let sd = self.moments.sample_variance(self.period).sqrt();
124 let deviation = r - mean;
125 let label = if sd == 0.0 {
126 0.0
127 } else if deviation > self.threshold * sd {
128 1.0
129 } else if deviation < -self.threshold * sd {
130 -1.0
131 } else {
132 0.0
133 };
134 let old = self.window.pop_front().expect("window is non-empty");
136 self.moments.evict(old);
137 self.window.push_back(r);
138 self.moments.push(r);
139 if self.moments.needs_reseed(self.period) {
140 self.moments.reseed(self.window.iter().copied());
141 }
142 self.last = Some(label);
143 Some(label)
144 }
145
146 fn reset(&mut self) {
147 self.prev_price = None;
148 self.window.clear();
149 self.moments.reset();
150 self.last = None;
151 }
152
153 #[inline]
154 fn warmup_period(&self) -> usize {
155 self.period + 2
158 }
159
160 #[inline]
161 fn is_ready(&self) -> bool {
162 self.last.is_some()
163 }
164
165 #[inline]
166 fn name(&self) -> &'static str {
167 "JumpIndicator"
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::traits::BatchExt;
175
176 #[test]
177 fn rejects_bad_params() {
178 assert!(matches!(
179 JumpIndicator::new(1, 3.0),
180 Err(Error::InvalidPeriod { .. })
181 ));
182 assert!(matches!(
183 JumpIndicator::new(20, 0.0),
184 Err(Error::InvalidParameter { .. })
185 ));
186 assert!(matches!(
187 JumpIndicator::new(20, f64::NAN),
188 Err(Error::InvalidParameter { .. })
189 ));
190 }
191
192 #[test]
193 fn accessors_and_metadata() {
194 let ji = JumpIndicator::new(20, 3.0).unwrap();
195 assert_eq!(ji.params(), (20, 3.0));
196 assert_eq!(ji.warmup_period(), 22);
197 assert_eq!(ji.name(), "JumpIndicator");
198 assert!(!ji.is_ready());
199 }
200
201 #[test]
202 fn detects_upward_jump() {
203 let mut ji = JumpIndicator::new(10, 3.0).unwrap();
204 let mut prices: Vec<f64> = (0..20)
206 .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
207 .collect();
208 let last_calm = *prices.last().unwrap();
209 prices.push(last_calm * 1.2);
210 let out = ji.batch(&prices);
211 assert_eq!(out.last().copied().flatten(), Some(1.0));
212 }
213
214 #[test]
215 fn detects_downward_jump() {
216 let mut ji = JumpIndicator::new(10, 3.0).unwrap();
217 let mut prices: Vec<f64> = (0..20)
218 .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
219 .collect();
220 let last_calm = *prices.last().unwrap();
221 prices.push(last_calm * 0.8);
222 let out = ji.batch(&prices);
223 assert_eq!(out.last().copied().flatten(), Some(-1.0));
224 }
225
226 #[test]
227 fn calm_series_has_no_jumps() {
228 let mut ji = JumpIndicator::new(20, 3.0).unwrap();
229 let prices: Vec<f64> = (0..80)
230 .map(|i| 100.0 + (f64::from(i) * 0.5).sin())
231 .collect();
232 for v in ji.batch(&prices).into_iter().flatten() {
233 assert_eq!(v, 0.0);
234 }
235 }
236
237 #[test]
238 fn zero_trailing_volatility_returns_zero() {
239 let mut ji = JumpIndicator::new(10, 3.0).unwrap();
244 for v in ji.batch(&[100.0; 30]).into_iter().flatten() {
245 assert_eq!(v, 0.0);
246 }
247 }
248
249 #[test]
250 fn steady_drift_is_not_flagged() {
251 let mut ji = JumpIndicator::new(10, 3.0).unwrap();
255 let prices: Vec<f64> = (0..40).map(|i| 100.0 + f64::from(i) * 0.5).collect();
256 for v in ji.batch(&prices).into_iter().flatten() {
257 assert_eq!(v, 0.0);
258 }
259 }
260
261 #[test]
262 fn ignores_non_finite_and_non_positive() {
263 let mut ji = JumpIndicator::new(5, 3.0).unwrap();
264 let prices: Vec<f64> = (0..20)
265 .map(|i| 100.0 + (f64::from(i) * 0.6).sin())
266 .collect();
267 let out = ji.batch(&prices);
268 let last = *out.last().unwrap();
269 assert!(last.is_some());
270 assert_eq!(ji.update(f64::NAN), None);
271 assert_eq!(ji.update(-1.0), None);
272 assert_eq!(ji.update(0.0), None);
273 }
274
275 #[test]
276 fn reset_clears_state() {
277 let mut ji = JumpIndicator::new(5, 3.0).unwrap();
278 ji.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
279 assert!(ji.is_ready());
280 ji.reset();
281 assert!(!ji.is_ready());
282 assert_eq!(ji.update(1.0), None);
283 }
284
285 #[test]
286 fn batch_equals_streaming() {
287 let prices: Vec<f64> = (1..=120)
288 .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 3.0)
289 .collect();
290 let batch = JumpIndicator::new(20, 3.0).unwrap().batch(&prices);
291 let mut b = JumpIndicator::new(20, 3.0).unwrap();
292 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
293 assert_eq!(batch, streamed);
294 }
295}