Skip to main content

kestrel_chartkit/indicator/
market_structure_breaks.rs

1use 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
13/// Market Structure Break (BOS / ChOCH) Engine.
14/// Detects Break of Structure (BOS) for trend continuation and Change of Character (ChOCH) for trend reversals.
15pub 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}