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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub enum OscillatorAnchor {
74 AtPivot,
76 #[default]
79 WindowExtreme,
80}
81
82#[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 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 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 pub fn with_oscillator_anchor(mut self, anchor: OscillatorAnchor) -> Self {
175 self.oscillator_anchor = anchor;
176 self
177 }
178
179 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 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 ¤t,
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
348fn 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 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 let prior_a = PivotAnchor {
429 index: 5,
430 timestamp: 5,
431 price: 100.0,
432 oscillator: 50.0,
433 };
434 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 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}