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::indicators::rolling_moments::ShiftedMoments;
7use crate::traits::Indicator;
8
9/// Jump Indicator — a discrete `{−1, 0, +1}` flag for whether the current log
10/// return is an outlier relative to the trailing volatility of returns.
11///
12/// ```text
13/// rₜ   = ln(priceₜ / priceₜ₋₁)
14/// μ, σ = sample mean and stddev of the `period` returns *before* rₜ (trailing)
15/// flag = +1 if rₜ − μ >  threshold · σ
16///        −1 if rₜ − μ < −threshold · σ
17///         0 otherwise
18/// ```
19///
20/// The baseline is the trailing return distribution and **excludes** the current
21/// return, so a genuine jump cannot inflate the band it is tested against.
22/// Measuring the deviation from the trailing mean `μ` (not the raw return) means
23/// a steady drift is *not* flagged — only moves that are large relative to the
24/// recent return distribution count. `+1` marks an up jump, `−1` a down jump,
25/// and `0` an ordinary move. When the trailing window has zero dispersion
26/// (`σ = 0`, e.g. a perfectly constant drift) there is no defined baseline and
27/// the indicator returns `0` rather than flagging every move.
28///
29/// This is the generic, threshold-tunable detector; downstream models keep any
30/// regime-specific sensitivity by choosing `threshold`. Non-finite and
31/// non-positive prices are ignored (the log return is undefined): the tick is
32/// dropped and the last value returned.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, JumpIndicator};
38///
39/// let mut indicator = JumpIndicator::new(20, 3.0).unwrap();
40/// let mut last = None;
41/// for i in 0..40 {
42///     last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
43/// }
44/// // A calm sinusoid produces no jumps.
45/// assert_eq!(last, Some(0.0));
46/// ```
47#[derive(Debug, Clone)]
48pub struct JumpIndicator {
49    period: usize,
50    threshold: f64,
51    prev_price: Option<f64>,
52    /// Trailing window of the `period` returns preceding the current one.
53    window: VecDeque<f64>,
54    moments: ShiftedMoments,
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 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    /// Configured `(period, threshold)`.
95    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            // Still filling the trailing window; no baseline yet.
116            self.window.push_back(r);
117            self.moments.push(r);
118            return None;
119        }
120        // Trailing window is full: classify `r` against the volatility of the
121        // `period` returns that precede it.
122        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        // Slide the trailing window forward to include `r`.
135        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        // One price seeds `prev`, `period` returns fill the trailing window,
156        // then the next return is the first one classified.
157        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        // Calm oscillating warmup (small, varied returns), then a +20% spike.
205        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        // A constant price has exactly-zero returns => zero trailing dispersion
240        // => no defined baseline => label 0. (Pins the `sd == 0` branch with an
241        // exact-zero series; a geometric drift is conceptually zero-vol too but
242        // floating-point rounding of the log returns leaves ~1e-16 noise.)
243        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        // A near-constant positive drift (small, equal-ish returns) must not be
252        // flagged: the deviation from the trailing mean stays well inside the
253        // band even though the raw return is non-zero every bar.
254        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}