kestrel_chartkit/indicator/
order_block.rs1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6#[derive(Debug, Clone)]
7pub struct OrderBlockZone {
8 pub is_bullish: bool,
9 pub top: f64,
10 pub bottom: f64,
11 pub created_bar: usize,
12 pub mitigated: bool,
13}
14
15pub struct OrderBlockEngine {
18 atr_len: usize,
19 min_disp_mult: f64,
20 bars: Vec<Bar>,
21 atr_vals: Vec<f64>,
22 active_obs: Vec<OrderBlockZone>,
23 bar_count: usize,
24 alerts: Vec<IndicatorAlert>,
25}
26
27impl OrderBlockEngine {
28 pub fn new(atr_len: usize, min_disp_mult: f64) -> Self {
29 Self {
30 atr_len,
31 min_disp_mult,
32 bars: Vec::new(),
33 atr_vals: Vec::new(),
34 active_obs: Vec::new(),
35 bar_count: 0,
36 alerts: Vec::new(),
37 }
38 }
39}
40
41impl Indicator for OrderBlockEngine {
42 fn name(&self) -> &str {
43 "order_block"
44 }
45
46 fn warmup_period(&self) -> usize {
47 self.atr_len + 5
48 }
49
50 fn reset(&mut self) {
51 self.bars.clear();
52 self.atr_vals.clear();
53 self.active_obs.clear();
54 self.bar_count = 0;
55 self.alerts.clear();
56 }
57
58 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
59 self.bar_count += 1;
60 self.bars.push(bar.clone());
61 if self.bars.len() > self.atr_len + 10 {
62 self.bars.remove(0);
63 }
64
65 self.alerts.clear();
66
67 if self.bars.len() < self.atr_len + 1 {
68 return None;
69 }
70
71 let mut tr_sum = 0.0f64;
73 let start = self.bars.len() - self.atr_len;
74 for i in start..self.bars.len() {
75 let tr1 = self.bars[i].high - self.bars[i].low;
76 let tr2 = (self.bars[i].high - self.bars[i - 1].close).abs();
77 let tr3 = (self.bars[i].low - self.bars[i - 1].close).abs();
78 let tr = tr1.max(tr2).max(tr3);
79 tr_sum += tr;
80 }
81 let current_atr = tr_sum / (self.atr_len as f64);
82 self.atr_vals.push(current_atr);
83
84 let curr_idx = self.bars.len() - 1;
85 let curr_bar = &self.bars[curr_idx];
86 let prev_bar = &self.bars[curr_idx - 1];
87
88 let body_size = (curr_bar.close - curr_bar.open).abs();
89 let is_displacement = body_size >= (self.min_disp_mult * current_atr);
90
91 if is_displacement && curr_bar.close > curr_bar.open && prev_bar.close < prev_bar.open {
93 let ob_zone = OrderBlockZone {
94 is_bullish: true,
95 top: prev_bar.high,
96 bottom: prev_bar.low,
97 created_bar: self.bar_count,
98 mitigated: false,
99 };
100 self.alerts.push(IndicatorAlert::new(
101 "bullish_order_block",
102 format!(
103 "Bullish Demand Order Block Zone (${:.2} - ${:.2})",
104 ob_zone.bottom, ob_zone.top
105 ),
106 0.90,
107 ));
108 self.active_obs.push(ob_zone);
109 }
110
111 if is_displacement && curr_bar.close < curr_bar.open && prev_bar.close > prev_bar.open {
113 let ob_zone = OrderBlockZone {
114 is_bullish: false,
115 top: prev_bar.high,
116 bottom: prev_bar.low,
117 created_bar: self.bar_count,
118 mitigated: false,
119 };
120 self.alerts.push(IndicatorAlert::new(
121 "bearish_order_block",
122 format!(
123 "Bearish Supply Order Block Zone (${:.2} - ${:.2})",
124 ob_zone.bottom, ob_zone.top
125 ),
126 0.90,
127 ));
128 self.active_obs.push(ob_zone);
129 }
130
131 let mut active_count = 0.0f64;
133 for ob in self.active_obs.iter_mut() {
134 if ob.mitigated {
135 continue;
136 }
137 if ob.is_bullish {
138 active_count += 1.0;
139 if curr_bar.low <= ob.top {
140 if curr_bar.close < ob.bottom {
141 ob.mitigated = true;
142 } else {
143 self.alerts.push(IndicatorAlert::new(
144 "ob_retest_bullish",
145 format!("Retest of Bullish Demand Zone (${:.2})", ob.top),
146 0.80,
147 ));
148 }
149 }
150 } else {
151 active_count -= 1.0;
152 if curr_bar.high >= ob.bottom {
153 if curr_bar.close > ob.top {
154 ob.mitigated = true;
155 } else {
156 self.alerts.push(IndicatorAlert::new(
157 "ob_retest_bearish",
158 format!("Retest of Bearish Supply Zone (${:.2})", ob.bottom),
159 0.80,
160 ));
161 }
162 }
163 }
164 }
165
166 let mut extra = HashMap::new();
167 extra.insert("active_count".to_string(), active_count);
168 if let Some(last_active) = self.active_obs.iter().rev().find(|ob| !ob.mitigated) {
169 extra.insert("active_ob_top".to_string(), last_active.top);
170 extra.insert("active_ob_bottom".to_string(), last_active.bottom);
171 let duration = self.bar_count.saturating_sub(last_active.created_bar) as f64;
172 extra.insert("active_ob_duration".to_string(), duration);
173 }
174
175 Some(IndicatorOutput::with_extra(active_count, extra))
176 }
177
178 fn alerts(&self) -> Vec<IndicatorAlert> {
179 self.alerts.clone()
180 }
181}
182
183pub fn build_order_block(params: &HashMap<String, f64>) -> OrderBlockEngine {
184 let atr_len = params.get("atr_len").copied().unwrap_or(14.0) as usize;
185 let min_disp_mult = params.get("min_disp").copied().unwrap_or(1.0);
186 OrderBlockEngine::new(atr_len, min_disp_mult)
187}