1use super::PriceBuilder;
10use super::bar::Bar;
11use super::range_bar::calculate_atr;
12use chrono::{DateTime, Utc};
13
14#[derive(Debug, Clone)]
20pub struct PointFigureConfig {
21 pub box_size: f64,
23 pub reversal_boxes: usize,
25 pub use_atr: bool,
27 pub atr_period: usize,
29 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 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 pub fn with_atr(mut self, period: usize) -> Self {
57 self.use_atr = true;
58 self.atr_period = period;
59 self
60 }
61
62 pub fn with_close_only(mut self) -> Self {
64 self.use_close = true;
65 self
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum ColumnDirection {
72 Up,
74 Down,
76}
77
78#[derive(Debug, Clone)]
80pub struct PnfColumn {
81 pub start_price: f64,
83 pub end_price: f64,
85 pub direction: ColumnDirection,
87 pub start_time: DateTime<Utc>,
89 pub end_time: DateTime<Utc>,
91 pub box_size: f64,
93 pub box_cnt: usize,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum PnfBoxesError {
101 NonFiniteEndpoint,
103 InvalidBoxSize,
105 TooManyBoxes(usize),
107 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 pub fn low(&self) -> f64 {
136 self.start_price.min(self.end_price)
137 }
138
139 pub fn high(&self) -> f64 {
141 self.start_price.max(self.end_price)
142 }
143
144 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 pub fn contains(&self, price: f64) -> bool {
172 price >= self.low() && price <= self.high()
173 }
174}
175
176pub fn to_pnf_columns(data: &[Bar], config: &PointFigureConfig) -> Vec<PnfColumn> {
184 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#[derive(Clone)]
207pub(crate) struct PnfBuilder {
208 box_size: f64,
209 reversal_amount: f64,
210 use_close: bool,
211 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 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 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 let new_low = snap_to_box(low_price, box_size);
266 if curr_column.end_price - new_low >= reversal_amount {
267 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 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 let new_high = snap_to_box(high_price, box_size);
293 if new_high - curr_column.end_price >= reversal_amount {
294 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 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
325fn 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
335fn 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 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}