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}