Skip to main content

kestrel_chartkit/indicator/
candle_story.rs

1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// Candle Story Engine (Price Action & Pattern Recognition).
7/// Identifies Pinbars/Kangaroo Tails, Engulfing Bars, Tweezers, and Marubozu candles.
8pub struct CandleStoryEngine {
9    window: Vec<Bar>,
10    alerts: Vec<IndicatorAlert>,
11}
12
13impl CandleStoryEngine {
14    pub fn new() -> Self {
15        Self {
16            window: Vec::with_capacity(5),
17            alerts: Vec::new(),
18        }
19    }
20}
21
22impl Default for CandleStoryEngine {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Indicator for CandleStoryEngine {
29    fn name(&self) -> &str {
30        "candle_story"
31    }
32
33    fn warmup_period(&self) -> usize {
34        2
35    }
36
37    fn reset(&mut self) {
38        self.window.clear();
39        self.alerts.clear();
40    }
41
42    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
43        self.window.push(bar.clone());
44        if self.window.len() > 5 {
45            self.window.remove(0);
46        }
47
48        self.alerts.clear();
49
50        let range = bar.high - bar.low;
51        if range <= 0.0 {
52            return Some(IndicatorOutput::new(0.0));
53        }
54
55        let body = (bar.close - bar.open).abs();
56        let upper_wick = bar.high - bar.close.max(bar.open);
57        let lower_wick = bar.close.min(bar.open) - bar.low;
58        let is_bullish = bar.close >= bar.open;
59
60        // Calculate Candle Pressure (-100..+100)
61        let close_pos = (bar.close - bar.low) / range; // 0.0 to 1.0
62        let pressure = (close_pos - 0.5) * 200.0;
63
64        let mut pattern_type = 0.0f64;
65
66        // 1. Kangaroo Tail / Pinbar Reversal
67        if lower_wick >= 0.55 * range && close_pos >= 0.65 {
68            pattern_type = 1.0; // Bullish Kangaroo
69            self.alerts.push(IndicatorAlert::new(
70                "bullish_kangaroo_tail",
71                format!(
72                    "Bullish Kangaroo Tail / Pinbar Reversal (Lower Wick {:.0}%)",
73                    (lower_wick / range) * 100.0
74                ),
75                0.9,
76            ));
77        } else if upper_wick >= 0.55 * range && close_pos <= 0.35 {
78            pattern_type = 2.0; // Bearish Kangaroo
79            self.alerts.push(IndicatorAlert::new(
80                "bearish_kangaroo_tail",
81                format!(
82                    "Bearish Kangaroo Tail / Pinbar Reversal (Upper Wick {:.0}%)",
83                    (upper_wick / range) * 100.0
84                ),
85                0.9,
86            ));
87        }
88
89        // 2. Engulfing Bar (requires at least 2 bars)
90        if self.window.len() >= 2 {
91            let prev = &self.window[self.window.len() - 2];
92            let prev_body = (prev.close - prev.open).abs();
93            let prev_bearish = prev.close < prev.open;
94            let prev_bullish = prev.close > prev.open;
95
96            if is_bullish && prev_bearish && body > prev_body && bar.close > prev.open {
97                pattern_type = 3.0; // Bullish Engulfing
98                self.alerts.push(IndicatorAlert::new(
99                    "bullish_engulfing",
100                    "Bullish Engulfing Pattern (Strong Momentum Shift)",
101                    0.85,
102                ));
103            } else if !is_bullish && prev_bullish && body > prev_body && bar.close < prev.open {
104                pattern_type = 4.0; // Bearish Engulfing
105                self.alerts.push(IndicatorAlert::new(
106                    "bearish_engulfing",
107                    "Bearish Engulfing Pattern (Strong Seller Dominance)",
108                    0.85,
109                ));
110            }
111
112            // 3. Tweezer Top / Bottom
113            let high_diff = (bar.high - prev.high).abs() / bar.high;
114            let low_diff = (bar.low - prev.low).abs() / bar.low;
115
116            if low_diff < 0.0015 && is_bullish && prev_bearish {
117                pattern_type = 5.0; // Bullish Tweezer
118                self.alerts.push(IndicatorAlert::new(
119                    "bullish_tweezer",
120                    "Bullish Tweezer Bottom (Double Support Reversal)",
121                    0.8,
122                ));
123            } else if high_diff < 0.0015 && !is_bullish && prev_bullish {
124                pattern_type = 6.0; // Bearish Tweezer
125                self.alerts.push(IndicatorAlert::new(
126                    "bearish_tweezer",
127                    "Bearish Tweezer Top (Double Resistance Reversal)",
128                    0.8,
129                ));
130            }
131        }
132
133        // 4. Marubozu (Extreme Institutional Dominance)
134        if body / range >= 0.82 {
135            if is_bullish {
136                pattern_type = 7.0;
137                self.alerts.push(IndicatorAlert::new(
138                    "bullish_marubozu",
139                    format!(
140                        "Bullish Marubozu ({:.0}% Body Dominance)",
141                        (body / range) * 100.0
142                    ),
143                    0.8,
144                ));
145            } else {
146                pattern_type = 8.0;
147                self.alerts.push(IndicatorAlert::new(
148                    "bearish_marubozu",
149                    format!(
150                        "Bearish Marubozu ({:.0}% Body Dominance)",
151                        (body / range) * 100.0
152                    ),
153                    0.8,
154                ));
155            }
156        }
157
158        let mut extra = HashMap::new();
159        extra.insert("pattern_type".to_string(), pattern_type);
160        extra.insert("pressure".to_string(), pressure);
161        extra.insert("body_ratio".to_string(), body / range);
162
163        Some(IndicatorOutput::with_extra(pressure, extra))
164    }
165
166    fn alerts(&self) -> Vec<IndicatorAlert> {
167        self.alerts.clone()
168    }
169}
170
171pub fn build_candle_story(_params: &HashMap<String, f64>) -> CandleStoryEngine {
172    CandleStoryEngine::new()
173}