kestrel_chartkit/indicator/
market_structure_breaks.rs1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum TrendState {
8 Uptrend,
9 Downtrend,
10 Neutral,
11}
12
13pub struct MarketStructureBreaksEngine {
16 lookback: usize,
17 bars: Vec<Bar>,
18 swing_high: Option<f64>,
19 swing_low: Option<f64>,
20 trend: TrendState,
21 alerts: Vec<IndicatorAlert>,
22}
23
24impl MarketStructureBreaksEngine {
25 pub fn new(lookback: usize) -> Self {
26 Self {
27 lookback,
28 bars: Vec::new(),
29 swing_high: None,
30 swing_low: None,
31 trend: TrendState::Neutral,
32 alerts: Vec::new(),
33 }
34 }
35}
36
37impl Indicator for MarketStructureBreaksEngine {
38 fn name(&self) -> &str {
39 "market_structure_breaks"
40 }
41
42 fn warmup_period(&self) -> usize {
43 self.lookback * 2 + 1
44 }
45
46 fn reset(&mut self) {
47 self.bars.clear();
48 self.swing_high = None;
49 self.swing_low = None;
50 self.trend = TrendState::Neutral;
51 self.alerts.clear();
52 }
53
54 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
55 self.bars.push(bar.clone());
56 let max_history = self.lookback * 4 + 1;
57 if self.bars.len() > max_history {
58 self.bars.remove(0);
59 }
60
61 self.alerts.clear();
62
63 let req_len = self.lookback * 2 + 1;
64 if self.bars.len() < req_len {
65 return None;
66 }
67
68 let cand_idx = self.bars.len() - 1 - self.lookback;
69 let cand_high = self.bars[cand_idx].high;
70 let cand_low = self.bars[cand_idx].low;
71
72 let mut is_high = true;
73 let mut is_low = true;
74
75 for i in (cand_idx - self.lookback)..=cand_idx + self.lookback {
76 if i == cand_idx {
77 continue;
78 }
79 if self.bars[i].high >= cand_high {
80 is_high = false;
81 }
82 if self.bars[i].low <= cand_low {
83 is_low = false;
84 }
85 }
86
87 if is_high {
88 self.swing_high = Some(cand_high);
89 }
90 if is_low {
91 self.swing_low = Some(cand_low);
92 }
93
94 let mut signal_val = 0.0f64;
95
96 if let Some(sh) = self.swing_high {
97 if bar.close > sh {
98 if self.trend == TrendState::Uptrend {
99 self.alerts.push(IndicatorAlert::new(
100 "bullish_bos",
101 format!("Bullish Break of Structure (BOS) above ${:.2}", sh),
102 0.90,
103 ));
104 signal_val = 1.0;
105 } else if self.trend == TrendState::Downtrend || self.trend == TrendState::Neutral {
106 self.trend = TrendState::Uptrend;
107 self.alerts.push(IndicatorAlert::new(
108 "bullish_choch",
109 format!(
110 "Bullish Change of Character (ChOCH Reversal) above ${:.2}",
111 sh
112 ),
113 0.95,
114 ));
115 signal_val = 2.0;
116 }
117 self.swing_high = None;
118 }
119 }
120
121 if let Some(sl) = self.swing_low {
122 if bar.close < sl {
123 if self.trend == TrendState::Downtrend {
124 self.alerts.push(IndicatorAlert::new(
125 "bearish_bos",
126 format!("Bearish Break of Structure (BOS) below ${:.2}", sl),
127 0.90,
128 ));
129 signal_val = -1.0;
130 } else if self.trend == TrendState::Uptrend || self.trend == TrendState::Neutral {
131 self.trend = TrendState::Downtrend;
132 self.alerts.push(IndicatorAlert::new(
133 "bearish_choch",
134 format!(
135 "Bearish Change of Character (ChOCH Reversal) below ${:.2}",
136 sl
137 ),
138 0.95,
139 ));
140 signal_val = -2.0;
141 }
142 self.swing_low = None;
143 }
144 }
145
146 Some(IndicatorOutput::new(signal_val))
147 }
148
149 fn alerts(&self) -> Vec<IndicatorAlert> {
150 self.alerts.clone()
151 }
152}
153
154pub fn build_market_structure_breaks(params: &HashMap<String, f64>) -> MarketStructureBreaksEngine {
155 let lookback = params.get("lookback").copied().unwrap_or(5.0) as usize;
156 MarketStructureBreaksEngine::new(lookback)
157}