kestrel_chartkit/indicator/
bos_choch.rs1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use std::collections::{HashMap, VecDeque};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[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#[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, 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; }
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; }
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}