kestrel_chartkit/indicator/
pivots_structure.rs1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6pub struct PivotStructureEngine {
9 left_bars: usize,
10 right_bars: usize,
11 score_window: usize,
12 bars: Vec<Bar>,
13 pivot_scores: Vec<f64>,
14 last_high: Option<f64>,
15 last_low: Option<f64>,
16 prev_high: Option<f64>,
17 prev_low: Option<f64>,
18 alerts: Vec<IndicatorAlert>,
19}
20
21impl PivotStructureEngine {
22 pub fn new(left_bars: usize, right_bars: usize, score_window: usize) -> Self {
23 Self {
24 left_bars,
25 right_bars,
26 score_window,
27 bars: Vec::new(),
28 pivot_scores: Vec::new(),
29 last_high: None,
30 last_low: None,
31 prev_high: None,
32 prev_low: None,
33 alerts: Vec::new(),
34 }
35 }
36}
37
38impl Indicator for PivotStructureEngine {
39 fn name(&self) -> &str {
40 "pivots_structure"
41 }
42
43 fn warmup_period(&self) -> usize {
44 self.left_bars + self.right_bars + 1
45 }
46
47 fn reset(&mut self) {
48 self.bars.clear();
49 self.pivot_scores.clear();
50 self.last_high = None;
51 self.last_low = None;
52 self.prev_high = None;
53 self.prev_low = None;
54 self.alerts.clear();
55 }
56
57 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
58 self.bars.push(bar.clone());
59 let max_history = (self.left_bars + self.right_bars + 1) * 4;
60 if self.bars.len() > max_history {
61 self.bars.remove(0);
62 }
63
64 self.alerts.clear();
65
66 let req_len = self.left_bars + self.right_bars + 1;
67 if self.bars.len() < req_len {
68 return None;
69 }
70
71 let candidate_idx = self.bars.len() - 1 - self.right_bars;
73 let cand_high = self.bars[candidate_idx].high;
74 let cand_low = self.bars[candidate_idx].low;
75
76 let mut is_pivot_high = true;
77 let mut is_pivot_low = true;
78
79 for i in (candidate_idx - self.left_bars)..=candidate_idx + self.right_bars {
80 if i == candidate_idx {
81 continue;
82 }
83 if self.bars[i].high >= cand_high {
84 is_pivot_high = false;
85 }
86 if self.bars[i].low <= cand_low {
87 is_pivot_low = false;
88 }
89 }
90
91 let mut cur_score = 0.0;
92 let mut found_pivot = false;
93
94 if is_pivot_high {
95 self.prev_high = self.last_high;
96 self.last_high = Some(cand_high);
97 if let Some(prev) = self.prev_high {
98 cur_score += if cand_high > prev { 2.0 } else { -1.0 };
99 found_pivot = true;
100 }
101 }
102
103 if is_pivot_low {
104 self.prev_low = self.last_low;
105 self.last_low = Some(cand_low);
106 if let Some(prev) = self.prev_low {
107 cur_score += if cand_low > prev { 1.0 } else { -2.0 };
108 found_pivot = true;
109 }
110 }
111
112 if found_pivot {
113 self.pivot_scores.push(cur_score);
114 if self.pivot_scores.len() > self.score_window {
115 self.pivot_scores.remove(0);
116 }
117 }
118
119 let score = if !self.pivot_scores.is_empty() {
120 let sum: f64 = self.pivot_scores.iter().sum();
121 let max_possible = (self.score_window as f64) * 2.0;
122 let raw = (sum / max_possible) * 100.0;
123 raw.clamp(-100.0, 100.0)
124 } else {
125 0.0
126 };
127
128 if score >= 50.0 {
129 self.alerts.push(IndicatorAlert::new(
130 "structure_bullish_bias",
131 format!("Strong Bullish Market Structure Bias (+{:.0} Score)", score),
132 0.85,
133 ));
134 } else if score <= -50.0 {
135 self.alerts.push(IndicatorAlert::new(
136 "structure_bearish_bias",
137 format!("Strong Bearish Market Structure Bias ({:.0} Score)", score),
138 0.85,
139 ));
140 }
141
142 Some(IndicatorOutput::new(score))
143 }
144
145 fn alerts(&self) -> Vec<IndicatorAlert> {
146 self.alerts.clone()
147 }
148}
149
150pub fn build_pivots_structure(params: &HashMap<String, f64>) -> PivotStructureEngine {
151 let left_bars = params.get("left_bars").copied().unwrap_or(5.0) as usize;
152 let right_bars = params.get("right_bars").copied().unwrap_or(5.0) as usize;
153 let score_window = params.get("score_window").copied().unwrap_or(10.0) as usize;
154 PivotStructureEngine::new(left_bars, right_bars, score_window)
155}