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