1use std::collections::HashMap;
2
3use crate::indicator::smoothing::Rma;
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7pub 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#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct CandleStoryConfig {
46 pub pin_wick_min: f64,
48 pub pin_close_pos: f64,
50 pub marubozu_body_min: f64,
52 pub belt_hold_body_min: f64,
58 pub belt_hold_open_wick_max: f64,
60 pub doji_body_max: f64,
62 pub spinning_top_body_max: f64,
64 pub hammer_wick_body_min: f64,
70 pub hammer_opposite_max: f64,
72 pub tweezer_tolerance: f64,
74 pub min_range_atr: f64,
76 pub atr_len: usize,
78 pub trend_lookback: usize,
80 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#[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 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
180fn 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
192fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}