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    /// Whether a value has been emitted since the last reset. The trait
53    /// defines `is_ready` as exactly that, and the state this used to key
54    /// off changed at a different moment.
55    has_emitted: bool,
56}
57
58impl Default for LongLine {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl LongLine {
65    /// Construct a Long Line detector with the default 5-candle rolling average.
66    pub const fn new() -> Self {
67        Self {
68            period: 5,
69            ranges: VecDeque::new(),
70            has_emitted: false,
71        }
72    }
73
74    /// Construct a Long Line detector with a custom averaging period.
75    ///
76    /// `period` must be at least `1`.
77    pub fn with_period(period: usize) -> Result<Self> {
78        if period == 0 {
79            return Err(Error::PeriodZero);
80        }
81        if period > crate::error::MAX_PERIOD {
82            return Err(Error::InvalidPeriod {
83                message: crate::error::PERIOD_ABOVE_MAX,
84            });
85        }
86        Ok(Self {
87            period,
88            ranges: VecDeque::new(),
89            has_emitted: false,
90        })
91    }
92
93    /// Configured averaging period.
94    pub fn period(&self) -> usize {
95        self.period
96    }
97}
98
99impl Indicator for LongLine {
100    type Input = Candle;
101    type Output = f64;
102
103    #[inline]
104    fn update(&mut self, candle: Candle) -> Option<f64> {
105        let range = candle.high - candle.low;
106        let body = candle.close - candle.open;
107        if self.ranges.len() < self.period {
108            self.ranges.push_back(range);
109            return None;
110        }
111        // Past the window gate every path emits, so readiness starts here.
112        self.has_emitted = true;
113        let avg = self.ranges.iter().sum::<f64>() / self.period as f64;
114        self.ranges.push_back(range);
115        self.ranges.pop_front();
116        if range > avg && body.abs() >= 0.5 * range {
117            return Some(if body > 0.0 { 1.0 } else { -1.0 });
118        }
119        Some(0.0)
120    }
121
122    fn reset(&mut self) {
123        self.has_emitted = false;
124        self.ranges.clear();
125    }
126
127    #[inline]
128    fn warmup_period(&self) -> usize {
129        self.period
130    }
131
132    #[inline]
133    fn is_ready(&self) -> bool {
134        self.has_emitted
135    }
136
137    #[inline]
138    fn name(&self) -> &'static str {
139        "LongLine"
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::traits::BatchExt;
147
148    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
149        Candle::new(open, high, low, close, 1.0, ts).unwrap()
150    }
151
152    fn warm(t: &mut LongLine) {
153        for ts in 0..5 {
154            assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, ts)), None);
155        }
156    }
157
158    #[test]
159    fn rejects_zero_period() {
160        assert!(LongLine::with_period(0).is_err());
161    }
162
163    #[test]
164    fn accepts_valid_period() {
165        let t = LongLine::with_period(10).unwrap();
166        assert_eq!(t.period(), 10);
167    }
168
169    #[test]
170    fn accessors_and_metadata() {
171        let t = LongLine::new();
172        assert_eq!(t.name(), "LongLine");
173        assert_eq!(t.warmup_period(), 5);
174        assert!(!t.is_ready());
175        assert_eq!(t.period(), 5);
176    }
177
178    #[test]
179    fn long_white_line_is_plus_one() {
180        let mut t = LongLine::new();
181        warm(&mut t);
182        // The window is full but nothing has been emitted yet, so the
183        // indicator is not ready until the next bar produces a value.
184        assert!(!t.is_ready());
185        assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 5)), Some(1.0));
186    }
187
188    #[test]
189    fn long_black_line_is_minus_one() {
190        let mut t = LongLine::new();
191        warm(&mut t);
192        assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 5)), Some(-1.0));
193    }
194
195    #[test]
196    fn short_range_yields_zero() {
197        let mut t = LongLine::new();
198        warm(&mut t);
199        // Range no bigger than the average -> not a long line.
200        assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, 5)), Some(0.0));
201    }
202
203    #[test]
204    fn wide_range_small_body_yields_zero() {
205        let mut t = LongLine::new();
206        warm(&mut t);
207        // Wide range but a tiny body -> a spinning top, not a long line.
208        assert_eq!(t.update(c(10.5, 13.0, 9.9, 10.6, 5)), Some(0.0));
209    }
210
211    #[test]
212    fn warmup_withholds() {
213        let mut t = LongLine::new();
214        for ts in 0..5 {
215            assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, ts)), None);
216        }
217    }
218
219    #[test]
220    fn batch_equals_streaming() {
221        let candles: Vec<Candle> = (0..40)
222            .map(|i| {
223                let base = 100.0 + i as f64;
224                if i % 7 == 0 {
225                    c(base, base + 4.0, base - 0.1, base + 3.9, i)
226                } else {
227                    c(base, base + 0.5, base - 0.5, base + 0.2, i)
228                }
229            })
230            .collect();
231        let mut a = LongLine::new();
232        let mut b = LongLine::new();
233        assert_eq!(
234            a.batch(&candles),
235            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236        );
237    }
238
239    #[test]
240    fn reset_clears_state() {
241        let mut t = LongLine::new();
242        warm(&mut t);
243        t.update(c(10.0, 13.0, 9.9, 12.9, 5));
244        assert!(t.is_ready());
245        t.reset();
246        assert!(!t.is_ready());
247        assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 0)), None);
248    }
249
250    #[test]
251    fn default_matches_new() {
252        assert_eq!(LongLine::default().period(), LongLine::new().period());
253    }
254}