Skip to main content

kestrel_chartkit/indicator/
divergence.rs

1use std::collections::VecDeque;
2
3#[derive(Debug, Clone)]
4pub struct SlopeDivergence {
5    div_len: usize,
6    div_min: f64,
7    fast_window: VecDeque<f64>,
8    slow_window: VecDeque<f64>,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Default)]
12pub struct DivergenceAlerts {
13    pub bull: bool,
14    pub bear: bool,
15    pub fast_dir: f64,
16}
17
18impl SlopeDivergence {
19    pub fn new(div_len: usize, div_min: f64) -> Self {
20        Self {
21            div_len,
22            div_min,
23            fast_window: VecDeque::with_capacity(div_len + 1),
24            slow_window: VecDeque::with_capacity(div_len + 1),
25        }
26    }
27
28    pub fn update(&mut self, fast: f64, slow: f64) -> DivergenceAlerts {
29        if self.fast_window.len() == self.div_len + 1 {
30            self.fast_window.pop_front();
31        }
32        self.fast_window.push_back(fast);
33        if self.slow_window.len() == self.div_len + 1 {
34            self.slow_window.pop_front();
35        }
36        self.slow_window.push_back(slow);
37
38        if self.fast_window.len() < self.div_len + 1 {
39            return DivergenceAlerts::default();
40        }
41        let fast_dir = fast - self.fast_window[0];
42        let slow_dir = slow - self.slow_window[0];
43        DivergenceAlerts {
44            bull: fast_dir < -self.div_min && slow_dir > 0.0,
45            bear: fast_dir > self.div_min && slow_dir < 0.0,
46            fast_dir,
47        }
48    }
49
50    pub fn reset(&mut self) {
51        self.fast_window.clear();
52        self.slow_window.clear();
53    }
54
55    pub fn div_min(&self) -> f64 {
56        self.div_min
57    }
58}
59
60/// Divergence Classification Kind.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub enum DivergenceKind {
64    RegularBullish,
65    RegularBearish,
66    HiddenBullish,
67    HiddenBearish,
68}
69
70/// How a pivot candidate's oscillator value is anchored.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub enum OscillatorAnchor {
74    /// The oscillator's value exactly at the candidate pivot bar.
75    AtPivot,
76    /// The oscillator's extreme (min for a low pivot, max for a high pivot) across the
77    /// confirmation window. Matches this engine's original, sole behavior.
78    #[default]
79    WindowExtreme,
80}
81
82/// Enhanced Pivot-based Divergence Detection Engine across Price and Oscillator anchors.
83///
84/// Ranks divergence candidates: each confirmed pivot is compared against up to
85/// [`PivotDivergenceEngine::with_max_prior_pivots`] prior same-type pivots (not just the
86/// immediately preceding one), each candidate is scored by [`DivergenceEvent::quality_score`],
87/// and only the single highest-scoring bullish and bearish candidate per confirmation is emitted
88/// — the conflict resolution the plain "compare against the last pivot only" version lacked.
89#[derive(Debug, Clone)]
90pub struct PivotDivergenceEngine {
91    left_bars: usize,
92    right_bars: usize,
93    min_distance: usize,
94    max_distance: usize,
95    max_prior_pivots: usize,
96    oscillator_anchor: OscillatorAnchor,
97    next_index: usize,
98    window: VecDeque<PivotSample>,
99    previous_lows: VecDeque<PivotAnchor>,
100    previous_highs: VecDeque<PivotAnchor>,
101}
102
103#[derive(Debug, Clone)]
104struct PivotSample {
105    index: usize,
106    timestamp: i64,
107    high: f64,
108    low: f64,
109    oscillator: f64,
110}
111
112#[derive(Debug, Clone, Copy)]
113struct PivotAnchor {
114    index: usize,
115    timestamp: i64,
116    price: f64,
117    oscillator: f64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct DivergenceEvent {
122    pub kind: DivergenceKind,
123    pub previous_timestamp: i64,
124    pub pivot_timestamp: i64,
125    pub confirmed_timestamp: i64,
126    pub previous_price: f64,
127    pub pivot_price: f64,
128    pub previous_oscillator: f64,
129    pub pivot_oscillator: f64,
130    pub bars_between: usize,
131    /// Heuristic `0.0..=1.0` ranking of this candidate: 60% relative price+oscillator divergence
132    /// magnitude, 40% how centrally the pivot spacing falls within
133    /// `[min_distance, max_distance]`. Used to pick the winning candidate among several prior
134    /// pivots and to let consumers threshold weak divergences; not a calibrated probability.
135    pub quality_score: f64,
136}
137
138impl PivotDivergenceEngine {
139    pub fn new(lookback: usize) -> Self {
140        Self::with_confirmation(lookback.max(1), lookback.max(1), 1, usize::MAX)
141    }
142
143    pub fn with_confirmation(
144        left_bars: usize,
145        right_bars: usize,
146        min_distance: usize,
147        max_distance: usize,
148    ) -> Self {
149        let left_bars = left_bars.max(1);
150        let right_bars = right_bars.max(1);
151        Self {
152            left_bars,
153            right_bars,
154            min_distance,
155            max_distance: max_distance.max(min_distance),
156            max_prior_pivots: 5,
157            oscillator_anchor: OscillatorAnchor::default(),
158            next_index: 0,
159            window: VecDeque::with_capacity(left_bars + right_bars + 1),
160            previous_lows: VecDeque::new(),
161            previous_highs: VecDeque::new(),
162        }
163    }
164
165    /// How many prior same-type pivots to keep and rank divergence candidates against (beyond
166    /// just the immediately preceding one). Default 5.
167    pub fn with_max_prior_pivots(mut self, max_prior_pivots: usize) -> Self {
168        self.max_prior_pivots = max_prior_pivots.max(1);
169        self
170    }
171
172    /// How a pivot candidate's oscillator value is anchored. Default
173    /// [`OscillatorAnchor::WindowExtreme`].
174    pub fn with_oscillator_anchor(mut self, anchor: OscillatorAnchor) -> Self {
175        self.oscillator_anchor = anchor;
176        self
177    }
178
179    /// Returns events only after `right_bars` have confirmed the candidate pivot.
180    pub fn update(&mut self, bar: &crate::model::Bar, oscillator: f64) -> Vec<DivergenceEvent> {
181        let capacity = self.left_bars + self.right_bars + 1;
182        if self.window.len() == capacity {
183            self.window.pop_front();
184        }
185        self.window.push_back(PivotSample {
186            index: self.next_index,
187            timestamp: bar.timestamp,
188            high: bar.high,
189            low: bar.low,
190            oscillator,
191        });
192        self.next_index += 1;
193        if self.window.len() < capacity {
194            return Vec::new();
195        }
196
197        let candidate_index = self.left_bars;
198        let candidate = &self.window[candidate_index];
199        let is_low = self
200            .window
201            .iter()
202            .enumerate()
203            .all(|(index, sample)| index == candidate_index || sample.low > candidate.low);
204        let is_high = self
205            .window
206            .iter()
207            .enumerate()
208            .all(|(index, sample)| index == candidate_index || sample.high < candidate.high);
209
210        let mut events = Vec::with_capacity(2);
211
212        if is_low {
213            let oscillator = match self.oscillator_anchor {
214                OscillatorAnchor::AtPivot => candidate.oscillator,
215                OscillatorAnchor::WindowExtreme => self
216                    .window
217                    .iter()
218                    .map(|sample| sample.oscillator)
219                    .fold(f64::INFINITY, f64::min),
220            };
221            let current = PivotAnchor {
222                index: candidate.index,
223                timestamp: candidate.timestamp,
224                price: candidate.low,
225                oscillator,
226            };
227            if let Some(event) =
228                self.best_ranked_event(&self.previous_lows, current, bar.timestamp, true)
229            {
230                events.push(event);
231            }
232            push_bounded(&mut self.previous_lows, current, self.max_prior_pivots);
233        }
234        if is_high {
235            let oscillator = match self.oscillator_anchor {
236                OscillatorAnchor::AtPivot => candidate.oscillator,
237                OscillatorAnchor::WindowExtreme => self
238                    .window
239                    .iter()
240                    .map(|sample| sample.oscillator)
241                    .fold(f64::NEG_INFINITY, f64::max),
242            };
243            let current = PivotAnchor {
244                index: candidate.index,
245                timestamp: candidate.timestamp,
246                price: candidate.high,
247                oscillator,
248            };
249            if let Some(event) =
250                self.best_ranked_event(&self.previous_highs, current, bar.timestamp, false)
251            {
252                events.push(event);
253            }
254            push_bounded(&mut self.previous_highs, current, self.max_prior_pivots);
255        }
256        events
257    }
258
259    pub fn reset(&mut self) {
260        self.next_index = 0;
261        self.window.clear();
262        self.previous_lows.clear();
263        self.previous_highs.clear();
264    }
265
266    /// Scores `current` against every stored prior pivot in `priors`, keeping only the
267    /// highest-scoring valid candidate (the conflict resolution step) instead of returning one
268    /// event per prior.
269    fn best_ranked_event(
270        &self,
271        priors: &VecDeque<PivotAnchor>,
272        current: PivotAnchor,
273        confirmed_timestamp: i64,
274        is_low: bool,
275    ) -> Option<DivergenceEvent> {
276        priors
277            .iter()
278            .filter_map(|&previous| {
279                self.candidate_event(previous, current, confirmed_timestamp, is_low)
280            })
281            .max_by(|a, b| a.quality_score.total_cmp(&b.quality_score))
282    }
283
284    fn candidate_event(
285        &self,
286        previous: PivotAnchor,
287        current: PivotAnchor,
288        confirmed_timestamp: i64,
289        is_low: bool,
290    ) -> Option<DivergenceEvent> {
291        let bars_between = current.index - previous.index;
292        if !(self.min_distance..=self.max_distance).contains(&bars_between) {
293            return None;
294        }
295        let kind =
296            if is_low && current.price < previous.price && current.oscillator > previous.oscillator
297            {
298                DivergenceKind::RegularBullish
299            } else if is_low
300                && current.price > previous.price
301                && current.oscillator < previous.oscillator
302            {
303                DivergenceKind::HiddenBullish
304            } else if !is_low
305                && current.price > previous.price
306                && current.oscillator < previous.oscillator
307            {
308                DivergenceKind::RegularBearish
309            } else if !is_low
310                && current.price < previous.price
311                && current.oscillator > previous.oscillator
312            {
313                DivergenceKind::HiddenBearish
314            } else {
315                return None;
316            };
317
318        let quality_score = divergence_quality_score(
319            &previous,
320            &current,
321            bars_between,
322            self.min_distance,
323            self.max_distance,
324        );
325
326        Some(DivergenceEvent {
327            kind,
328            previous_timestamp: previous.timestamp,
329            pivot_timestamp: current.timestamp,
330            confirmed_timestamp,
331            previous_price: previous.price,
332            pivot_price: current.price,
333            previous_oscillator: previous.oscillator,
334            pivot_oscillator: current.oscillator,
335            bars_between,
336            quality_score,
337        })
338    }
339}
340
341fn push_bounded(deque: &mut VecDeque<PivotAnchor>, value: PivotAnchor, max_len: usize) {
342    if deque.len() >= max_len {
343        deque.pop_front();
344    }
345    deque.push_back(value);
346}
347
348/// Heuristic `0.0..=1.0` divergence quality: 60% weight on relative price+oscillator divergence
349/// magnitude (bigger disagreement between the two = stronger signal), 40% weight on how close the
350/// pivot spacing is to the middle of `[min_distance, max_distance]` (too close risks noise, too
351/// far risks an unrelated coincidence).
352fn divergence_quality_score(
353    previous: &PivotAnchor,
354    current: &PivotAnchor,
355    bars_between: usize,
356    min_distance: usize,
357    max_distance: usize,
358) -> f64 {
359    let price_move = (current.price - previous.price).abs() / previous.price.abs().max(1e-9);
360    let osc_move =
361        (current.oscillator - previous.oscillator).abs() / previous.oscillator.abs().max(1e-9);
362    let magnitude = ((price_move + osc_move) / 2.0).min(1.0);
363
364    let span = max_distance.saturating_sub(min_distance).max(1) as f64;
365    let mid = min_distance as f64 + span / 2.0;
366    let distance_quality =
367        1.0 - ((bars_between as f64 - mid).abs() / (span / 2.0).max(1.0)).min(1.0);
368
369    (0.6 * magnitude + 0.4 * distance_quality).clamp(0.0, 1.0)
370}
371
372#[cfg(test)]
373mod pivot_tests {
374    use super::*;
375    use crate::model::Bar;
376
377    #[test]
378    fn emits_only_after_pivot_confirmation() {
379        let mut engine = PivotDivergenceEngine::with_confirmation(1, 1, 1, 10);
380        let points = [
381            (10.0, 50.0),
382            (8.0, 20.0),
383            (11.0, 40.0),
384            (7.0, 30.0),
385            (12.0, 45.0),
386        ];
387        let mut events = Vec::new();
388        for (index, (low, oscillator)) in points.into_iter().enumerate() {
389            let bar = Bar::new(index as i64, low + 1.0, low + 2.0, low, low + 1.0, 1.0);
390            events.extend(engine.update(&bar, oscillator));
391        }
392        assert_eq!(events.len(), 1);
393        assert_eq!(events[0].kind, DivergenceKind::RegularBullish);
394        assert_eq!(events[0].pivot_timestamp, 3);
395        assert_eq!(events[0].confirmed_timestamp, 4);
396        assert!(events[0].quality_score >= 0.0 && events[0].quality_score <= 1.0);
397    }
398
399    #[test]
400    fn tracks_multiple_prior_pivots_bounded_by_max_prior_pivots() {
401        let engine =
402            PivotDivergenceEngine::with_confirmation(1, 1, 1, 100).with_max_prior_pivots(2);
403        assert_eq!(engine.max_prior_pivots, 2);
404
405        let mut priors = VecDeque::new();
406        for i in 0..3 {
407            push_bounded(
408                &mut priors,
409                PivotAnchor {
410                    index: i,
411                    timestamp: i as i64,
412                    price: 10.0 - i as f64,
413                    oscillator: 20.0 + i as f64,
414                },
415                engine.max_prior_pivots,
416            );
417        }
418        // Bounded to 2: the oldest (index 0) must have been evicted.
419        assert_eq!(priors.len(), 2);
420        assert!(priors.iter().all(|p| p.index != 0));
421    }
422
423    #[test]
424    fn conflict_resolution_picks_the_higher_quality_candidate() {
425        let engine = PivotDivergenceEngine::with_confirmation(1, 1, 1, 100);
426
427        // Prior A: close in time and price/oscillator barely disagree -> low quality.
428        let prior_a = PivotAnchor {
429            index: 5,
430            timestamp: 5,
431            price: 100.0,
432            oscillator: 50.0,
433        };
434        // Prior B: farther back but with a much larger price/oscillator disagreement -> should
435        // score higher on magnitude despite being farther from the ideal mid-distance.
436        let prior_b = PivotAnchor {
437            index: 1,
438            timestamp: 1,
439            price: 130.0,
440            oscillator: 20.0,
441        };
442        let current = PivotAnchor {
443            index: 6,
444            timestamp: 6,
445            price: 99.0,
446            oscillator: 55.0,
447        };
448
449        let mut priors = VecDeque::new();
450        priors.push_back(prior_a);
451        priors.push_back(prior_b);
452
453        let event = engine
454            .best_ranked_event(&priors, current, 6, true)
455            .expect("at least one candidate must qualify as a regular bullish divergence");
456
457        let score_a = engine
458            .candidate_event(prior_a, current, 6, true)
459            .unwrap()
460            .quality_score;
461        let score_b = engine
462            .candidate_event(prior_b, current, 6, true)
463            .unwrap()
464            .quality_score;
465        let winner_price = if score_a >= score_b {
466            prior_a.price
467        } else {
468            prior_b.price
469        };
470
471        assert_eq!(event.previous_price, winner_price);
472        assert!((event.quality_score - score_a.max(score_b)).abs() < 1e-12);
473    }
474
475    #[test]
476    fn oscillator_anchor_at_pivot_differs_from_window_extreme() {
477        let mut at_pivot = PivotDivergenceEngine::with_confirmation(1, 1, 1, 10)
478            .with_oscillator_anchor(OscillatorAnchor::AtPivot);
479        let mut window_extreme = PivotDivergenceEngine::with_confirmation(1, 1, 1, 10)
480            .with_oscillator_anchor(OscillatorAnchor::WindowExtreme);
481
482        // Oscillator dips lower one bar after the low-price pivot bar, so the window minimum
483        // (bar 2) differs from the oscillator value exactly at the pivot bar (bar 1).
484        let points = [(10.0, 50.0), (8.0, 40.0), (9.0, 10.0)];
485        for (index, (low, oscillator)) in points.into_iter().enumerate() {
486            let bar = Bar::new(index as i64, low + 1.0, low + 2.0, low, low + 1.0, 1.0);
487            at_pivot.update(&bar, oscillator);
488            window_extreme.update(&bar, oscillator);
489        }
490
491        assert_eq!(at_pivot.previous_lows.back().unwrap().oscillator, 40.0);
492        assert_eq!(
493            window_extreme.previous_lows.back().unwrap().oscillator,
494            10.0
495        );
496    }
497}