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