Skip to main content

wickra_core/indicators/
three_line_break.rs

1//! Three Line Break — the close-driven line-break chart trend, as a direction.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Three Line Break — the trend direction of a line-break ("kakushi") chart, where
8/// a reversal requires the close to break the extreme of the last `lines` lines.
9///
10/// ```text
11/// continue the trend when close exceeds the prior line's end
12/// reverse the trend when close breaks beyond the extreme of the last `lines` lines
13/// output = current line direction: +1 (up), −1 (down)
14/// ```
15///
16/// A line-break chart ignores time and small moves entirely: it draws a new line
17/// only when the close makes a new extreme in the trend, and flips direction only
18/// when the close reverses past the high (or low) of the last `lines` lines —
19/// classically **three**. This filters out minor pullbacks, so the emitted
20/// direction stays in a trend until a genuinely significant reversal. Distinct from
21/// the candlestick [`ThreeLineStrike`](crate::ThreeLineStrike) (a fixed four-bar
22/// pattern); this is the line-break *chart type* reduced to its trend state. See
23/// also the alt-chart "Three-Line-Break Bars" builder.
24///
25/// The output is `+1.0` / `−1.0`. The first bar seeds the reference price; the
26/// direction is emitted once the first line is drawn (data-dependent;
27/// `warmup_period` returns the minimum `2`). Each `update` is O(`lines`).
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, Indicator, ThreeLineBreak};
33///
34/// let mut indicator = ThreeLineBreak::new(3).unwrap();
35/// let mut last = None;
36/// for i in 0..20 {
37///     let close = 100.0 + f64::from(i);
38///     let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
39///     last = indicator.update(c);
40/// }
41/// assert_eq!(last, Some(1.0));
42/// ```
43#[derive(Debug, Clone)]
44pub struct ThreeLineBreak {
45    lines: usize,
46    line_values: Vec<f64>,
47    dir: i8,
48    last: Option<f64>,
49}
50
51impl ThreeLineBreak {
52    /// Construct a Three Line Break requiring `lines` lines to reverse (classic 3).
53    ///
54    /// # Errors
55    ///
56    /// Returns [`Error::PeriodZero`] if `lines == 0`.
57    pub fn new(lines: usize) -> Result<Self> {
58        if lines == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if lines > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            lines,
68            line_values: Vec::with_capacity(lines + 1),
69            dir: 0,
70            last: None,
71        })
72    }
73
74    /// Configured number of lines required to reverse.
75    pub const fn lines(&self) -> usize {
76        self.lines
77    }
78
79    /// Current direction if available.
80    pub const fn value(&self) -> Option<f64> {
81        self.last
82    }
83
84    fn push_line(&mut self, close: f64, dir: i8) {
85        self.dir = dir;
86        self.line_values.push(close);
87        if self.line_values.len() > self.lines {
88            self.line_values.remove(0);
89        }
90    }
91}
92
93impl Indicator for ThreeLineBreak {
94    type Input = Candle;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, candle: Candle) -> Option<f64> {
99        let close = candle.close;
100        let Some(&prior) = self.line_values.last() else {
101            // Seed the reference price; no line yet.
102            self.line_values.push(close);
103            return None;
104        };
105        if self.dir >= 0 {
106            if close > prior {
107                self.push_line(close, 1);
108            } else {
109                let low = self
110                    .line_values
111                    .iter()
112                    .copied()
113                    .fold(f64::INFINITY, f64::min);
114                if close < low {
115                    self.push_line(close, -1);
116                }
117            }
118        } else if close < prior {
119            self.push_line(close, -1);
120        } else {
121            let high = self
122                .line_values
123                .iter()
124                .copied()
125                .fold(f64::NEG_INFINITY, f64::max);
126            if close > high {
127                self.push_line(close, 1);
128            }
129        }
130        if self.dir == 0 {
131            return None;
132        }
133        let v = f64::from(self.dir);
134        self.last = Some(v);
135        Some(v)
136    }
137
138    fn reset(&mut self) {
139        self.line_values.clear();
140        self.dir = 0;
141        self.last = None;
142    }
143
144    #[inline]
145    fn warmup_period(&self) -> usize {
146        2
147    }
148
149    #[inline]
150    fn is_ready(&self) -> bool {
151        self.last.is_some()
152    }
153
154    #[inline]
155    fn name(&self) -> &'static str {
156        "ThreeLineBreak"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::traits::BatchExt;
164
165    fn c(close: f64) -> Candle {
166        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
167    }
168
169    #[test]
170    fn rejects_zero_lines() {
171        assert!(matches!(ThreeLineBreak::new(0), Err(Error::PeriodZero)));
172    }
173
174    #[test]
175    fn accessors_and_metadata() {
176        let t = ThreeLineBreak::new(3).unwrap();
177        assert_eq!(t.lines(), 3);
178        assert_eq!(t.warmup_period(), 2);
179        assert_eq!(t.name(), "ThreeLineBreak");
180        assert!(!t.is_ready());
181        assert_eq!(t.value(), None);
182    }
183
184    #[test]
185    fn uptrend_is_plus_one() {
186        let mut t = ThreeLineBreak::new(3).unwrap();
187        let candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
188        let out = t.batch(&candles);
189        assert!(out[0].is_none());
190        assert_eq!(out[1], Some(1.0));
191        assert_eq!(out.last().unwrap(), &Some(1.0));
192    }
193
194    #[test]
195    fn downtrend_is_minus_one() {
196        let mut t = ThreeLineBreak::new(3).unwrap();
197        let candles: Vec<Candle> = (0..20).map(|i| c(100.0 - f64::from(i))).collect();
198        let last = t.batch(&candles).into_iter().flatten().last().unwrap();
199        assert_eq!(last, -1.0);
200    }
201
202    #[test]
203    fn small_pullback_does_not_reverse() {
204        // Rise to build 3 up-lines, then a small dip that does not break the
205        // 3-line low keeps the direction up.
206        let mut t = ThreeLineBreak::new(3).unwrap();
207        t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // up-lines at 101,102,103
208                                                            // close 102.5 is below the prior line (103) but above the 3-line low (101) -> no reversal.
209        assert_eq!(t.update(c(102.5)), Some(1.0));
210    }
211
212    #[test]
213    fn break_of_three_line_extreme_reverses() {
214        let mut t = ThreeLineBreak::new(3).unwrap();
215        t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // lines 101,102,103, dir up
216                                                            // close 100.5 breaks below the 3-line low (101) -> reverse to down.
217        assert_eq!(t.update(c(100.5)), Some(-1.0));
218    }
219
220    #[test]
221    fn reset_clears_state() {
222        let mut t = ThreeLineBreak::new(3).unwrap();
223        t.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
224        assert!(t.is_ready());
225        t.reset();
226        assert!(!t.is_ready());
227        assert_eq!(t.value(), None);
228        assert_eq!(t.update(c(100.0)), None);
229    }
230
231    #[test]
232    fn flat_close_emits_none_until_a_line_forms() {
233        let mut t = ThreeLineBreak::new(3).unwrap();
234        assert_eq!(t.update(c(100.0)), None);
235        // An identical close draws no line, so the direction stays unset.
236        assert_eq!(t.update(c(100.0)), None);
237        assert!(!t.is_ready());
238    }
239
240    #[test]
241    fn batch_equals_streaming() {
242        let candles: Vec<Candle> = (0..80)
243            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
244            .collect();
245        let batch = ThreeLineBreak::new(3).unwrap().batch(&candles);
246        let mut b = ThreeLineBreak::new(3).unwrap();
247        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
248        assert_eq!(batch, streamed);
249    }
250}