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    fn update(&mut self, candle: Candle) -> Option<f64> {
61        self.has_emitted = true;
62        let prev = self.prev;
63        self.prev = Some(candle);
64        let Some(bar1) = prev else {
65            return Some(0.0);
66        };
67        // Both bars black, bar2's body inside bar1's body and smaller.
68        if bar1.close < bar1.open
69            && candle.close < candle.open
70            && candle.open <= bar1.open
71            && candle.close >= bar1.close
72            && (candle.open - candle.close) < (bar1.open - bar1.close)
73        {
74            return Some(1.0);
75        }
76        Some(0.0)
77    }
78
79    fn reset(&mut self) {
80        self.prev = None;
81        self.has_emitted = false;
82    }
83
84    fn warmup_period(&self) -> usize {
85        2
86    }
87
88    fn is_ready(&self) -> bool {
89        self.has_emitted
90    }
91
92    fn name(&self) -> &'static str {
93        "HomingPigeon"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::traits::BatchExt;
101
102    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
103        Candle::new(open, high, low, close, 1.0, ts).unwrap()
104    }
105
106    #[test]
107    fn accessors_and_metadata() {
108        let t = HomingPigeon::new();
109        assert_eq!(t.name(), "HomingPigeon");
110        assert_eq!(t.warmup_period(), 2);
111        assert!(!t.is_ready());
112    }
113
114    #[test]
115    fn homing_pigeon_is_plus_one() {
116        let mut t = HomingPigeon::new();
117        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
118        assert_eq!(t.update(c(14.0, 14.1, 10.9, 11.0, 1)), Some(1.0));
119    }
120
121    #[test]
122    fn second_bar_white_yields_zero() {
123        let mut t = HomingPigeon::new();
124        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
125        // bar2 white -> not a homing pigeon.
126        assert_eq!(t.update(c(11.0, 14.1, 10.9, 14.0, 1)), Some(0.0));
127    }
128
129    #[test]
130    fn second_body_not_inside_yields_zero() {
131        let mut t = HomingPigeon::new();
132        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
133        // bar2 opens above bar1's open -> body not contained.
134        assert_eq!(t.update(c(16.0, 16.1, 10.9, 11.0, 1)), Some(0.0));
135    }
136
137    #[test]
138    fn first_bar_returns_zero() {
139        let mut t = HomingPigeon::new();
140        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
141    }
142
143    #[test]
144    fn batch_equals_streaming() {
145        let candles: Vec<Candle> = (0..40)
146            .map(|i| {
147                let base = 100.0 + i as f64;
148                c(base + 5.0, base + 5.1, base - 0.1, base, i)
149            })
150            .collect();
151        let mut a = HomingPigeon::new();
152        let mut b = HomingPigeon::new();
153        assert_eq!(
154            a.batch(&candles),
155            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
156        );
157    }
158
159    #[test]
160    fn reset_clears_state() {
161        let mut t = HomingPigeon::new();
162        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
163        t.update(c(14.0, 14.1, 10.9, 11.0, 1));
164        assert!(t.is_ready());
165        t.reset();
166        assert!(!t.is_ready());
167        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
168    }
169}