Skip to main content

kestrel_chartkit/indicator/
bos_choch.rs

1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use std::collections::{HashMap, VecDeque};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8/// Structure Event Type.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
11pub enum StructureEventKind {
12    BullishBos,
13    BearishBos,
14    BullishChoch,
15    BearishChoch,
16}
17
18/// Break of Structure (BOS) and Change of Character (CHoCH) Detection Engine.
19#[derive(Debug, Clone)]
20pub struct BosChochEngine {
21    pivot_len: usize,
22    bars: VecDeque<Bar>,
23    last_pivot_high: Option<f64>,
24    last_pivot_low: Option<f64>,
25    current_trend: i8, // 1 = Bullish, -1 = Bearish
26    last_event: Option<StructureEventKind>,
27}
28
29impl BosChochEngine {
30    pub fn new(pivot_len: usize) -> Self {
31        Self {
32            pivot_len: pivot_len.max(2),
33            bars: VecDeque::with_capacity(pivot_len * 2 + 1),
34            last_pivot_high: None,
35            last_pivot_low: None,
36            current_trend: 0,
37            last_event: None,
38        }
39    }
40
41    pub fn with_defaults() -> Self {
42        Self::new(5)
43    }
44
45    pub fn last_event(&self) -> Option<StructureEventKind> {
46        self.last_event
47    }
48}
49
50impl Indicator for BosChochEngine {
51    fn name(&self) -> &str {
52        "bos_choch"
53    }
54
55    fn warmup_period(&self) -> usize {
56        self.pivot_len * 2 + 1
57    }
58
59    fn reset(&mut self) {
60        self.bars.clear();
61        self.last_pivot_high = None;
62        self.last_pivot_low = None;
63        self.current_trend = 0;
64        self.last_event = None;
65    }
66
67    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
68        self.bars.push_back(bar.clone());
69        if self.bars.len() > self.pivot_len * 2 + 1 {
70            self.bars.pop_front();
71        }
72
73        if self.bars.len() < self.pivot_len * 2 + 1 {
74            return None;
75        }
76
77        let mid_idx = self.pivot_len;
78        let mid_bar = &self.bars[mid_idx];
79
80        let is_pivot_high = self
81            .bars
82            .iter()
83            .enumerate()
84            .all(|(i, b)| i == mid_idx || b.high <= mid_bar.high);
85        let is_pivot_low = self
86            .bars
87            .iter()
88            .enumerate()
89            .all(|(i, b)| i == mid_idx || b.low >= mid_bar.low);
90
91        if is_pivot_high {
92            self.last_pivot_high = Some(mid_bar.high);
93        }
94        if is_pivot_low {
95            self.last_pivot_low = Some(mid_bar.low);
96        }
97
98        self.last_event = None;
99
100        if let Some(ph) = self.last_pivot_high {
101            if bar.close > ph {
102                if self.current_trend <= 0 {
103                    self.current_trend = 1;
104                    self.last_event = Some(StructureEventKind::BullishChoch);
105                } else {
106                    self.last_event = Some(StructureEventKind::BullishBos);
107                }
108                self.last_pivot_high = None; // Reset until next pivot
109            }
110        }
111
112        if let Some(pl) = self.last_pivot_low {
113            if bar.close < pl {
114                if self.current_trend >= 0 {
115                    self.current_trend = -1;
116                    self.last_event = Some(StructureEventKind::BearishChoch);
117                } else {
118                    self.last_event = Some(StructureEventKind::BearishBos);
119                }
120                self.last_pivot_low = None; // Reset until next pivot
121            }
122        }
123
124        let event_code = match self.last_event {
125            Some(StructureEventKind::BullishBos) => 1.0,
126            Some(StructureEventKind::BullishChoch) => 2.0,
127            Some(StructureEventKind::BearishBos) => -1.0,
128            Some(StructureEventKind::BearishChoch) => -2.0,
129            None => 0.0,
130        };
131
132        let mut extra = HashMap::new();
133        extra.insert("trend".to_string(), self.current_trend as f64);
134        extra.insert("event_code".to_string(), event_code);
135
136        Some(IndicatorOutput::with_extra(event_code, extra))
137    }
138
139    fn alerts(&self) -> Vec<IndicatorAlert> {
140        let mut alerts = Vec::new();
141        if let Some(event) = self.last_event {
142            let note = match event {
143                StructureEventKind::BullishBos => "Bullish Break of Structure (BOS)",
144                StructureEventKind::BullishChoch => "Bullish Change of Character (CHoCH)",
145                StructureEventKind::BearishBos => "Bearish Break of Structure (BOS)",
146                StructureEventKind::BearishChoch => "Bearish Change of Character (CHoCH)",
147            };
148            alerts.push(IndicatorAlert::new("structure_break", note, 0.9));
149        }
150        alerts
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_bos_choch_detection() {
160        let mut engine = BosChochEngine::new(3);
161        for i in 0..30 {
162            let price = 100.0 + (i as f64 * 1.5);
163            let bar = Bar::new(i, price, price + 1.0, price - 1.0, price, 1000.0);
164            engine.on_bar(&bar);
165        }
166        assert!(engine
167            .on_bar(&Bar::new(30, 150.0, 155.0, 149.0, 154.0, 1000.0))
168            .is_some());
169    }
170}