Skip to main content

wickra_core/indicators/
homing_pigeon.rs

1//! Homing Pigeon candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Homing Pigeon — a 2-bar bullish reversal. Two black candles in a decline, the
7/// second a small body sitting entirely inside the first body (a same-colour
8/// harami). The shrinking range signals selling pressure is fading.
9///
10/// ```text
11/// bar1 black (close < open)
12/// bar2 black & its body sits inside bar1's body
13///      (open2 <= open1  &&  close2 >= close1)
14/// bar2 body is smaller than bar1's
15/// ```
16///
17/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Homing Pigeon
18/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The
19/// first bar always returns `0.0` because the two-bar window is not yet filled.
20/// Pattern-shape check only — no trend filter is applied; combine with a trend
21/// indicator for actionable signals.
22///
23/// # Signed ±1 encoding
24///
25/// This detector emits the uniform candlestick sign convention shared across the
26/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into
27/// a machine-learning feature matrix as a single dimension.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, HomingPigeon, Indicator};
33///
34/// let mut indicator = HomingPigeon::new();
35/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
36/// let out = indicator
37///     .update(Candle::new(14.0, 14.1, 10.9, 11.0, 1.0, 1).unwrap());
38/// assert_eq!(out, Some(1.0));
39/// ```
40#[derive(Debug, Clone, Default)]
41pub struct HomingPigeon {
42    prev: Option<Candle>,
43    has_emitted: bool,
44}
45
46impl HomingPigeon {
47    /// Construct a new Homing Pigeon detector.
48    pub const fn new() -> Self {
49        Self {
50            prev: None,
51            has_emitted: false,
52        }
53    }
54}
55
56impl Indicator for HomingPigeon {
57    type Input = Candle;
58    type Output = f64;
59
60    #[inline]
61    fn update(&mut self, candle: Candle) -> Option<f64> {
62        let prev = self.prev;
63        self.prev = Some(candle);
64        let bar1 = prev?;
65        self.has_emitted = true;
66        // Both bars black, bar2's body inside bar1's body and smaller.
67        if bar1.close < bar1.open
68            && candle.close < candle.open
69            && candle.open <= bar1.open
70            && candle.close >= bar1.close
71            && (candle.open - candle.close) < (bar1.open - bar1.close)
72        {
73            return Some(1.0);
74        }
75        Some(0.0)
76    }
77
78    fn reset(&mut self) {
79        self.prev = None;
80        self.has_emitted = false;
81    }
82
83    #[inline]
84    fn warmup_period(&self) -> usize {
85        2
86    }
87
88    #[inline]
89    fn is_ready(&self) -> bool {
90        self.has_emitted
91    }
92
93    #[inline]
94    fn name(&self) -> &'static str {
95        "HomingPigeon"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::traits::BatchExt;
103
104    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
105        Candle::new(open, high, low, close, 1.0, ts).unwrap()
106    }
107
108    #[test]
109    fn accessors_and_metadata() {
110        let t = HomingPigeon::new();
111        assert_eq!(t.name(), "HomingPigeon");
112        assert_eq!(t.warmup_period(), 2);
113        assert!(!t.is_ready());
114    }
115
116    #[test]
117    fn homing_pigeon_is_plus_one() {
118        let mut t = HomingPigeon::new();
119        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
120        assert_eq!(t.update(c(14.0, 14.1, 10.9, 11.0, 1)), Some(1.0));
121    }
122
123    #[test]
124    fn second_bar_white_yields_zero() {
125        let mut t = HomingPigeon::new();
126        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
127        // bar2 white -> not a homing pigeon.
128        assert_eq!(t.update(c(11.0, 14.1, 10.9, 14.0, 1)), Some(0.0));
129    }
130
131    #[test]
132    fn second_body_not_inside_yields_zero() {
133        let mut t = HomingPigeon::new();
134        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
135        // bar2 opens above bar1's open -> body not contained.
136        assert_eq!(t.update(c(16.0, 16.1, 10.9, 11.0, 1)), Some(0.0));
137    }
138
139    #[test]
140    fn first_bar_returns_zero() {
141        let mut t = HomingPigeon::new();
142        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
143    }
144
145    #[test]
146    fn batch_equals_streaming() {
147        let candles: Vec<Candle> = (0..40)
148            .map(|i| {
149                let base = 100.0 + i as f64;
150                c(base + 5.0, base + 5.1, base - 0.1, base, i)
151            })
152            .collect();
153        let mut a = HomingPigeon::new();
154        let mut b = HomingPigeon::new();
155        assert_eq!(
156            a.batch(&candles),
157            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
158        );
159    }
160
161    #[test]
162    fn reset_clears_state() {
163        let mut t = HomingPigeon::new();
164        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
165        t.update(c(14.0, 14.1, 10.9, 11.0, 1));
166        assert!(t.is_ready());
167        t.reset();
168        assert!(!t.is_ready());
169        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
170    }
171}