Skip to main content

egui_charts/model/
line_break.rs

1//! Line Break chart transformation.
2//!
3//! A Line Break chart extends the current direction when the selected price
4//! passes the last line's extreme. A reversal requires a break past the opposite
5//! extreme of the configured number of preceding lines. Use
6//! [`to_line_break_lines`] to transform OHLCV bars without rendering.
7//!
8//! - [`to_line_break_lines`] converts OHLCV bars into line-break lines.
9//! - [`detect_signal`] classifies the relationship between the last two
10//!   lines as a continuation or reversal.
11
12use super::PriceBasedSource;
13use super::PriceBuilder;
14use super::bar::Bar;
15use chrono::{DateTime, Utc};
16
17/// Configuration for the Line Break chart transformation.
18///
19/// The `line_count` parameter (typically 3) controls how many prior lines
20/// must be exceeded for a reversal to register.
21#[derive(Debug, Clone)]
22pub struct LineBreakConfig {
23    /// Number of lines to look back for reversal
24    pub line_count: usize,
25    /// Price input; high/low gives continuation priority when a bar crosses both thresholds.
26    pub source: PriceBasedSource,
27}
28
29impl Default for LineBreakConfig {
30    fn default() -> Self {
31        Self {
32            line_count: 3,
33            source: PriceBasedSource::Close,
34        }
35    }
36}
37
38impl LineBreakConfig {
39    /// Create a Line Break config with the given look-back count.
40    pub fn new(line_count: usize) -> Self {
41        Self {
42            line_count,
43            source: PriceBasedSource::Close,
44        }
45    }
46}
47
48/// Direction of a single line in a Line Break chart.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LineDirection {
51    /// An upward direction.
52    Up,
53    /// A downward direction.
54    Down,
55}
56
57/// A single line (block) in a Line Break chart.
58///
59/// A continuation line opens at the previous line's close, a reversal line
60/// at the previous line's open; it closes at the bar's close.
61#[derive(Debug, Clone)]
62pub struct LineBreakLine {
63    /// Open price: the previous line's close for a continuation, its open for a reversal
64    pub open: f64,
65    /// Close price
66    pub close: f64,
67    /// Direction of the line
68    pub direction: LineDirection,
69    /// Ts when line was created
70    pub ts: DateTime<Utc>,
71}
72
73impl LineBreakLine {
74    /// Get the high of the line
75    pub fn high(&self) -> f64 {
76        self.open.max(self.close)
77    }
78
79    /// Get the low of the line
80    pub fn low(&self) -> f64 {
81        self.open.min(self.close)
82    }
83
84    /// Check if this is a bullish (up) line
85    pub fn is_bullish(&self) -> bool {
86        self.direction == LineDirection::Up
87    }
88
89    /// Check if this is a bearish (down) line
90    pub fn is_bearish(&self) -> bool {
91        self.direction == LineDirection::Down
92    }
93}
94
95/// Convert bar data to Line Break lines
96///
97/// # Safety Limits
98/// - At most the newest 10,000 lines
99pub fn to_line_break_lines(data: &[Bar], config: &LineBreakConfig) -> Vec<LineBreakLine> {
100    let mut builder = LineBreakBuilder::new(config);
101    for bar in data {
102        builder.push(bar);
103    }
104    builder.elements()
105}
106
107pub(crate) const MAX_LINES: usize = 10_000;
108
109/// Builds Line Break lines bar by bar.
110#[derive(Clone)]
111pub(crate) struct LineBreakBuilder {
112    line_count: usize,
113    source: PriceBasedSource,
114    lines: Vec<LineBreakLine>,
115}
116
117impl LineBreakBuilder {
118    pub(crate) fn new(config: &LineBreakConfig) -> Self {
119        Self {
120            line_count: config.line_count,
121            source: config.source,
122            lines: Vec::new(),
123        }
124    }
125}
126
127impl PriceBuilder for LineBreakBuilder {
128    type Element = LineBreakLine;
129
130    fn push(&mut self, bar: &Bar) {
131        let Some(last_line) = self.lines.last() else {
132            // The first bar is the first line
133            self.lines.push(LineBreakLine {
134                open: bar.open,
135                close: bar.close,
136                direction: if bar.close >= bar.open {
137                    LineDirection::Up
138                } else {
139                    LineDirection::Down
140                },
141                ts: bar.time,
142            });
143            return;
144        };
145        let lines = &self.lines;
146        let curr_direction = last_line.direction;
147
148        let price = match self.source {
149            PriceBasedSource::Close => bar.close,
150            PriceBasedSource::HighLow if curr_direction == LineDirection::Up => {
151                if bar.high > last_line.high() {
152                    bar.high
153                } else {
154                    bar.low
155                }
156            }
157            PriceBasedSource::HighLow => {
158                if bar.low < last_line.low() {
159                    bar.low
160                } else {
161                    bar.high
162                }
163            }
164        };
165        let new_line = match curr_direction {
166            LineDirection::Up => {
167                // For a continuation, close must exceed the last line's high
168                if price > last_line.high() {
169                    Some(LineBreakLine {
170                        open: last_line.close,
171                        close: price,
172                        direction: LineDirection::Up,
173                        ts: bar.time,
174                    })
175                }
176                // For a reversal, close must break below the low of the last N lines
177                else {
178                    let lookback = self.line_count.min(lines.len());
179                    let reversal_low = lines[lines.len() - lookback..]
180                        .iter()
181                        .map(|l| l.low())
182                        .fold(f64::INFINITY, f64::min);
183
184                    (price < reversal_low).then_some(LineBreakLine {
185                        open: last_line.open,
186                        close: price,
187                        direction: LineDirection::Down,
188                        ts: bar.time,
189                    })
190                }
191            }
192            LineDirection::Down => {
193                // For a continuation, close must break below the last line's low
194                if price < last_line.low() {
195                    Some(LineBreakLine {
196                        open: last_line.close,
197                        close: price,
198                        direction: LineDirection::Down,
199                        ts: bar.time,
200                    })
201                }
202                // For a reversal, close must exceed the high of the last N lines
203                else {
204                    let lookback = self.line_count.min(lines.len());
205                    let reversal_high = lines[lines.len() - lookback..]
206                        .iter()
207                        .map(|l| l.high())
208                        .fold(f64::NEG_INFINITY, f64::max);
209
210                    (price > reversal_high).then_some(LineBreakLine {
211                        open: last_line.open,
212                        close: price,
213                        direction: LineDirection::Up,
214                        ts: bar.time,
215                    })
216                }
217            }
218        };
219        self.lines.extend(new_line);
220
221        // Bound memory by dropping the oldest lines; only the last `line_count` are read.
222        let keep = MAX_LINES.max(self.line_count);
223        if self.lines.len() >= keep.saturating_mul(2) {
224            self.lines.drain(..self.lines.len() - keep);
225        }
226    }
227
228    fn elements(&self) -> Vec<LineBreakLine> {
229        self.lines[self.lines.len().saturating_sub(MAX_LINES)..].to_vec()
230    }
231}
232
233/// Signal generated from the last two lines of a Line Break chart.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum LineBreakSignal {
236    /// New bullish line (potential buy)
237    Bullish,
238    /// New bearish line (potential sell)
239    Bearish,
240    /// Reversal from down to up
241    BullishReversal,
242    /// Reversal from up to down
243    BearishReversal,
244    /// No signal
245    None,
246}
247
248/// Classify the signal produced by the last two lines in the sequence.
249///
250/// Returns [`LineBreakSignal::None`] if fewer than two lines exist.
251pub fn detect_signal(lines: &[LineBreakLine]) -> LineBreakSignal {
252    if lines.len() < 2 {
253        return LineBreakSignal::None;
254    }
255
256    let prev = &lines[lines.len() - 2];
257    let current = &lines[lines.len() - 1];
258
259    match (prev.direction, current.direction) {
260        (LineDirection::Down, LineDirection::Up) => LineBreakSignal::BullishReversal,
261        (LineDirection::Up, LineDirection::Down) => LineBreakSignal::BearishReversal,
262        (LineDirection::Up, LineDirection::Up) => LineBreakSignal::Bullish,
263        (LineDirection::Down, LineDirection::Down) => LineBreakSignal::Bearish,
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_signal_detection() {
273        let lines = vec![
274            LineBreakLine {
275                open: 100.0,
276                close: 105.0,
277                direction: LineDirection::Up,
278                ts: Utc::now(),
279            },
280            LineBreakLine {
281                open: 105.0,
282                close: 98.0,
283                direction: LineDirection::Down,
284                ts: Utc::now(),
285            },
286        ];
287
288        let signal = detect_signal(&lines);
289        assert_eq!(signal, LineBreakSignal::BearishReversal);
290    }
291
292    #[test]
293    fn reversal_lines_open_at_the_last_line_open() {
294        // (first open, closes after the first bar, expected (open, close) of every line)
295        let rows = [
296            (
297                10.0,
298                [11.0, 12.0, 13.0, 9.0],
299                [(10.0, 11.0), (11.0, 12.0), (12.0, 13.0), (12.0, 9.0)],
300            ),
301            (
302                13.0,
303                [12.0, 11.0, 10.0, 14.0],
304                [(13.0, 12.0), (12.0, 11.0), (11.0, 10.0), (11.0, 14.0)],
305            ),
306        ];
307        for (open, closes, expected) in rows {
308            let t0 = Utc::now();
309            let bars: Vec<Bar> = closes
310                .iter()
311                .enumerate()
312                .map(|(i, &c)| {
313                    let o = if i == 0 { open } else { c };
314                    Bar {
315                        time: t0,
316                        open: o,
317                        high: o.max(c),
318                        low: o.min(c),
319                        close: c,
320                        volume: 0.0,
321                        colors: None,
322                        payload: None,
323                    }
324                })
325                .collect();
326            let lines = to_line_break_lines(&bars, &LineBreakConfig::default());
327            let got: Vec<(f64, f64)> = lines.iter().map(|l| (l.open, l.close)).collect();
328            assert_eq!(got, expected);
329        }
330    }
331}