Skip to main content

kestrel_chartkit/indicator/
order_block.rs

1use 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
15/// Institutional Order Block Engine.
16/// Detects demand and supply order blocks formed by strong displacement expansions.
17pub 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        // Calculate ATR over last atr_len bars
72        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        // Check for Bullish OB: prev bar was bearish, current bar expands violently upward
92        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        // Check for Bearish OB: prev bar was bullish, current bar expands violently downward
112        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        // Check mitigation of existing active OBs
132        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}