Skip to main content

wickra_core/indicators/
jump_indicator.rs

1//! Jump Indicator — detects return outliers relative to trailing volatility.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Jump Indicator — a discrete `{−1, 0, +1}` flag for whether the current log
9/// return is an outlier relative to the trailing volatility of returns.
10///
11/// ```text
12/// rₜ   = ln(priceₜ / priceₜ₋₁)
13/// μ, σ = sample mean and stddev of the `period` returns *before* rₜ (trailing)
14/// flag = +1 if rₜ − μ >  threshold · σ
15///        −1 if rₜ − μ < −threshold · σ
16///         0 otherwise
17/// ```
18///
19/// The baseline is the trailing return distribution and **excludes** the current
20/// return, so a genuine jump cannot inflate the band it is tested against.
21/// Measuring the deviation from the trailing mean `μ` (not the raw return) means
22/// a steady drift is *not* flagged — only moves that are large relative to the
23/// recent return distribution count. `+1` marks an up jump, `−1` a down jump,
24/// and `0` an ordinary move. When the trailing window has zero dispersion
25/// (`σ = 0`, e.g. a perfectly constant drift) there is no defined baseline and
26/// the indicator returns `0` rather than flagging every move.
27///
28/// This is the generic, threshold-tunable detector; downstream models keep any
29/// regime-specific sensitivity by choosing `threshold`. Non-finite and
30/// non-positive prices are ignored (the log return is undefined): the tick is
31/// dropped and the last value returned.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Indicator, JumpIndicator};
37///
38/// let mut indicator = JumpIndicator::new(20, 3.0).unwrap();
39/// let mut last = None;
40/// for i in 0..40 {
41///     last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
42/// }
43/// // A calm sinusoid produces no jumps.
44/// assert_eq!(last, Some(0.0));
45/// ```
46#[derive(Debug, Clone)]
47pub struct JumpIndicator {
48    period: usize,
49    threshold: f64,
50    prev_price: Option<f64>,
51    /// Trailing window of the `period` returns preceding the current one.
52    window: VecDeque<f64>,
53    sum: f64,
54    sum_sq: f64,
55    last: Option<f64>,
56}
57
58impl JumpIndicator {
59    /// Construct a new Jump Indicator.
60    ///
61    /// `threshold` is the number of trailing standard deviations a return must
62    /// exceed to be flagged.
63    ///
64    /// # Errors
65    /// Returns [`Error::InvalidPeriod`] if `period < 2` (the sample standard
66    /// deviation needs at least two returns), or [`Error::InvalidParameter`] if
67    /// `threshold` is not finite and positive.
68    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    /// Configured `(period, threshold)`.
91    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            // Still filling the trailing window; no baseline yet.
112            self.window.push_back(r);
113            self.sum += r;
114            self.sum_sq += r * r;
115            return None;
116        }
117        // Trailing window is full: classify `r` against the volatility of the
118        // `period` returns that precede it.
119        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        // Slide the trailing window forward to include `r`.
134        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        // One price seeds `prev`, `period` returns fill the trailing window,
154        // then the next return is the first one classified.
155        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        // Calm oscillating warmup (small, varied returns), then a +20% spike.
201        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        // A constant price has exactly-zero returns => zero trailing dispersion
236        // => no defined baseline => label 0. (Pins the `sd == 0` branch with an
237        // exact-zero series; a geometric drift is conceptually zero-vol too but
238        // floating-point rounding of the log returns leaves ~1e-16 noise.)
239        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        // A near-constant positive drift (small, equal-ish returns) must not be
248        // flagged: the deviation from the trailing mean stays well inside the
249        // band even though the raw return is non-zero every bar.
250        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}