Skip to main content

quantwave_core/indicators/
point_figure.rs

1//! Point & Figure column construction (close-based, N-box reversal).
2//!
3//! A Point & Figure chart discards time and volume and reduces price to a grid
4//! of boxes of height `box_size`. Price movement is drawn as columns: rising
5//! columns of X's and falling columns of O's. A column keeps extending while
6//! price advances by whole boxes, and a new (opposite) column starts only when
7//! price reverses by at least `reversal` boxes from the current column's extreme
8//! — the classic **N-box reversal** filter (`reversal = 3` is the traditional
9//! default).
10//!
11//! This implements the **close-price** construction: each bar contributes its
12//! close, quantised to the box grid `floor(price / box_size)`. On a reversal the
13//! new column begins one box back from the prior extreme (the standard
14//! `top − 1 … top − N` placement).
15//!
16//! Sources:
17//! - Cohen, A. W. (1947), *How to Use the Three-Point Reversal Method of Point &
18//!   Figure Stock Market Trading* — origin of the three-box (N-box) reversal.
19//! - du Plessis, J. (2012), *The Definitive Guide to Point and Figure*, 2nd ed. —
20//!   modern box-size / reversal construction; the close-price method.
21//!
22//! Two surfaces kept in parity (see the proptest):
23//! - streaming: [`PointFigureBuilder::next`] pushes one close, returns a
24//!   completed column when a reversal starts a new one (else `None`);
25//! - batch: [`point_figure_batch`] folds the builder over a close slice.
26
27/// One completed Point & Figure column. `direction` is +1 (X, rising) or -1
28/// (O, falling); `top`/`bottom` are the column's high/low box levels
29/// (`top >= bottom`); `boxes` is the span between them in `box_size` units.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct PointFigureColumn {
32    pub top: f64,
33    pub bottom: f64,
34    pub direction: i8,
35    pub boxes: u32,
36}
37
38/// Stateful close-based Point & Figure builder.
39#[derive(Debug, Clone)]
40pub struct PointFigureBuilder {
41    box_size: f64,
42    reversal: u32,
43    /// Current column's high and low box indices (`floor(price / box_size)`).
44    hi_idx: i64,
45    lo_idx: i64,
46    /// Current column: +1 X, -1 O, 0 before the first box move establishes one.
47    direction: i8,
48    initialized: bool,
49}
50
51impl PointFigureBuilder {
52    /// Create a builder. `box_size` must be positive and `reversal >= 1`.
53    pub fn new(box_size: f64, reversal: u32) -> Self {
54        Self {
55            box_size,
56            reversal: reversal.max(1),
57            hi_idx: 0,
58            lo_idx: 0,
59            direction: 0,
60            initialized: false,
61        }
62    }
63
64    fn level(&self, idx: i64) -> f64 {
65        idx as f64 * self.box_size
66    }
67
68    /// Push one close; return a completed column if this close reversed the
69    /// current one, else `None`.
70    pub fn next(&mut self, close: f64) -> Option<PointFigureColumn> {
71        if self.box_size <= 0.0 || !close.is_finite() {
72            return None;
73        }
74        let i = (close / self.box_size).floor() as i64;
75        if !self.initialized {
76            self.hi_idx = i;
77            self.lo_idx = i;
78            self.initialized = true;
79            return None;
80        }
81        let rev = self.reversal as i64;
82        match self.direction {
83            0 => {
84                // First whole-box move fixes the initial column direction.
85                if i > self.hi_idx {
86                    self.direction = 1;
87                    self.hi_idx = i;
88                } else if i < self.lo_idx {
89                    self.direction = -1;
90                    self.lo_idx = i;
91                }
92                None
93            }
94            1 => {
95                // X column: extend up on a new high; reverse down `rev` boxes.
96                if i > self.hi_idx {
97                    self.hi_idx = i;
98                    None
99                } else if i <= self.hi_idx - rev {
100                    let col = PointFigureColumn {
101                        top: self.level(self.hi_idx),
102                        bottom: self.level(self.lo_idx),
103                        direction: 1,
104                        boxes: (self.hi_idx - self.lo_idx) as u32,
105                    };
106                    // New O column starts one box below the prior top.
107                    self.hi_idx -= 1;
108                    self.lo_idx = i;
109                    self.direction = -1;
110                    Some(col)
111                } else {
112                    None
113                }
114            }
115            _ => {
116                // O column: extend down on a new low; reverse up `rev` boxes.
117                if i < self.lo_idx {
118                    self.lo_idx = i;
119                    None
120                } else if i >= self.lo_idx + rev {
121                    let col = PointFigureColumn {
122                        top: self.level(self.hi_idx),
123                        bottom: self.level(self.lo_idx),
124                        direction: -1,
125                        boxes: (self.hi_idx - self.lo_idx) as u32,
126                    };
127                    // New X column starts one box above the prior bottom.
128                    self.lo_idx += 1;
129                    self.hi_idx = i;
130                    self.direction = 1;
131                    Some(col)
132                } else {
133                    None
134                }
135            }
136        }
137    }
138}
139
140/// Batch Point & Figure: fold [`PointFigureBuilder`] over a close slice.
141pub fn point_figure_batch(closes: &[f64], box_size: f64, reversal: u32) -> Vec<PointFigureColumn> {
142    let mut b = PointFigureBuilder::new(box_size, reversal);
143    let mut out = Vec::new();
144    for &c in closes {
145        if let Some(col) = b.next(c) {
146            out.push(col);
147        }
148    }
149    out
150}
151
152/// ATR-box Point & Figure: box size is `multiplier * atr` (a single
153/// representative ATR), matching the ATR-box convention of the other bar types.
154pub fn point_figure_atr_batch(
155    closes: &[f64],
156    atr: f64,
157    multiplier: f64,
158    reversal: u32,
159) -> Vec<PointFigureColumn> {
160    point_figure_batch(closes, atr * multiplier, reversal)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use proptest::prelude::*;
167
168    #[test]
169    fn gold_single_x_column_on_reversal() {
170        // box=1, rev=3. Rise 10→13 (X column [10,13]); fall to 10 (= 13-3) starts
171        // an O column and completes the X column.
172        let cols = point_figure_batch(&[10.0, 13.0, 10.0], 1.0, 3);
173        assert_eq!(cols.len(), 1);
174        assert_eq!(cols[0].direction, 1);
175        assert_eq!(cols[0].bottom, 10.0);
176        assert_eq!(cols[0].top, 13.0);
177        assert_eq!(cols[0].boxes, 3);
178    }
179
180    #[test]
181    fn gold_alternating_columns() {
182        // X [10,13], then O completes when price turns back up 3 boxes.
183        let cols = point_figure_batch(&[10.0, 13.0, 10.0, 7.0, 10.0], 1.0, 3);
184        let dirs: Vec<i8> = cols.iter().map(|c| c.direction).collect();
185        assert_eq!(dirs, vec![1, -1]);
186        // O column ran from the reversal top (12) down to 7.
187        assert_eq!(cols[1].top, 12.0);
188        assert_eq!(cols[1].bottom, 7.0);
189    }
190
191    #[test]
192    fn no_reversal_within_threshold() {
193        // A 2-box pullback (< reversal 3) never starts a new column.
194        assert!(point_figure_batch(&[10.0, 13.0, 11.0, 13.5], 1.0, 3).is_empty());
195    }
196
197    #[test]
198    fn atr_box_reversal_param() {
199        let cols = point_figure_atr_batch(&[10.0, 13.0, 10.0], 1.0, 1.0, 3);
200        assert_eq!(cols.len(), 1);
201        assert_eq!(cols[0].boxes, 3);
202    }
203
204    proptest! {
205        // Streaming (one at a time) must equal batch (fold).
206        #[test]
207        fn streaming_equals_batch(
208            closes in prop::collection::vec(0.0f64..1000.0, 1..200),
209            box_size in 0.5f64..50.0,
210            reversal in 1u32..5,
211        ) {
212            let batch = point_figure_batch(&closes, box_size, reversal);
213            let mut b = PointFigureBuilder::new(box_size, reversal);
214            let mut streamed = Vec::new();
215            for &c in &closes {
216                if let Some(col) = b.next(c) {
217                    streamed.push(col);
218                }
219            }
220            prop_assert_eq!(streamed, batch);
221        }
222
223        // Columns strictly alternate direction, and each column's open→close sign
224        // matches its stated direction with a positive box span.
225        #[test]
226        fn columns_alternate_and_consistent(
227            closes in prop::collection::vec(0.0f64..1000.0, 1..200),
228            box_size in 0.5f64..50.0,
229            reversal in 1u32..5,
230        ) {
231            let cols = point_figure_batch(&closes, box_size, reversal);
232            for w in cols.windows(2) {
233                prop_assert_eq!(w[0].direction, -w[1].direction);
234            }
235            for c in &cols {
236                prop_assert!(c.direction == 1 || c.direction == -1);
237                prop_assert!(c.top >= c.bottom);
238                let span = ((c.top - c.bottom) / box_size).round() as u32;
239                prop_assert_eq!(span, c.boxes);
240            }
241        }
242    }
243}