Skip to main content

wickra_core/indicators/
on_neck.rs

1//! On-Neck candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// On-Neck — a 2-bar bearish continuation. In a decline a long black candle is
7/// followed by a white candle that opens below the black bar's low yet rallies
8/// only as far as the black bar's *low* (the "neckline"). The feeble bounce shows
9/// sellers remain in control.
10///
11/// ```text
12/// long body = |close − open| >= 0.5 * (high − low)
13/// bar1 black & long
14/// bar2 white, opens below bar1's low      (open2 < low1)
15/// bar2 closes at bar1's low (the neckline) (|close2 − low1| <= 0.05 · range1)
16/// ```
17///
18/// Output is `−1.0` when the pattern completes and `0.0` otherwise. On-Neck is a
19/// single-direction (bearish-only) continuation, so it never emits `+1.0`. The
20/// first bar always returns `0.0` because the two-bar window is not yet filled.
21/// Body and neckline thresholds follow the geometric house style rather than
22/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
23/// applied; combine with a trend indicator for actionable signals.
24///
25/// # Signed ±1 encoding
26///
27/// This detector emits the uniform candlestick sign convention shared across the
28/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
29/// a machine-learning feature matrix as a single dimension.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, Indicator, OnNeck};
35///
36/// let mut indicator = OnNeck::new();
37/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap());
38/// let out = indicator
39///     .update(Candle::new(7.0, 9.1, 6.9, 9.0, 1.0, 1).unwrap());
40/// assert_eq!(out, Some(-1.0));
41/// ```
42#[derive(Debug, Clone, Default)]
43pub struct OnNeck {
44    prev: Option<Candle>,
45    has_emitted: bool,
46}
47
48impl OnNeck {
49    /// Construct a new On-Neck detector.
50    pub const fn new() -> Self {
51        Self {
52            prev: None,
53            has_emitted: false,
54        }
55    }
56}
57
58impl Indicator for OnNeck {
59    type Input = Candle;
60    type Output = f64;
61
62    fn update(&mut self, candle: Candle) -> Option<f64> {
63        self.has_emitted = true;
64        let prev = self.prev;
65        self.prev = Some(candle);
66        let Some(bar1) = prev else {
67            return Some(0.0);
68        };
69        let range1 = bar1.high - bar1.low;
70        if range1 <= 0.0 {
71            return Some(0.0);
72        }
73        if bar1.close < bar1.open
74            && (bar1.open - bar1.close) >= 0.5 * range1
75            && candle.close > candle.open
76            && candle.open < bar1.low
77            && (candle.close - bar1.low).abs() <= 0.05 * range1
78        {
79            return Some(-1.0);
80        }
81        Some(0.0)
82    }
83
84    fn reset(&mut self) {
85        self.prev = None;
86        self.has_emitted = false;
87    }
88
89    fn warmup_period(&self) -> usize {
90        2
91    }
92
93    fn is_ready(&self) -> bool {
94        self.has_emitted
95    }
96
97    fn name(&self) -> &'static str {
98        "OnNeck"
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::traits::BatchExt;
106
107    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
108        Candle::new(open, high, low, close, 1.0, ts).unwrap()
109    }
110
111    #[test]
112    fn accessors_and_metadata() {
113        let t = OnNeck::new();
114        assert_eq!(t.name(), "OnNeck");
115        assert_eq!(t.warmup_period(), 2);
116        assert!(!t.is_ready());
117    }
118
119    #[test]
120    fn on_neck_is_minus_one() {
121        let mut t = OnNeck::new();
122        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
123        assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(-1.0));
124    }
125
126    #[test]
127    fn close_into_body_yields_zero() {
128        let mut t = OnNeck::new();
129        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
130        // Closes at the prior close, not the low -> in-neck, not on-neck.
131        assert_eq!(t.update(c(7.0, 10.2, 6.9, 10.1, 1)), Some(0.0));
132    }
133
134    #[test]
135    fn second_bar_black_yields_zero() {
136        let mut t = OnNeck::new();
137        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
138        assert_eq!(t.update(c(9.5, 9.6, 6.9, 9.0, 1)), Some(0.0));
139    }
140
141    #[test]
142    fn opens_above_low_yields_zero() {
143        let mut t = OnNeck::new();
144        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
145        // Opens above bar1's low.
146        assert_eq!(t.update(c(9.5, 10.1, 9.4, 10.0, 1)), Some(0.0));
147    }
148
149    #[test]
150    fn first_bar_returns_zero() {
151        let mut t = OnNeck::new();
152        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
153    }
154
155    #[test]
156    fn batch_equals_streaming() {
157        let candles: Vec<Candle> = (0..40)
158            .map(|i| {
159                let base = 100.0 + i as f64;
160                c(base + 5.0, base + 5.1, base - 1.0, base, i)
161            })
162            .collect();
163        let mut a = OnNeck::new();
164        let mut b = OnNeck::new();
165        assert_eq!(
166            a.batch(&candles),
167            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
168        );
169    }
170
171    #[test]
172    fn reset_clears_state() {
173        let mut t = OnNeck::new();
174        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
175        t.update(c(7.0, 9.1, 6.9, 9.0, 1));
176        assert!(t.is_ready());
177        t.reset();
178        assert!(!t.is_ready());
179        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
180    }
181
182    #[test]
183    fn zero_range_first_bar_yields_zero() {
184        let mut t = OnNeck::new();
185        // Flat first bar (range1 == 0) -> rejected.
186        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
187        assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0));
188    }
189}