Skip to main content

kestrel_chartkit/indicator/
candle_story.rs

1use std::collections::HashMap;
2
3use crate::indicator::smoothing::Rma;
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7/// Candle Story Engine — normalised single- and multi-candle classification.
8///
9/// # Why a set and not a code
10///
11/// The engine reports **every** pattern it recognises on a bar, each as its own `extra` flag.
12/// A single `pattern_type` scalar cannot express what a bar actually is: a bar can be a marubozu
13/// *and* engulf its predecessor, and with one slot the later check silently erases the earlier
14/// one. Which pattern survived then depended on the order of the `if` branches, which is not a
15/// property of the market.
16///
17/// # Why the metrics come out too
18///
19/// `body_ratio`, the two wick ratios and `range_atr` are the normalised form every candle
20/// definition is stated in. Emitting them makes the classification auditable — a caller can see
21/// *why* a bar was or was not a pinbar instead of trusting the flag — and it makes the thresholds
22/// meaningful, because a threshold on a ratio transfers between instruments and one on a price
23/// distance does not.
24///
25/// # Why the trend is an input
26///
27/// Hammer and hanging man are the same geometry. So are inverted hammer and shooting star. What
28/// separates them is the move that came before, and that is not in the candle. The engine
29/// therefore reports the geometry (`hammer_shape`, `inverted_hammer_shape`) separately from the
30/// named readings (`hammer`, `hanging_man`, …), and derives the named ones from an explicit
31/// `trend_context` measured over `trend_lookback` bars. Naming a shape without that context would
32/// assert something the bar does not contain.
33pub struct CandleStoryEngine {
34    config: CandleStoryConfig,
35    window: Vec<Bar>,
36    closes: Vec<f64>,
37    atr: Rma,
38    atr_value: Option<f64>,
39    prev_close: Option<f64>,
40    alerts: Vec<IndicatorAlert>,
41}
42
43/// Thresholds of the classification. Every one of them is a decision, not a constant of nature.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct CandleStoryConfig {
46    /// Minimum wick share of the range for a pinbar.
47    pub pin_wick_min: f64,
48    /// How far towards the opposite end the close must sit for a pinbar (0…1).
49    pub pin_close_pos: f64,
50    /// Minimum body share of the range for a marubozu.
51    pub marubozu_body_min: f64,
52    /// Minimum body share of the range for a belt hold.
53    ///
54    /// Lower than `marubozu_body_min` on purpose: a belt hold is defined by *where the bar opened*,
55    /// not by the absence of both wicks. Requiring the marubozu threshold here would make the
56    /// belt hold a subset of the marubozu and the flag redundant.
57    pub belt_hold_body_min: f64,
58    /// Maximum wick on the *opening* side, as a share of the range, for a belt hold.
59    pub belt_hold_open_wick_max: f64,
60    /// Maximum body share of the range for a doji.
61    pub doji_body_max: f64,
62    /// Maximum body share of the range for a spinning top.
63    pub spinning_top_body_max: f64,
64    /// Wick length as a multiple of the **body** for hammer and inverted hammer.
65    ///
66    /// Deliberately a different denominator than `pin_wick_min`, which measures against the
67    /// range. The two definitions circulate under the same names and select different bars; the
68    /// engine keeps them apart instead of picking one.
69    pub hammer_wick_body_min: f64,
70    /// Maximum opposite wick, as a multiple of the body, for hammer and inverted hammer.
71    pub hammer_opposite_max: f64,
72    /// Relative tolerance for two highs or lows counting as equal (tweezer).
73    pub tweezer_tolerance: f64,
74    /// Minimum range relative to ATR before a shape is reported at all.
75    pub min_range_atr: f64,
76    /// ATR length backing `range_atr`.
77    pub atr_len: usize,
78    /// Bars used to determine the prior move.
79    pub trend_lookback: usize,
80    /// How far, in ATR, the close must have moved over the lookback to count as a trend.
81    pub trend_min_atr: f64,
82}
83
84impl Default for CandleStoryConfig {
85    fn default() -> Self {
86        Self {
87            pin_wick_min: 0.55,
88            pin_close_pos: 0.65,
89            marubozu_body_min: 0.82,
90            belt_hold_body_min: 0.6,
91            belt_hold_open_wick_max: 0.03,
92            doji_body_max: 0.08,
93            spinning_top_body_max: 0.3,
94            hammer_wick_body_min: 2.0,
95            hammer_opposite_max: 0.5,
96            tweezer_tolerance: 0.0015,
97            min_range_atr: 0.5,
98            atr_len: 14,
99            trend_lookback: 10,
100            trend_min_atr: 1.0,
101        }
102    }
103}
104
105/// The normalised description of one bar — the form every candle definition is stated in.
106#[derive(Debug, Clone, Copy, PartialEq)]
107struct Metrics {
108    body: f64,
109    range: f64,
110    upper: f64,
111    lower: f64,
112    body_ratio: f64,
113    upper_ratio: f64,
114    lower_ratio: f64,
115    close_position: f64,
116    bullish: bool,
117}
118
119impl Metrics {
120    fn of(bar: &Bar) -> Option<Self> {
121        let range = bar.high - bar.low;
122        if range <= 0.0 || !range.is_finite() {
123            return None;
124        }
125        let body = (bar.close - bar.open).abs();
126        let upper = bar.high - bar.close.max(bar.open);
127        let lower = bar.close.min(bar.open) - bar.low;
128        Some(Self {
129            body,
130            range,
131            upper,
132            lower,
133            body_ratio: body / range,
134            upper_ratio: upper / range,
135            lower_ratio: lower / range,
136            close_position: (bar.close - bar.low) / range,
137            bullish: bar.close >= bar.open,
138        })
139    }
140}
141
142impl CandleStoryEngine {
143    pub fn new() -> Self {
144        Self::with_config(CandleStoryConfig::default())
145    }
146
147    pub fn with_config(config: CandleStoryConfig) -> Self {
148        Self {
149            atr: Rma::new(config.atr_len),
150            config,
151            window: Vec::with_capacity(5),
152            closes: Vec::new(),
153            atr_value: None,
154            prev_close: None,
155            alerts: Vec::new(),
156        }
157    }
158
159    /// The prior move, in ATR units: negative after a decline, positive after an advance.
160    ///
161    /// `None` until enough bars have accumulated. A named reading that depends on the trend is
162    /// withheld while it is `None` rather than defaulting to one of the two readings.
163    fn trend_context(&self) -> Option<f64> {
164        let atr = self.atr_value?;
165        if atr <= 0.0 || self.closes.len() <= self.config.trend_lookback {
166            return None;
167        }
168        let now = *self.closes.last()?;
169        let then = self.closes[self.closes.len() - 1 - self.config.trend_lookback];
170        Some((now - then) / atr)
171    }
172}
173
174impl Default for CandleStoryEngine {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180/// Whether two prices are (almost) equal, relative to their own scale.
181///
182/// Several two- and three-bar patterns are defined by two closes or two opens "matching" rather
183/// than a shape — counterattack lines, separating lines, matching low, stick sandwich,
184/// deliberation. One tolerance (`tweezer_tolerance`) already exists for exactly this question on
185/// highs and lows; this reuses it instead of adding a second, differently named threshold for the
186/// same idea applied to closes and opens.
187fn nahezu_gleich(a: f64, b: f64, tolerance: f64) -> bool {
188    let scale = a.abs().max(b.abs());
189    scale > 0.0 && (a - b).abs() / scale < tolerance
190}
191
192/// Records a pattern: sets its flag and emits the matching alert.
193fn mark(
194    found: &mut HashMap<String, f64>,
195    alerts: &mut Vec<IndicatorAlert>,
196    kind: &str,
197    message: impl Into<String>,
198    strength: f64,
199) {
200    found.insert(kind.to_string(), 1.0);
201    alerts.push(IndicatorAlert::new(kind, message, strength));
202}
203
204impl Indicator for CandleStoryEngine {
205    fn name(&self) -> &str {
206        "candle_story"
207    }
208
209    fn warmup_period(&self) -> usize {
210        2
211    }
212
213    fn reset(&mut self) {
214        self.window.clear();
215        self.closes.clear();
216        self.atr = Rma::new(self.config.atr_len);
217        self.atr_value = None;
218        self.prev_close = None;
219        self.alerts.clear();
220    }
221
222    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
223        self.window.push(bar.clone());
224        if self.window.len() > 5 {
225            self.window.remove(0);
226        }
227        self.closes.push(bar.close);
228        if self.closes.len() > self.config.trend_lookback + 2 {
229            self.closes.remove(0);
230        }
231
232        let true_range = match self.prev_close {
233            Some(prev) => (bar.high - bar.low)
234                .max((bar.high - prev).abs())
235                .max((bar.low - prev).abs()),
236            None => bar.high - bar.low,
237        };
238        self.prev_close = Some(bar.close);
239        self.atr_value = self.atr.update(true_range);
240
241        self.alerts.clear();
242
243        let Some(m) = Metrics::of(bar) else {
244            return Some(IndicatorOutput::new(0.0));
245        };
246
247        let pressure = (m.close_position - 0.5) * 200.0;
248        let range_atr = self.atr_value.filter(|a| *a > 0.0).map(|a| m.range / a);
249        let cfg = self.config;
250
251        let mut found: HashMap<String, f64> = HashMap::new();
252        let mut alerts = Vec::new();
253
254        // A bar below the size floor is classified as nothing: without it every micro-bar with an
255        // accidental wick distribution becomes a hammer, and there are a great many of those.
256        let big_enough = range_atr.is_none_or(|r| r >= cfg.min_range_atr);
257
258        if big_enough {
259            self.classify_single(&m, &mut found, &mut alerts);
260            let trend = self.trend_context();
261            self.name_by_trend(&m, trend, &mut found, &mut alerts);
262        }
263        if self.window.len() >= 2 {
264            let prev = self.window[self.window.len() - 2].clone();
265            self.classify_pair(&m, &prev, bar, &mut found, &mut alerts);
266        }
267        if self.window.len() >= 3 {
268            self.classify_triple(&mut found, &mut alerts);
269        }
270        if self.window.len() >= 4 {
271            self.classify_four(&mut found, &mut alerts);
272        }
273        if self.window.len() >= 5 {
274            self.classify_five(&mut found, &mut alerts);
275        }
276
277        self.alerts = alerts;
278
279        let mut extra: HashMap<String, f64> = found;
280        let count = extra.len() as f64;
281        extra.insert("pattern_count".to_string(), count);
282        extra.insert("pressure".to_string(), pressure);
283        extra.insert("body_ratio".to_string(), m.body_ratio);
284        extra.insert("upper_wick_ratio".to_string(), m.upper_ratio);
285        extra.insert("lower_wick_ratio".to_string(), m.lower_ratio);
286        extra.insert("close_position".to_string(), m.close_position);
287        if let Some(r) = range_atr {
288            extra.insert("range_atr".to_string(), r);
289        }
290        if let Some(t) = self.trend_context() {
291            extra.insert("trend_context".to_string(), t);
292        }
293
294        Some(IndicatorOutput::with_extra(pressure, extra))
295    }
296
297    fn alerts(&self) -> Vec<IndicatorAlert> {
298        self.alerts.clone()
299    }
300}
301
302impl CandleStoryEngine {
303    /// Shapes that need one bar only.
304    fn classify_single(
305        &self,
306        m: &Metrics,
307        found: &mut HashMap<String, f64>,
308        alerts: &mut Vec<IndicatorAlert>,
309    ) {
310        let cfg = self.config;
311
312        if m.body_ratio <= cfg.doji_body_max {
313            mark(
314                found,
315                alerts,
316                "doji",
317                "Doji — open and close nearly equal",
318                0.6,
319            );
320            if m.lower_ratio >= cfg.pin_wick_min {
321                mark(found, alerts, "dragonfly_doji", "Dragonfly doji", 0.7);
322            }
323            if m.upper_ratio >= cfg.pin_wick_min {
324                mark(found, alerts, "gravestone_doji", "Gravestone doji", 0.7);
325            }
326            if m.lower_ratio >= 0.3 && m.upper_ratio >= 0.3 {
327                mark(found, alerts, "long_legged_doji", "Long-legged doji", 0.6);
328            }
329        } else if m.body_ratio <= cfg.spinning_top_body_max {
330            mark(
331                found,
332                alerts,
333                "spinning_top",
334                "Spinning top — much movement, little net result",
335                0.5,
336            );
337        }
338
339        // Pinbar: wick against the *range*.
340        if m.lower_ratio >= cfg.pin_wick_min && m.close_position >= cfg.pin_close_pos {
341            mark(
342                found,
343                alerts,
344                "bullish_pinbar",
345                format!(
346                    "Bullish pinbar (lower wick {:.0}% of range)",
347                    m.lower_ratio * 100.0
348                ),
349                0.9,
350            );
351        }
352        if m.upper_ratio >= cfg.pin_wick_min && m.close_position <= 1.0 - cfg.pin_close_pos {
353            mark(
354                found,
355                alerts,
356                "bearish_pinbar",
357                format!(
358                    "Bearish pinbar (upper wick {:.0}% of range)",
359                    m.upper_ratio * 100.0
360                ),
361                0.9,
362            );
363        }
364
365        // Hammer geometry: wick against the *body*. A different denominator, a different set.
366        if m.body > 0.0 {
367            if m.lower >= cfg.hammer_wick_body_min * m.body
368                && m.upper <= cfg.hammer_opposite_max * m.body
369            {
370                mark(
371                    found,
372                    alerts,
373                    "hammer_shape",
374                    "Long lower wick, small body at the top",
375                    0.6,
376                );
377            }
378            if m.upper >= cfg.hammer_wick_body_min * m.body
379                && m.lower <= cfg.hammer_opposite_max * m.body
380            {
381                mark(
382                    found,
383                    alerts,
384                    "inverted_hammer_shape",
385                    "Long upper wick, small body at the bottom",
386                    0.6,
387                );
388            }
389        }
390
391        if m.body_ratio >= cfg.marubozu_body_min {
392            let kind = if m.bullish {
393                "bullish_marubozu"
394            } else {
395                "bearish_marubozu"
396            };
397            mark(
398                found,
399                alerts,
400                kind,
401                format!("Marubozu ({:.0}% body dominance)", m.body_ratio * 100.0),
402                0.8,
403            );
404        }
405
406        // Belt hold geometry: the bar opens at its own extreme and runs from there. Only the
407        // *opening* side has to be free of a wick — the closing side may leave one, which is what
408        // separates "opened at the low and never looked back" from a marubozu, where the period
409        // was decided at both ends.
410        if m.body_ratio >= cfg.belt_hold_body_min {
411            if m.bullish && m.lower_ratio <= cfg.belt_hold_open_wick_max {
412                mark(
413                    found,
414                    alerts,
415                    "bullish_belt_hold_shape",
416                    "Opened at the low, no lower wick",
417                    0.55,
418                );
419            }
420            if !m.bullish && m.upper_ratio <= cfg.belt_hold_open_wick_max {
421                mark(
422                    found,
423                    alerts,
424                    "bearish_belt_hold_shape",
425                    "Opened at the high, no upper wick",
426                    0.55,
427                );
428            }
429        }
430    }
431
432    /// The readings that only exist together with a prior move.
433    ///
434    /// Hammer and hanging man are the same shape. Reporting one of them without the trend would
435    /// be a claim the bar does not support, so both are withheld while the context is unknown.
436    fn name_by_trend(
437        &self,
438        _m: &Metrics,
439        trend: Option<f64>,
440        found: &mut HashMap<String, f64>,
441        alerts: &mut Vec<IndicatorAlert>,
442    ) {
443        let Some(trend) = trend else { return };
444        let min = self.config.trend_min_atr;
445        let downtrend = trend <= -min;
446        let uptrend = trend >= min;
447
448        if found.contains_key("hammer_shape") {
449            if downtrend {
450                mark(
451                    found,
452                    alerts,
453                    "hammer",
454                    "Hammer — same shape, after a decline",
455                    0.75,
456                );
457            } else if uptrend {
458                mark(
459                    found,
460                    alerts,
461                    "hanging_man",
462                    "Hanging man — same shape, after an advance",
463                    0.75,
464                );
465            }
466        }
467        // A belt hold is the shape *plus* the move it opposes. A bullish bar that opens at its low
468        // in an advance is a continuation bar, not a belt hold — the classical reading names the
469        // opening against a prevailing move, so the shape alone stays unnamed.
470        if found.contains_key("bullish_belt_hold_shape") && downtrend {
471            mark(
472                found,
473                alerts,
474                "bullish_belt_hold",
475                "Bullish belt hold — opened at the low, after a decline",
476                0.7,
477            );
478        }
479        if found.contains_key("bearish_belt_hold_shape") && uptrend {
480            mark(
481                found,
482                alerts,
483                "bearish_belt_hold",
484                "Bearish belt hold — opened at the high, after an advance",
485                0.7,
486            );
487        }
488        if found.contains_key("inverted_hammer_shape") {
489            if downtrend {
490                mark(
491                    found,
492                    alerts,
493                    "inverted_hammer",
494                    "Inverted hammer — after a decline",
495                    0.7,
496                );
497            } else if uptrend {
498                mark(
499                    found,
500                    alerts,
501                    "shooting_star",
502                    "Shooting star — after an advance",
503                    0.75,
504                );
505            }
506        }
507    }
508
509    /// Two-bar patterns.
510    fn classify_pair(
511        &self,
512        m: &Metrics,
513        prev: &Bar,
514        bar: &Bar,
515        found: &mut HashMap<String, f64>,
516        alerts: &mut Vec<IndicatorAlert>,
517    ) {
518        let cfg = self.config;
519        let prev_body = (prev.close - prev.open).abs();
520        let prev_bearish = prev.close < prev.open;
521        let prev_bullish = prev.close > prev.open;
522
523        // Body against body — the definition that circulates most widely. The range variant
524        // additionally requires the outer bar to exceed both extremes and selects far fewer bars;
525        // it is reported separately rather than folded in.
526        let engulfs = m.body > prev_body;
527        if m.bullish && prev_bearish && engulfs && bar.close > prev.open && bar.open <= prev.close {
528            mark(
529                found,
530                alerts,
531                "bullish_engulfing",
532                "Bullish engulfing (body over body)",
533                0.85,
534            );
535            if bar.high >= prev.high && bar.low <= prev.low {
536                mark(
537                    found,
538                    alerts,
539                    "bullish_engulfing_range",
540                    "Bullish engulfing (range over range)",
541                    0.9,
542                );
543            }
544        } else if !m.bullish
545            && prev_bullish
546            && engulfs
547            && bar.close < prev.open
548            && bar.open >= prev.close
549        {
550            mark(
551                found,
552                alerts,
553                "bearish_engulfing",
554                "Bearish engulfing (body over body)",
555                0.85,
556            );
557            if bar.high >= prev.high && bar.low <= prev.low {
558                mark(
559                    found,
560                    alerts,
561                    "bearish_engulfing_range",
562                    "Bearish engulfing (range over range)",
563                    0.9,
564                );
565            }
566        }
567
568        // Harami — the inverse containment: this body sits inside the previous one.
569        let inside = bar.open.max(bar.close) <= prev.open.max(prev.close)
570            && bar.open.min(bar.close) >= prev.open.min(prev.close);
571        if inside && m.body < prev_body {
572            if prev_bearish {
573                mark(
574                    found,
575                    alerts,
576                    "bullish_harami",
577                    "Bullish harami — inside the previous body",
578                    0.7,
579                );
580            } else if prev_bullish {
581                mark(
582                    found,
583                    alerts,
584                    "bearish_harami",
585                    "Bearish harami — inside the previous body",
586                    0.7,
587                );
588            }
589            if found.contains_key("doji") {
590                mark(
591                    found,
592                    alerts,
593                    "harami_cross",
594                    "Harami cross — the inside bar is a doji",
595                    0.75,
596                );
597            }
598            // Homing pigeon: the same containment as a harami, but both candles share a colour
599            // instead of the inner one being read as a pause. Reported alongside `bearish_harami`,
600            // not instead of it — a bar can be both at once.
601            if prev_bearish && !m.bullish {
602                mark(
603                    found,
604                    alerts,
605                    "homing_pigeon",
606                    "Homing pigeon — a harami where both candles are bearish",
607                    0.7,
608                );
609            }
610        }
611
612        // Kicking: two marubozu of opposite colour, with the second gapping past the first's own
613        // extreme — not just past its body, which every marubozu already does by definition.
614        if let Some(pm) = Metrics::of(prev) {
615            if pm.body_ratio >= cfg.marubozu_body_min && m.body_ratio >= cfg.marubozu_body_min {
616                let kicking = (prev_bullish && !m.bullish && bar.high < prev.low)
617                    || (prev_bearish && m.bullish && bar.low > prev.high);
618                if kicking {
619                    mark(
620                        found,
621                        alerts,
622                        "kicking",
623                        "Kicking — two marubozu, a gap between them",
624                        0.85,
625                    );
626                }
627            }
628        }
629
630        // Counterattack lines / separating lines: opposite colours, one price matching almost
631        // exactly — the close for counterattack, the open for separating lines. Reusing the
632        // tweezer tolerance rather than a new threshold for the same "almost equal" question.
633        if prev_bullish != m.bullish {
634            if nahezu_gleich(bar.close, prev.close, cfg.tweezer_tolerance) {
635                mark(
636                    found,
637                    alerts,
638                    "counterattack_lines",
639                    "Counterattack lines — matching closes, opposite colour",
640                    0.7,
641                );
642            }
643            if nahezu_gleich(bar.open, prev.open, cfg.tweezer_tolerance) {
644                mark(
645                    found,
646                    alerts,
647                    "separating_lines",
648                    "Separating lines — matching opens, opposite colour",
649                    0.7,
650                );
651            }
652        }
653
654        // Matching low: two bearish candles that close at (almost) the same price.
655        if prev_bearish && !m.bullish && nahezu_gleich(bar.close, prev.close, cfg.tweezer_tolerance)
656        {
657            mark(
658                found,
659                alerts,
660                "matching_low",
661                "Matching low — two bearish candles, the same close",
662                0.7,
663            );
664        }
665
666        if prev.low.abs() > 0.0 && prev.high.abs() > 0.0 {
667            let high_diff = (bar.high - prev.high).abs() / prev.high.abs();
668            let low_diff = (bar.low - prev.low).abs() / prev.low.abs();
669            if low_diff < cfg.tweezer_tolerance && m.bullish && prev_bearish {
670                mark(
671                    found,
672                    alerts,
673                    "bullish_tweezer",
674                    "Tweezer bottom — two equal lows",
675                    0.8,
676                );
677            } else if high_diff < cfg.tweezer_tolerance && !m.bullish && prev_bullish {
678                mark(
679                    found,
680                    alerts,
681                    "bearish_tweezer",
682                    "Tweezer top — two equal highs",
683                    0.8,
684                );
685            }
686        }
687    }
688
689    /// Three-bar patterns: morning/evening star, advance block, abandoned baby.
690    fn classify_triple(&self, found: &mut HashMap<String, f64>, alerts: &mut Vec<IndicatorAlert>) {
691        let n = self.window.len();
692        let (first, middle, last) = (
693            &self.window[n - 3],
694            &self.window[n - 2],
695            &self.window[n - 1],
696        );
697        let (Some(f), Some(mid), Some(l)) =
698            (Metrics::of(first), Metrics::of(middle), Metrics::of(last))
699        else {
700            return;
701        };
702
703        // Morning/evening star: the middle bar has to be small; that is what makes it a pause
704        // rather than a continuation.
705        if mid.body_ratio <= self.config.spinning_top_body_max {
706            let first_mid = (first.open + first.close) / 2.0;
707
708            if !f.bullish && l.bullish && last.close > first_mid && middle.close < first.close {
709                mark(
710                    found,
711                    alerts,
712                    "morning_star",
713                    "Morning star — decline, pause, recovery past the midpoint",
714                    0.8,
715                );
716            }
717            if f.bullish && !l.bullish && last.close < first_mid && middle.close > first.close {
718                mark(
719                    found,
720                    alerts,
721                    "evening_star",
722                    "Evening star — advance, pause, decline past the midpoint",
723                    0.8,
724                );
725            }
726        }
727
728        // Advance block: three bullish candles still climbing, but each with a smaller body and
729        // a longer upper wick than the one before — an uptrend running out of conviction rather
730        // than reversing outright.
731        if f.bullish
732            && mid.bullish
733            && l.bullish
734            && mid.body < f.body
735            && l.body < mid.body
736            && mid.upper > f.upper
737            && l.upper > mid.upper
738        {
739            mark(
740                found,
741                alerts,
742                "advance_block",
743                "Advance block — shrinking bodies, growing upper wicks",
744                0.6,
745            );
746        }
747
748        // Abandoned baby: a candle in trend direction, a gap, a doji isolated by gaps on both
749        // sides, and a candle against the trend on the far side of the second gap. Checked
750        // against the bars' actual ranges (not the doji's ratios alone) because the isolation —
751        // not the doji shape — is what makes this an abandoned baby rather than a star.
752        if mid.body_ratio <= self.config.doji_body_max {
753            let island_above = middle.low > first.high && middle.low > last.high;
754            let island_below = middle.high < first.low && middle.high < last.low;
755            if (island_above && f.bullish && !l.bullish)
756                || (island_below && !f.bullish && l.bullish)
757            {
758                mark(
759                    found,
760                    alerts,
761                    "abandoned_baby",
762                    "Abandoned baby — a doji isolated by gaps on both sides",
763                    0.85,
764                );
765            }
766        }
767
768        // Tri-star: the same isolation as an abandoned baby, but all three bars are dojis rather
769        // than trend candles either side — there is no trend reading to derive from the shape.
770        if f.body_ratio <= self.config.doji_body_max
771            && mid.body_ratio <= self.config.doji_body_max
772            && l.body_ratio <= self.config.doji_body_max
773            && ((middle.low > first.high && middle.low > last.high)
774                || (middle.high < first.low && middle.high < last.low))
775        {
776            mark(
777                found,
778                alerts,
779                "tri_star",
780                "Tri-star — three dojis, the middle one isolated by gaps",
781                0.6,
782            );
783        }
784
785        // Tasuki gap: two candles in trend direction with a gap between them, then a counter
786        // candle that opens inside the second and closes back into the gap without filling it.
787        if f.bullish && mid.bullish && middle.low > first.high {
788            if !l.bullish && last.close > first.high && last.close < middle.low {
789                mark(
790                    found,
791                    alerts,
792                    "tasuki_gap",
793                    "Tasuki gap — an upside gap, the counter candle stays inside it",
794                    0.6,
795                );
796            }
797        } else if !f.bullish
798            && !mid.bullish
799            && middle.high < first.low
800            && l.bullish
801            && last.close < first.low
802            && last.close > middle.high
803        {
804            mark(
805                found,
806                alerts,
807                "tasuki_gap",
808                "Tasuki gap — a downside gap, the counter candle stays inside it",
809                0.6,
810            );
811        }
812
813        // Upside gap two crows: a bullish candle, a gap up, then a bearish candle whose body a
814        // second bearish candle encloses — without the close falling back through the gap.
815        if f.bullish
816            && !mid.bullish
817            && !l.bullish
818            && middle.low > first.high
819            && last.open > middle.open
820            && last.close < middle.close
821            && last.close > first.close
822        {
823            mark(
824                found,
825                alerts,
826                "upside_gap_two_crows",
827                "Upside gap two crows — the gap survives both bearish candles",
828                0.7,
829            );
830        }
831
832        // Three stars in the south: three bearish candles, each with a smaller range and a
833        // higher low than the one before — a decline running out of room, told through the lows
834        // rather than the bodies (that is Advance Block's mirror).
835        if !f.bullish
836            && !mid.bullish
837            && !l.bullish
838            && mid.range < f.range
839            && l.range < mid.range
840            && middle.low > first.low
841            && last.low > middle.low
842        {
843            mark(
844                found,
845                alerts,
846                "three_stars_in_the_south",
847                "Three stars in the south — shrinking ranges, rising lows",
848                0.6,
849            );
850        }
851
852        // Deliberation: two large bullish candles, then a small third that opens right at the
853        // second's close — a stall, not a reversal on its own.
854        if f.bullish
855            && mid.bullish
856            && l.bullish
857            && f.body_ratio > self.config.spinning_top_body_max
858            && mid.body_ratio > self.config.spinning_top_body_max
859            && l.body_ratio <= self.config.spinning_top_body_max
860            && nahezu_gleich(last.open, middle.close, self.config.tweezer_tolerance)
861        {
862            mark(
863                found,
864                alerts,
865                "deliberation",
866                "Deliberation — a small third candle opening at the second's close",
867                0.6,
868            );
869        }
870
871        // Stick sandwich: bearish, bullish, bearish — the two outer candles closing at
872        // (almost) the same price.
873        if !f.bullish
874            && mid.bullish
875            && !l.bullish
876            && nahezu_gleich(last.close, first.close, self.config.tweezer_tolerance)
877        {
878            mark(
879                found,
880                alerts,
881                "stick_sandwich",
882                "Stick sandwich — matching closes either side of one counter candle",
883                0.7,
884            );
885        }
886
887        // Unique three river bottom: a large bearish candle, then a bearish candle with a long
888        // lower wick that makes a new low, then a small bullish candle that holds above it.
889        if !f.bullish
890            && !mid.bullish
891            && l.bullish
892            && middle.low < first.low
893            && mid.lower_ratio >= self.config.pin_wick_min
894            && l.body_ratio <= self.config.spinning_top_body_max
895            && last.low > middle.low
896        {
897            mark(
898                found,
899                alerts,
900                "unique_three_river_bottom",
901                "Unique three river bottom — a long lower wick at a new low, then a small hold",
902                0.65,
903            );
904        }
905    }
906
907    /// Four-bar patterns: three-line strike, concealing baby swallow.
908    fn classify_four(&self, found: &mut HashMap<String, f64>, alerts: &mut Vec<IndicatorAlert>) {
909        let n = self.window.len();
910        let (b0, b1, b2, b3) = (
911            &self.window[n - 4],
912            &self.window[n - 3],
913            &self.window[n - 2],
914            &self.window[n - 1],
915        );
916        let (Some(m0), Some(m1), Some(m2), Some(m3)) = (
917            Metrics::of(b0),
918            Metrics::of(b1),
919            Metrics::of(b2),
920            Metrics::of(b3),
921        ) else {
922            return;
923        };
924        let cfg = self.config;
925
926        // Three-line strike: three same-direction candles each making further progress, then a
927        // single counter candle whose range covers all three — an engulfing over three bars
928        // instead of one.
929        let three_line_strike = if m0.bullish && m1.bullish && m2.bullish {
930            b1.close > b0.close
931                && b2.close > b1.close
932                && !m3.bullish
933                && b3.high >= b0.high.max(b1.high).max(b2.high)
934                && b3.low <= b0.low.min(b1.low).min(b2.low)
935        } else if !m0.bullish && !m1.bullish && !m2.bullish {
936            b1.close < b0.close
937                && b2.close < b1.close
938                && m3.bullish
939                && b3.high >= b0.high.max(b1.high).max(b2.high)
940                && b3.low <= b0.low.min(b1.low).min(b2.low)
941        } else {
942            false
943        };
944        if three_line_strike {
945            mark(
946                found,
947                alerts,
948                "three_line_strike",
949                "Three-line strike — a single candle engulfing three",
950                0.75,
951            );
952        }
953
954        // Concealing baby swallow: two bearish marubozu, then a bearish candle whose upper wick
955        // pokes into the second's body, then a candle that fully encloses the third's range.
956        if !m0.bullish
957            && !m1.bullish
958            && !m2.bullish
959            && !m3.bullish
960            && m0.body_ratio >= cfg.marubozu_body_min
961            && m1.body_ratio >= cfg.marubozu_body_min
962            && b2.high > b1.close
963            && b2.high < b1.open
964            && b3.high >= b2.high
965            && b3.low <= b2.low
966        {
967            mark(
968                found,
969                alerts,
970                "concealing_baby_swallow",
971                "Concealing baby swallow — an upper wick into the previous body, then fully enclosed",
972                0.6,
973            );
974        }
975    }
976
977    /// Five-bar patterns: breakaway.
978    ///
979    /// A gap in trend direction, three small continuation candles, then a large counter candle
980    /// that closes back into the gap. `window` already caps at five bars — the same buffer the
981    /// pinbar/engulfing checks use — so no extra state is needed to reach back this far.
982    fn classify_five(&self, found: &mut HashMap<String, f64>, alerts: &mut Vec<IndicatorAlert>) {
983        let n = self.window.len();
984        let (b0, b1, b2, b3, b4) = (
985            &self.window[n - 5],
986            &self.window[n - 4],
987            &self.window[n - 3],
988            &self.window[n - 2],
989            &self.window[n - 1],
990        );
991        let (Some(m0), Some(m1), Some(m2), Some(m3), Some(m4)) = (
992            Metrics::of(b0),
993            Metrics::of(b1),
994            Metrics::of(b2),
995            Metrics::of(b3),
996            Metrics::of(b4),
997        ) else {
998            return;
999        };
1000
1001        let inner_small = m1.body < m0.body && m2.body < m0.body && m3.body < m0.body;
1002        let counter_large = m4.body > m1.body && m4.body > m2.body && m4.body > m3.body;
1003
1004        if inner_small && counter_large {
1005            let breakaway = if m0.bullish {
1006                // Gap up in trend direction, three small bullish continuation candles, then a
1007                // large bearish candle closing back between b0's high and b1's low — the gap it
1008                // left open.
1009                let gap = b1.low > b0.high;
1010                let continuation = m1.bullish && m2.bullish && m3.bullish;
1011                let closes_into_gap = !m4.bullish && b4.close > b0.high && b4.close < b1.low;
1012                gap && continuation && closes_into_gap
1013            } else {
1014                let gap = b1.high < b0.low;
1015                let continuation = !m1.bullish && !m2.bullish && !m3.bullish;
1016                let closes_into_gap = m4.bullish && b4.close < b0.low && b4.close > b1.high;
1017                gap && continuation && closes_into_gap
1018            };
1019            if breakaway {
1020                mark(
1021                    found,
1022                    alerts,
1023                    "breakaway",
1024                    "Breakaway — gap, three small continuation candles, a large counter candle closing into the gap",
1025                    0.7,
1026                );
1027            }
1028        }
1029
1030        // Rising/falling three methods and mat hold share a shape — a large candle, three small
1031        // ones sitting inside its range, then a candle resuming the original direction — and
1032        // differ only in how far the last candle has to travel. Rising/falling need it to close
1033        // past the first candle's own extreme; mat hold only needs it large and same-direction,
1034        // which is why a completed rising/falling three methods also reads as a mat hold.
1035        let contained = [b1, b2, b3]
1036            .iter()
1037            .all(|b| b.high <= b0.high && b.low >= b0.low);
1038
1039        if contained && inner_small {
1040            if m0.bullish && m4.bullish && b4.close > b0.high {
1041                mark(
1042                    found,
1043                    alerts,
1044                    "rising_three_methods",
1045                    "Rising three methods — three small candles inside a large one, then a close beyond its high",
1046                    0.7,
1047                );
1048            }
1049            if !m0.bullish && !m4.bullish && b4.close < b0.low {
1050                mark(
1051                    found,
1052                    alerts,
1053                    "falling_three_methods",
1054                    "Falling three methods — three small candles inside a large one, then a close beyond its low",
1055                    0.7,
1056                );
1057            }
1058            if counter_large && m0.bullish == m4.bullish {
1059                mark(
1060                    found,
1061                    alerts,
1062                    "mat_hold",
1063                    "Mat hold — three small candles inside a large one, then a large candle resuming its direction",
1064                    0.6,
1065                );
1066            }
1067        }
1068
1069        // Ladder bottom: three bearish candles with progressively lower closes, then a bearish
1070        // candle with a long upper wick, then a bullish candle opening above it — the long wick is
1071        // what separates this from a plain four-bar decline.
1072        if !m0.bullish
1073            && !m1.bullish
1074            && !m2.bullish
1075            && !m3.bullish
1076            && b1.close < b0.close
1077            && b2.close < b1.close
1078            && m3.upper_ratio >= self.config.pin_wick_min
1079            && m4.bullish
1080            && b4.open > b3.high
1081        {
1082            mark(
1083                found,
1084                alerts,
1085                "ladder_bottom",
1086                "Ladder bottom — falling closes, a long upper wick, then a bullish gap open",
1087                0.65,
1088            );
1089        }
1090    }
1091}
1092
1093pub fn build_candle_story(_params: &HashMap<String, f64>) -> CandleStoryEngine {
1094    CandleStoryEngine::new()
1095}