Skip to main content

wickra_core/indicators/
short_line.rs

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