Skip to main content

wickra_core/indicators/
long_line.rs

1//! Long Line candlestick pattern.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6use std::collections::VecDeque;
7
8/// Long Line — a single candle whose range is *longer* than the recent average and
9/// whose body dominates that range (a solid directional bar). Because "long" only
10/// has meaning relative to recent activity, the detector compares each candle's
11/// range against a rolling average of the previous `period` ranges.
12///
13/// ```text
14/// avg = mean range of the previous `period` candles
15/// long line = range > avg  AND  |close − open| >= 0.5 * range
16/// white -> +1.0,  black -> −1.0
17/// ```
18///
19/// Output is `+1.0` (long white line), `−1.0` (long black line), or `0.0`
20/// otherwise. The first `period` candles return `0.0` while the rolling average
21/// fills. `period` defaults to `5` and must be at least `1`. This rolling baseline
22/// is the one place the family departs from a purely intra-candle rule, since a
23/// short/long classification is inherently scale-relative. Pattern-shape check
24/// only — no trend filter is applied; combine with a trend indicator for
25/// actionable signals.
26///
27/// # Signed ±1 encoding
28///
29/// This detector emits the uniform candlestick sign convention shared across the
30/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it
31/// drops straight into a machine-learning feature matrix as a single dimension.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, LongLine};
37///
38/// let mut indicator = LongLine::new();
39/// // Five quiet bars fill the rolling average.
40/// for ts in 0..5 {
41///     indicator.update(Candle::new(10.0, 10.5, 9.5, 10.2, 1.0, ts).unwrap());
42/// }
43/// // A wide solid white bar is a long white line.
44/// let out = indicator
45///     .update(Candle::new(10.0, 13.0, 9.9, 12.9, 1.0, 5).unwrap());
46/// assert_eq!(out, Some(1.0));
47/// ```
48#[derive(Debug, Clone)]
49pub struct LongLine {
50    period: usize,
51    ranges: VecDeque<f64>,
52}
53
54impl Default for LongLine {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl LongLine {
61    /// Construct a Long Line detector with the default 5-candle rolling average.
62    pub const fn new() -> Self {
63        Self {
64            period: 5,
65            ranges: VecDeque::new(),
66        }
67    }
68
69    /// Construct a Long Line detector with a custom averaging period.
70    ///
71    /// `period` must be at least `1`.
72    pub fn with_period(period: usize) -> Result<Self> {
73        if period == 0 {
74            return Err(Error::PeriodZero);
75        }
76        Ok(Self {
77            period,
78            ranges: VecDeque::new(),
79        })
80    }
81
82    /// Configured averaging period.
83    pub fn period(&self) -> usize {
84        self.period
85    }
86}
87
88impl Indicator for LongLine {
89    type Input = Candle;
90    type Output = f64;
91
92    fn update(&mut self, candle: Candle) -> Option<f64> {
93        let range = candle.high - candle.low;
94        let body = candle.close - candle.open;
95        if self.ranges.len() < self.period {
96            self.ranges.push_back(range);
97            return Some(0.0);
98        }
99        let avg = self.ranges.iter().sum::<f64>() / self.period as f64;
100        self.ranges.push_back(range);
101        self.ranges.pop_front();
102        if range > avg && body.abs() >= 0.5 * range {
103            return Some(if body > 0.0 { 1.0 } else { -1.0 });
104        }
105        Some(0.0)
106    }
107
108    fn reset(&mut self) {
109        self.ranges.clear();
110    }
111
112    fn warmup_period(&self) -> usize {
113        self.period
114    }
115
116    fn is_ready(&self) -> bool {
117        self.ranges.len() >= self.period
118    }
119
120    fn name(&self) -> &'static str {
121        "LongLine"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::BatchExt;
129
130    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
131        Candle::new(open, high, low, close, 1.0, ts).unwrap()
132    }
133
134    fn warm(t: &mut LongLine) {
135        for ts in 0..5 {
136            assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, ts)), Some(0.0));
137        }
138    }
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(LongLine::with_period(0).is_err());
143    }
144
145    #[test]
146    fn accepts_valid_period() {
147        let t = LongLine::with_period(10).unwrap();
148        assert_eq!(t.period(), 10);
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let t = LongLine::new();
154        assert_eq!(t.name(), "LongLine");
155        assert_eq!(t.warmup_period(), 5);
156        assert!(!t.is_ready());
157        assert_eq!(t.period(), 5);
158    }
159
160    #[test]
161    fn long_white_line_is_plus_one() {
162        let mut t = LongLine::new();
163        warm(&mut t);
164        assert!(t.is_ready());
165        assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 5)), Some(1.0));
166    }
167
168    #[test]
169    fn long_black_line_is_minus_one() {
170        let mut t = LongLine::new();
171        warm(&mut t);
172        assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 5)), Some(-1.0));
173    }
174
175    #[test]
176    fn short_range_yields_zero() {
177        let mut t = LongLine::new();
178        warm(&mut t);
179        // Range no bigger than the average -> not a long line.
180        assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, 5)), Some(0.0));
181    }
182
183    #[test]
184    fn wide_range_small_body_yields_zero() {
185        let mut t = LongLine::new();
186        warm(&mut t);
187        // Wide range but a tiny body -> a spinning top, not a long line.
188        assert_eq!(t.update(c(10.5, 13.0, 9.9, 10.6, 5)), Some(0.0));
189    }
190
191    #[test]
192    fn warmup_returns_zero() {
193        let mut t = LongLine::new();
194        for ts in 0..5 {
195            assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, ts)), Some(0.0));
196        }
197    }
198
199    #[test]
200    fn batch_equals_streaming() {
201        let candles: Vec<Candle> = (0..40)
202            .map(|i| {
203                let base = 100.0 + i as f64;
204                if i % 7 == 0 {
205                    c(base, base + 4.0, base - 0.1, base + 3.9, i)
206                } else {
207                    c(base, base + 0.5, base - 0.5, base + 0.2, i)
208                }
209            })
210            .collect();
211        let mut a = LongLine::new();
212        let mut b = LongLine::new();
213        assert_eq!(
214            a.batch(&candles),
215            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
216        );
217    }
218
219    #[test]
220    fn reset_clears_state() {
221        let mut t = LongLine::new();
222        warm(&mut t);
223        t.update(c(10.0, 13.0, 9.9, 12.9, 5));
224        assert!(t.is_ready());
225        t.reset();
226        assert!(!t.is_ready());
227        assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 0)), Some(0.0));
228    }
229
230    #[test]
231    fn default_matches_new() {
232        assert_eq!(LongLine::default().period(), LongLine::new().period());
233    }
234}