Skip to main content

egui_charts/model/
box_size.rs

1//! Box sizes derived once from historical bars.
2
3use super::Bar;
4use serde::{Deserialize, Serialize};
5
6/// How a price-based chart obtains its size in price units.
7/// Resolve from the loaded history when selecting the method, then keep the result while
8/// bars stream in. Reapply the parameters to explicitly recalculate from newer history.
9#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
10pub enum BoxSizeMethod {
11    /// Use the chart type's explicit size in price units.
12    #[default]
13    Fixed,
14    /// Wilder's average true range, seeded with the mean of the first `period` true ranges,
15    /// then smoothed through the remaining history and multiplied by `multiplier`.
16    Atr {
17        /// Number of true ranges in the seed and smoothing period; at least 1.
18        period: usize,
19        /// Positive, finite multiple of ATR.
20        multiplier: f64,
21    },
22    /// A percentage of the absolute latest close; 1 means 1%.
23    Percentage(f64),
24}
25
26impl BoxSizeMethod {
27    /// Resolve a positive finite size, or `None` for invalid inputs or insufficient history.
28    /// The first bar's true range is its high minus low. Later true ranges are the largest
29    /// of high minus low, absolute high minus previous close, and absolute low minus previous close.
30    pub fn resolve(self, fixed: f64, bars: &[Bar]) -> Option<f64> {
31        let positive = |n: f64| n.is_finite() && n > 0.0;
32        let size = match self {
33            Self::Fixed => fixed,
34            Self::Percentage(percent) if positive(percent) => {
35                super::last_with_value(bars)?.close.abs() * (percent / 100.0)
36            }
37            // Whitespace bars have no range and are skipped, as the price-based builders skip them.
38            Self::Atr { period, multiplier } if period > 0 && positive(multiplier) => {
39                let (mut average, mut previous, mut count) = (0.0, None::<f64>, 0);
40                for bar in bars.iter().filter(|bar| !bar.is_whitespace()) {
41                    let mut range = bar.high - bar.low;
42                    if !bar.high.is_finite()
43                        || !bar.low.is_finite()
44                        || !bar.close.is_finite()
45                        || range < 0.0
46                    {
47                        return None;
48                    }
49                    if let Some(previous) = previous {
50                        range = range
51                            .max((bar.high - previous).abs())
52                            .max((bar.low - previous).abs());
53                    }
54                    if count < period {
55                        average += range / period as f64;
56                    } else {
57                        average += (range - average) / period as f64;
58                    }
59                    previous = Some(bar.close);
60                    count += 1;
61                }
62                if count < period {
63                    return None;
64                }
65                average * multiplier
66            }
67            _ => return None,
68        };
69        positive(size).then_some(size)
70    }
71}
72
73/// Input used to build Renko, Kagi and Line Break charts.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
75pub enum PriceBasedSource {
76    /// Use each bar's close, including for Renko wick extremes.
77    #[default]
78    Close,
79    /// Use highs and lows. With no intrabar ordering, a continuation takes priority over a
80    /// reversal in the same bar; at most one direction is processed per source bar.
81    HighLow,
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn size_methods_match_wilder_atr_and_last_close_percentage() {
90        let time = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
91            .unwrap()
92            .to_utc();
93        // TR = 2, 4, 6, 8. The three-bar seed is 4; next ATR is (2*4 + 8)/3 = 16/3.
94        let bars: Vec<Bar> = [(99.0, 101.0), (98.0, 102.0), (97.0, 103.0), (96.0, 104.0)]
95            .into_iter()
96            .enumerate()
97            .map(|(i, (low, high))| {
98                Bar::new(
99                    time + chrono::Duration::minutes(i as i64),
100                    100.0,
101                    high,
102                    low,
103                    100.0,
104                    1.0,
105                )
106            })
107            .collect();
108        for (method, fixed, data, expected) in [
109            (BoxSizeMethod::Fixed, 0.25, &bars[..], Some(0.25)),
110            (BoxSizeMethod::Percentage(2.5), 99.0, &bars[..], Some(2.5)),
111            (
112                BoxSizeMethod::Atr {
113                    period: 3,
114                    multiplier: 1.5,
115                },
116                99.0,
117                &bars[..],
118                Some(8.0),
119            ),
120            (
121                BoxSizeMethod::Atr {
122                    period: 3,
123                    multiplier: 1.0,
124                },
125                99.0,
126                &bars[..3],
127                Some(4.0),
128            ),
129            (
130                BoxSizeMethod::Atr {
131                    period: 3,
132                    multiplier: 1.0,
133                },
134                99.0,
135                &bars[..2],
136                None,
137            ),
138            (
139                BoxSizeMethod::Atr {
140                    period: 0,
141                    multiplier: 1.0,
142                },
143                99.0,
144                &bars[..],
145                None,
146            ),
147            (BoxSizeMethod::Percentage(1.0), 99.0, &bars[..0], None),
148            (
149                BoxSizeMethod::Percentage(f64::INFINITY),
150                99.0,
151                &bars[..],
152                None,
153            ),
154            (BoxSizeMethod::Fixed, 0.0, &bars[..], None),
155        ] {
156            assert_eq!(method.resolve(fixed, data), expected, "{method:?}");
157        }
158        let expected = 16.0 / 3.0;
159        assert!(
160            (super::super::RenkoConfig::from_atr(&bars, 3, 1.0).brick_size - expected).abs()
161                < 1e-12
162        );
163        assert!(
164            (super::super::KagiConfig::from_atr(&bars, 3, 1.0).reversal_amount - expected).abs()
165                < 1e-12
166        );
167        let mut gap = bars[..2].to_vec();
168        gap[1] = Bar::new(gap[1].time, 110.0, 112.0, 109.0, 111.0, 1.0);
169        // Gap TR = max(3, |112-100|, |109-100|) = 12; seed = (2+12)/2 = 7.
170        assert_eq!(
171            BoxSizeMethod::Atr {
172                period: 2,
173                multiplier: 1.0
174            }
175            .resolve(0.0, &gap),
176            Some(7.0)
177        );
178    }
179}