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    #[inline]
63    fn update(&mut self, candle: Candle) -> Option<f64> {
64        let prev = self.prev;
65        self.prev = Some(candle);
66        let bar1 = prev?;
67        self.has_emitted = true;
68        let range1 = bar1.high - bar1.low;
69        if range1 <= 0.0 {
70            return Some(0.0);
71        }
72        if bar1.close < bar1.open
73            && (bar1.open - bar1.close) >= 0.5 * range1
74            && candle.close > candle.open
75            && candle.open < bar1.low
76            && (candle.close - bar1.low).abs() <= 0.05 * range1
77        {
78            return Some(-1.0);
79        }
80        Some(0.0)
81    }
82
83    fn reset(&mut self) {
84        self.prev = None;
85        self.has_emitted = false;
86    }
87
88    #[inline]
89    fn warmup_period(&self) -> usize {
90        2
91    }
92
93    #[inline]
94    fn is_ready(&self) -> bool {
95        self.has_emitted
96    }
97
98    #[inline]
99    fn name(&self) -> &'static str {
100        "OnNeck"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::traits::BatchExt;
108
109    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
110        Candle::new(open, high, low, close, 1.0, ts).unwrap()
111    }
112
113    #[test]
114    fn accessors_and_metadata() {
115        let t = OnNeck::new();
116        assert_eq!(t.name(), "OnNeck");
117        assert_eq!(t.warmup_period(), 2);
118        assert!(!t.is_ready());
119    }
120
121    #[test]
122    fn on_neck_is_minus_one() {
123        let mut t = OnNeck::new();
124        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
125        assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(-1.0));
126    }
127
128    #[test]
129    fn close_into_body_yields_zero() {
130        let mut t = OnNeck::new();
131        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
132        // Closes at the prior close, not the low -> in-neck, not on-neck.
133        assert_eq!(t.update(c(7.0, 10.2, 6.9, 10.1, 1)), Some(0.0));
134    }
135
136    #[test]
137    fn second_bar_black_yields_zero() {
138        let mut t = OnNeck::new();
139        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
140        assert_eq!(t.update(c(9.5, 9.6, 6.9, 9.0, 1)), Some(0.0));
141    }
142
143    #[test]
144    fn opens_above_low_yields_zero() {
145        let mut t = OnNeck::new();
146        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
147        // Opens above bar1's low.
148        assert_eq!(t.update(c(9.5, 10.1, 9.4, 10.0, 1)), Some(0.0));
149    }
150
151    #[test]
152    fn first_bar_returns_zero() {
153        let mut t = OnNeck::new();
154        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
155    }
156
157    #[test]
158    fn batch_equals_streaming() {
159        let candles: Vec<Candle> = (0..40)
160            .map(|i| {
161                let base = 100.0 + i as f64;
162                c(base + 5.0, base + 5.1, base - 1.0, base, i)
163            })
164            .collect();
165        let mut a = OnNeck::new();
166        let mut b = OnNeck::new();
167        assert_eq!(
168            a.batch(&candles),
169            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
170        );
171    }
172
173    #[test]
174    fn reset_clears_state() {
175        let mut t = OnNeck::new();
176        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
177        t.update(c(7.0, 9.1, 6.9, 9.0, 1));
178        assert!(t.is_ready());
179        t.reset();
180        assert!(!t.is_ready());
181        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
182    }
183
184    #[test]
185    fn zero_range_first_bar_yields_zero() {
186        let mut t = OnNeck::new();
187        // Flat first bar (range1 == 0) -> rejected.
188        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
189        assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0));
190    }
191}