Skip to main content

egui_charts/model/
point_figure.rs

1//! Point & Figure (P&F) chart transformation.
2//!
3//! P&F charts remove time from the horizontal axis. Instead, each column
4//! represents a sequence of rising Xs or falling Os. A new column starts
5//! only when price reverses by at least `reversal_boxes * box_size`.
6//!
7//! Use [`to_pnf_columns`] to transform OHLCV bars into P&F columns.
8
9use super::PriceBuilder;
10use super::bar::Bar;
11use super::range_bar::calculate_atr;
12use chrono::{DateTime, Utc};
13
14/// Configuration for Point & Figure chart transformation.
15///
16/// The two key parameters are `box_size` (price units per box) and
17/// `reversal_boxes` (how many boxes of counter-movement trigger a new
18/// column). The traditional default is a 1-box size with 3-box reversal.
19#[derive(Debug, Clone)]
20pub struct PointFigureConfig {
21    /// Box size in price units
22    pub box_size: f64,
23    /// Number of boxes required for reversal
24    pub reversal_boxes: usize,
25    /// Whether to use ATR for box size
26    pub use_atr: bool,
27    /// ATR period if using ATR-based boxes
28    pub atr_period: usize,
29    /// Traditional (high/low) or close-only method
30    pub use_close: bool,
31}
32
33impl Default for PointFigureConfig {
34    fn default() -> Self {
35        Self {
36            box_size: 1.0,
37            reversal_boxes: 3,
38            use_atr: false,
39            atr_period: 14,
40            use_close: false,
41        }
42    }
43}
44
45impl PointFigureConfig {
46    /// Create a P&F config with explicit box size and reversal count.
47    pub fn new(box_size: f64, reversal_boxes: usize) -> Self {
48        Self {
49            box_size,
50            reversal_boxes,
51            ..Default::default()
52        }
53    }
54
55    /// Use ATR-derived box size instead of a fixed value.
56    pub fn with_atr(mut self, period: usize) -> Self {
57        self.use_atr = true;
58        self.atr_period = period;
59        self
60    }
61
62    /// Use close-only method instead of high/low.
63    pub fn with_close_only(mut self) -> Self {
64        self.use_close = true;
65        self
66    }
67}
68
69/// Direction of a P&F column.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum ColumnDirection {
72    /// Rising column (Xs)
73    Up,
74    /// Falling column (Os)
75    Down,
76}
77
78/// A single column of Xs (up) or Os (down) in a P&F chart.
79#[derive(Debug, Clone)]
80pub struct PnfColumn {
81    /// Starting price (bottom for Up, top for Down)
82    pub start_price: f64,
83    /// Ending price (top for Up, bottom for Down)
84    pub end_price: f64,
85    /// Direction of column
86    pub direction: ColumnDirection,
87    /// Ts when column started
88    pub start_time: DateTime<Utc>,
89    /// Ts when column ended (or current time if active)
90    pub end_time: DateTime<Utc>,
91    /// Box size used for this column
92    pub box_size: f64,
93    /// Inclusive number of boxes, saturated at `usize::MAX` when the count is larger.
94    pub box_cnt: usize,
95}
96
97/// Why [`PnfColumn::boxes`] did not list a column's box prices.
98#[derive(Clone, Debug, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum PnfBoxesError {
101    /// The start or end price is NaN or infinite.
102    NonFiniteEndpoint,
103    /// The box size is not a finite number above zero.
104    InvalidBoxSize,
105    /// The column holds this many boxes, more than the 2,000,000 it lists.
106    TooManyBoxes(usize),
107    /// The box prices could not be allocated.
108    Allocation(std::collections::TryReserveError),
109}
110
111impl std::fmt::Display for PnfBoxesError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::NonFiniteEndpoint => f.write_str("box prices need finite column endpoints"),
115            Self::InvalidBoxSize => f.write_str("box prices need a finite box size above zero"),
116            Self::TooManyBoxes(count) => write!(
117                f,
118                "column of {count} boxes exceeds the expansion limit of 2,000,000 boxes"
119            ),
120            Self::Allocation(error) => error.fmt(f),
121        }
122    }
123}
124impl std::error::Error for PnfBoxesError {
125    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126        match self {
127            Self::Allocation(error) => Some(error),
128            _ => None,
129        }
130    }
131}
132
133impl PnfColumn {
134    /// Get the low price of the column
135    pub fn low(&self) -> f64 {
136        self.start_price.min(self.end_price)
137    }
138
139    /// Get the high price of the column
140    pub fn high(&self) -> f64 {
141        self.start_price.max(self.end_price)
142    }
143
144    /// Get all box prices in ascending price order.
145    ///
146    /// # Errors
147    /// Returns an error for non-finite endpoints, an invalid box size, more than two million
148    /// boxes, or an allocation failure. The column endpoints remain available without expansion.
149    pub fn boxes(&self) -> Result<Vec<f64>, PnfBoxesError> {
150        if !self.start_price.is_finite() || !self.end_price.is_finite() {
151            return Err(PnfBoxesError::NonFiniteEndpoint);
152        }
153        if !(self.box_size > 0.0 && self.box_size.is_finite()) {
154            return Err(PnfBoxesError::InvalidBoxSize);
155        }
156        let count = box_count(self.start_price, self.end_price, self.box_size);
157        const MAX_BOXES: usize = 2_000_000;
158        if count > MAX_BOXES {
159            return Err(PnfBoxesError::TooManyBoxes(count));
160        }
161        let mut prices = Vec::new();
162        prices
163            .try_reserve_exact(count)
164            .map_err(PnfBoxesError::Allocation)?;
165        let low = self.low();
166        prices.extend((0..count).map(|i| low + i as f64 * self.box_size));
167        Ok(prices)
168    }
169
170    /// Check if column contains a specific price level
171    pub fn contains(&self, price: f64) -> bool {
172        price >= self.low() && price <= self.high()
173    }
174}
175
176/// Convert bar data to Point and Figure columns
177///
178/// Returns no columns when the box size is not a positive finite number or the reversal is zero
179/// boxes.
180///
181/// # Safety Limits
182/// - At most the newest 5,000 columns
183pub fn to_pnf_columns(data: &[Bar], config: &PointFigureConfig) -> Vec<PnfColumn> {
184    // The box size, from ATR when the config asks for it
185    let box_size = if config.use_atr && data.len() >= config.atr_period {
186        calculate_atr(data, config.atr_period)
187    } else {
188        config.box_size
189    };
190    if data.is_empty() || !(box_size > 0.0 && box_size.is_finite()) || config.reversal_boxes == 0 {
191        return Vec::new();
192    }
193
194    let mut builder = PnfBuilder::new(box_size, config);
195    for bar in data {
196        builder.push(bar);
197    }
198    builder.elements()
199}
200
201pub(crate) const MAX_COLUMNS: usize = 5_000;
202
203/// Builds Point and Figure columns bar by bar; `box_size` must be a positive finite number and
204/// the reversal at least one box. The column in progress is kept apart from the closed columns
205/// and added by `elements`.
206#[derive(Clone)]
207pub(crate) struct PnfBuilder {
208    box_size: f64,
209    reversal_amount: f64,
210    use_close: bool,
211    /// The column in progress; `None` until the first bar.
212    curr_column: Option<PnfColumn>,
213    columns: Vec<PnfColumn>,
214}
215
216impl PnfBuilder {
217    pub(crate) fn new(box_size: f64, config: &PointFigureConfig) -> Self {
218        Self {
219            box_size,
220            reversal_amount: box_size * config.reversal_boxes as f64,
221            use_close: config.use_close,
222            curr_column: None,
223            columns: Vec::new(),
224        }
225    }
226}
227
228impl PriceBuilder for PnfBuilder {
229    type Element = PnfColumn;
230
231    fn push(&mut self, bar: &Bar) {
232        let (box_size, reversal_amount) = (self.box_size, self.reversal_amount);
233        let Some(mut curr_column) = self.curr_column.take() else {
234            // The first bar starts an up column of one box
235            let initial_price = if self.use_close { bar.close } else { bar.high };
236            self.curr_column = Some(PnfColumn {
237                start_price: snap_to_box(initial_price, box_size),
238                end_price: snap_to_box(initial_price, box_size),
239                direction: ColumnDirection::Up,
240                start_time: bar.time,
241                end_time: bar.time,
242                box_size,
243                box_cnt: 1,
244            });
245            return;
246        };
247        let (high_price, low_price) = if self.use_close {
248            (bar.close, bar.close)
249        } else {
250            (bar.high, bar.low)
251        };
252
253        match curr_column.direction {
254            ColumnDirection::Up => {
255                // Check for continuation (new highs)
256                let new_high = snap_to_box(high_price, box_size);
257                if new_high > curr_column.end_price {
258                    curr_column.end_price = new_high;
259                    curr_column.end_time = bar.time;
260                    curr_column.box_cnt =
261                        box_count(curr_column.start_price, curr_column.end_price, box_size);
262                }
263
264                // Check for reversal
265                let new_low = snap_to_box(low_price, box_size);
266                if curr_column.end_price - new_low >= reversal_amount {
267                    // Complete current column and start new one
268                    let reversed = PnfColumn {
269                        start_price: curr_column.end_price - box_size,
270                        end_price: new_low,
271                        direction: ColumnDirection::Down,
272                        start_time: bar.time,
273                        end_time: bar.time,
274                        box_size,
275                        box_cnt: box_count(curr_column.end_price - box_size, new_low, box_size),
276                    };
277                    self.columns
278                        .push(std::mem::replace(&mut curr_column, reversed));
279                }
280            }
281            ColumnDirection::Down => {
282                // Check for continuation (new lows)
283                let new_low = snap_to_box(low_price, box_size);
284                if new_low < curr_column.end_price {
285                    curr_column.end_price = new_low;
286                    curr_column.end_time = bar.time;
287                    curr_column.box_cnt =
288                        box_count(curr_column.start_price, curr_column.end_price, box_size);
289                }
290
291                // Check for reversal
292                let new_high = snap_to_box(high_price, box_size);
293                if new_high - curr_column.end_price >= reversal_amount {
294                    // Complete current column and start new one
295                    let reversed = PnfColumn {
296                        start_price: curr_column.end_price + box_size,
297                        end_price: new_high,
298                        direction: ColumnDirection::Up,
299                        start_time: bar.time,
300                        end_time: bar.time,
301                        box_size,
302                        box_cnt: box_count(curr_column.end_price + box_size, new_high, box_size),
303                    };
304                    self.columns
305                        .push(std::mem::replace(&mut curr_column, reversed));
306                }
307            }
308        }
309        self.curr_column = Some(curr_column);
310
311        // Bound memory by dropping the oldest columns; the builder never reads them.
312        if self.columns.len() == 2 * MAX_COLUMNS {
313            self.columns.drain(..MAX_COLUMNS);
314        }
315    }
316
317    fn elements(&self) -> Vec<PnfColumn> {
318        let mut columns = self.columns.clone();
319        columns.extend(self.curr_column.clone());
320        columns.drain(..columns.len().saturating_sub(MAX_COLUMNS));
321        columns
322    }
323}
324
325// Check the floating count before converting: the inclusive endpoint can exceed `usize` too.
326fn box_count(start: f64, end: f64, size: f64) -> usize {
327    let steps = ((end - start).abs() / size).round();
328    if steps >= usize::MAX as f64 {
329        usize::MAX
330    } else {
331        steps as usize + 1
332    }
333}
334
335/// Snap price to nearest box boundary
336fn snap_to_box(price: f64, box_size: f64) -> f64 {
337    (price / box_size).floor() * box_size
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_snap_to_box() {
346        assert!((snap_to_box(10.5, 1.0) - 10.0).abs() < 0.01);
347        assert!((snap_to_box(10.9, 1.0) - 10.0).abs() < 0.01);
348        assert!((snap_to_box(11.0, 1.0) - 11.0).abs() < 0.01);
349    }
350
351    #[test]
352    fn column_counts_saturate_without_losing_price_endpoints() {
353        // Inclusive counts: 100..101 has two boxes; 100..150 at 1e-8 has 5,000,000,001.
354        for (prices, size, expected) in [
355            (vec![100.0, 101.0], 1.0, 2),
356            (
357                vec![100.0, 150.0],
358                1e-8,
359                5_000_000_001_u64.min(usize::MAX as u64) as usize,
360            ),
361            (vec![100.0, 1e20], 1.0, usize::MAX),
362            (vec![100.0, -1e20], 1.0, usize::MAX),
363            (vec![100.0, 97.0, -1e20], 1.0, usize::MAX),
364            (vec![100.0, 97.0, 1e20], 1.0, usize::MAX),
365        ] {
366            let data: Vec<_> = prices
367                .iter()
368                .enumerate()
369                .map(|(i, &p)| {
370                    Bar::new(
371                        DateTime::from_timestamp(i as i64, 0).unwrap(),
372                        p,
373                        p,
374                        p,
375                        p,
376                        0.0,
377                    )
378                })
379                .collect();
380            let columns = to_pnf_columns(&data, &PointFigureConfig::new(size, 3));
381            let last = columns.last().unwrap();
382            assert_eq!(last.box_cnt, expected, "{prices:?} size {size}");
383            assert_eq!(last.end_price, *prices.last().unwrap());
384        }
385    }
386
387    #[test]
388    fn test_column_boxes() {
389        for (start, end, size, expected) in [
390            (
391                100.0,
392                105.0,
393                1.0,
394                Some(vec![100.0, 101.0, 102.0, 103.0, 104.0, 105.0]),
395            ),
396            (
397                105.0,
398                100.0,
399                1.0,
400                Some(vec![100.0, 101.0, 102.0, 103.0, 104.0, 105.0]),
401            ),
402            (100.0, 100.0, 1.0, Some(vec![100.0])),
403            (100.0, 1e12, 1.0, None),
404            (100.0, 1e20, 1.0, None),
405            (100.0, 150.0, 1e-8, None),
406            (100.0, 105.0, 0.0, None),
407            (100.0, 105.0, f64::NAN, None),
408            (100.0, f64::INFINITY, 1.0, None),
409            (f64::NAN, 105.0, 1.0, None),
410        ] {
411            let column = PnfColumn {
412                start_price: start,
413                end_price: end,
414                direction: ColumnDirection::Up,
415                start_time: Utc::now(),
416                end_time: Utc::now(),
417                box_size: size,
418                box_cnt: 1,
419            };
420            assert_eq!(column.boxes().ok(), expected, "{start}..{end} size {size}");
421        }
422    }
423}