use super::bar::Bar;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct PointFigureConfig {
pub box_size: f64,
pub reversal_boxes: usize,
pub use_atr: bool,
pub atr_period: usize,
pub use_close: bool,
}
impl Default for PointFigureConfig {
fn default() -> Self {
Self {
box_size: 1.0,
reversal_boxes: 3,
use_atr: false,
atr_period: 14,
use_close: false,
}
}
}
impl PointFigureConfig {
pub fn new(box_size: f64, reversal_boxes: usize) -> Self {
Self {
box_size,
reversal_boxes,
..Default::default()
}
}
pub fn with_atr(mut self, period: usize) -> Self {
self.use_atr = true;
self.atr_period = period;
self
}
pub fn with_close_only(mut self) -> Self {
self.use_close = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnDirection {
Up,
Down,
}
#[derive(Debug, Clone)]
pub struct PnfColumn {
pub start_price: f64,
pub end_price: f64,
pub direction: ColumnDirection,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub box_size: f64,
pub box_cnt: usize,
}
impl PnfColumn {
pub fn low(&self) -> f64 {
self.start_price.min(self.end_price)
}
pub fn high(&self) -> f64 {
self.start_price.max(self.end_price)
}
pub fn boxes(&self) -> Vec<f64> {
let mut prices = Vec::new();
let low = self.low();
let count = ((self.high() - low) / self.box_size).round() as usize;
for i in 0..=count {
prices.push(low + (i as f64 * self.box_size));
}
prices
}
pub fn contains(&self, price: f64) -> bool {
price >= self.low() && price <= self.high()
}
}
pub fn to_pnf_columns(data: &[Bar], config: &PointFigureConfig) -> Vec<PnfColumn> {
if data.is_empty() {
return Vec::new();
}
let (min_price, max_price) = data.iter().fold((f64::MAX, f64::MIN), |(min, max), bar| {
(min.min(bar.low), max.max(bar.high))
});
let price_range = max_price - min_price;
let base_box_size = if config.use_atr && data.len() >= config.atr_period {
calculate_atr(data, config.atr_period)
} else {
config.box_size
};
let min_box_size = price_range / 500.0;
let box_size = base_box_size.max(min_box_size).max(0.0001);
let reversal_amount = box_size * config.reversal_boxes as f64;
let mut columns: Vec<PnfColumn> = Vec::new();
const MAX_COLUMNS: usize = 5_000;
let first = &data[0];
let initial_price = if config.use_close {
first.close
} else {
first.high
};
let mut curr_column = PnfColumn {
start_price: snap_to_box(initial_price, box_size),
end_price: snap_to_box(initial_price, box_size),
direction: ColumnDirection::Up,
start_time: first.time,
end_time: first.time,
box_size,
box_cnt: 1,
};
for bar in data.iter().skip(1) {
let (high_price, low_price) = if config.use_close {
(bar.close, bar.close)
} else {
(bar.high, bar.low)
};
match curr_column.direction {
ColumnDirection::Up => {
let new_high = snap_to_box(high_price, box_size);
if new_high > curr_column.end_price {
curr_column.end_price = new_high;
curr_column.end_time = bar.time;
curr_column.box_cnt = ((curr_column.end_price - curr_column.start_price)
/ box_size)
.round() as usize
+ 1;
}
let new_low = snap_to_box(low_price, box_size);
if curr_column.end_price - new_low >= reversal_amount {
columns.push(curr_column.clone());
if columns.len() >= MAX_COLUMNS {
return columns;
}
curr_column = PnfColumn {
start_price: curr_column.end_price - box_size,
end_price: new_low,
direction: ColumnDirection::Down,
start_time: bar.time,
end_time: bar.time,
box_size,
box_cnt: ((curr_column.end_price - box_size - new_low) / box_size).round()
as usize
+ 1,
};
}
}
ColumnDirection::Down => {
let new_low = snap_to_box(low_price, box_size);
if new_low < curr_column.end_price {
curr_column.end_price = new_low;
curr_column.end_time = bar.time;
curr_column.box_cnt = ((curr_column.start_price - curr_column.end_price)
/ box_size)
.round() as usize
+ 1;
}
let new_high = snap_to_box(high_price, box_size);
if new_high - curr_column.end_price >= reversal_amount {
columns.push(curr_column.clone());
if columns.len() >= MAX_COLUMNS {
return columns;
}
curr_column = PnfColumn {
start_price: curr_column.end_price + box_size,
end_price: new_high,
direction: ColumnDirection::Up,
start_time: bar.time,
end_time: bar.time,
box_size,
box_cnt: ((new_high - curr_column.end_price - box_size) / box_size).round()
as usize
+ 1,
};
}
}
}
}
columns.push(curr_column);
columns
}
fn snap_to_box(price: f64, box_size: f64) -> f64 {
(price / box_size).floor() * box_size
}
fn calculate_atr(data: &[Bar], period: usize) -> f64 {
if data.len() < 2 {
return 1.0;
}
let mut tr_values = Vec::new();
for i in 1..data.len() {
let high = data[i].high;
let low = data[i].low;
let prev_close = data[i - 1].close;
let tr = (high - low)
.max((high - prev_close).abs())
.max((low - prev_close).abs());
tr_values.push(tr);
}
if tr_values.len() < period {
return tr_values.iter().sum::<f64>() / tr_values.len() as f64;
}
let sum: f64 = tr_values.iter().rev().take(period).sum();
sum / period as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_bars() -> Vec<Bar> {
let now = Utc::now();
vec![
Bar {
time: now,
open: 100.0,
high: 102.0,
low: 99.0,
close: 101.0,
volume: 1000.0,
},
Bar {
time: now,
open: 101.0,
high: 105.0,
low: 100.0,
close: 104.0,
volume: 1000.0,
},
Bar {
time: now,
open: 104.0,
high: 106.0,
low: 103.0,
close: 105.0,
volume: 1000.0,
},
Bar {
time: now,
open: 105.0,
high: 106.0,
low: 98.0,
close: 99.0,
volume: 1000.0,
},
]
}
#[test]
fn test_config_creation() {
let config = PointFigureConfig::new(1.0, 3);
assert!((config.box_size - 1.0).abs() < 0.01);
assert_eq!(config.reversal_boxes, 3);
}
#[test]
fn test_snap_to_box() {
assert!((snap_to_box(10.5, 1.0) - 10.0).abs() < 0.01);
assert!((snap_to_box(10.9, 1.0) - 10.0).abs() < 0.01);
assert!((snap_to_box(11.0, 1.0) - 11.0).abs() < 0.01);
}
#[test]
fn test_pnf_conversion() {
let bars = create_test_bars();
let config = PointFigureConfig::new(1.0, 3);
let columns = to_pnf_columns(&bars, &config);
assert!(!columns.is_empty());
}
#[test]
fn test_column_boxes() {
let column = PnfColumn {
start_price: 100.0,
end_price: 105.0,
direction: ColumnDirection::Up,
start_time: Utc::now(),
end_time: Utc::now(),
box_size: 1.0,
box_cnt: 6,
};
let boxes = column.boxes();
assert_eq!(boxes.len(), 6); }
}