Skip to main content

termichart_data/
transform.rs

1use termichart_core::{Candle, Point, Range};
2
3/// Computes x and y ranges from a slice of candles with 5% padding.
4///
5/// The x range spans `[min_time, max_time]` and the y range spans
6/// `[min_low, max_high]`, each padded by 5% of their span.
7///
8/// Returns `(x_range, y_range)`. If `candles` is empty, both ranges default
9/// to `0.0..1.0`.
10pub fn auto_range(candles: &[Candle]) -> (Range, Range) {
11    if candles.is_empty() {
12        return (
13            Range { min: 0.0, max: 1.0 },
14            Range { min: 0.0, max: 1.0 },
15        );
16    }
17
18    let mut x_min = candles[0].time;
19    let mut x_max = candles[0].time;
20    let mut y_min = candles[0].low;
21    let mut y_max = candles[0].high;
22
23    for c in &candles[1..] {
24        if c.time < x_min {
25            x_min = c.time;
26        }
27        if c.time > x_max {
28            x_max = c.time;
29        }
30        if c.low < y_min {
31            y_min = c.low;
32        }
33        if c.high > y_max {
34            y_max = c.high;
35        }
36    }
37
38    let x_range = Range { min: x_min, max: x_max }.with_padding(0.05);
39    let y_range = Range { min: y_min, max: y_max }.with_padding(0.05);
40
41    (x_range, y_range)
42}
43
44/// Computes x and y ranges from a slice of points with 5% padding.
45///
46/// Returns `(x_range, y_range)`. If `points` is empty, both ranges default
47/// to `0.0..1.0`.
48pub fn auto_range_points(points: &[Point]) -> (Range, Range) {
49    if points.is_empty() {
50        return (
51            Range { min: 0.0, max: 1.0 },
52            Range { min: 0.0, max: 1.0 },
53        );
54    }
55
56    let mut x_min = points[0].x;
57    let mut x_max = points[0].x;
58    let mut y_min = points[0].y;
59    let mut y_max = points[0].y;
60
61    for p in &points[1..] {
62        if p.x < x_min {
63            x_min = p.x;
64        }
65        if p.x > x_max {
66            x_max = p.x;
67        }
68        if p.y < y_min {
69            y_min = p.y;
70        }
71        if p.y > y_max {
72            y_max = p.y;
73        }
74    }
75
76    let x_range = Range { min: x_min, max: x_max }.with_padding(0.05);
77    let y_range = Range { min: y_min, max: y_max }.with_padding(0.05);
78
79    (x_range, y_range)
80}
81
82/// Rounds `x` to a "nice" number.
83///
84/// If `round` is `true`, returns the nearest nice number; otherwise returns
85/// the ceiling nice number. A nice number is of the form `1 * 10^n`,
86/// `2 * 10^n`, or `5 * 10^n`.
87fn nice_number(x: f64, round: bool) -> f64 {
88    if x == 0.0 {
89        return 0.0;
90    }
91
92    let exp = x.abs().log10().floor();
93    let frac = x.abs() / 10.0_f64.powf(exp);
94    let sign = if x < 0.0 { -1.0 } else { 1.0 };
95
96    let nice_frac = if round {
97        if frac < 1.5 {
98            1.0
99        } else if frac < 3.0 {
100            2.0
101        } else if frac < 7.0 {
102            5.0
103        } else {
104            10.0
105        }
106    } else {
107        // ceiling
108        if frac <= 1.0 {
109            1.0
110        } else if frac <= 2.0 {
111            2.0
112        } else if frac <= 5.0 {
113            5.0
114        } else {
115            10.0
116        }
117    };
118
119    sign * nice_frac * 10.0_f64.powf(exp)
120}
121
122/// Generates a set of "nice" evenly-spaced tick values for a given range.
123///
124/// The algorithm picks a rounded step size so that tick labels fall on
125/// human-friendly numbers (multiples of 1, 2, 5, 10, ...).
126///
127/// `max_ticks` is a *hint*; the returned vector may contain up to
128/// `max_ticks + 1` values.
129pub fn nice_ticks(range: &Range, max_ticks: usize) -> Vec<f64> {
130    if max_ticks == 0 {
131        return Vec::new();
132    }
133
134    let span = range.span();
135    if span == 0.0 {
136        return vec![range.min];
137    }
138
139    let step = nice_number(span / max_ticks as f64, true);
140    if step == 0.0 {
141        return vec![range.min];
142    }
143
144    let start = (range.min / step).floor() * step;
145    let mut ticks = Vec::new();
146    let mut v = start;
147
148    // Safety limit to avoid infinite loops with pathological inputs.
149    let limit = max_ticks * 4;
150    let mut count = 0;
151
152    while v <= range.max + step * 0.5 {
153        if v >= range.min - step * 0.5 {
154            // Round to avoid floating-point noise.
155            let rounded = (v * 1e12).round() / 1e12;
156            ticks.push(rounded);
157        }
158        v += step;
159        count += 1;
160        if count > limit {
161            break;
162        }
163    }
164
165    ticks
166}
167
168/// Converts regular OHLCV candles into Heikin-Ashi candles.
169///
170/// Heikin-Ashi smooths price action by averaging values:
171/// - **HA Close** = (Open + High + Low + Close) / 4
172/// - **HA Open**  = (prev HA Open + prev HA Close) / 2
173/// - **HA High**  = max(High, HA Open, HA Close)
174/// - **HA Low**   = min(Low, HA Open, HA Close)
175///
176/// The first candle uses the original open. Time and volume are preserved.
177pub fn to_heikin_ashi(candles: &[Candle]) -> Vec<Candle> {
178    if candles.is_empty() {
179        return Vec::new();
180    }
181
182    let mut ha = Vec::with_capacity(candles.len());
183
184    let first = &candles[0];
185    let ha_close = (first.open + first.high + first.low + first.close) / 4.0;
186    let ha_open = (first.open + first.close) / 2.0;
187    let ha_high = first.high.max(ha_open).max(ha_close);
188    let ha_low = first.low.min(ha_open).min(ha_close);
189    ha.push(Candle {
190        time: first.time,
191        open: ha_open,
192        high: ha_high,
193        low: ha_low,
194        close: ha_close,
195        volume: first.volume,
196    });
197
198    for i in 1..candles.len() {
199        let c = &candles[i];
200        let prev = &ha[i - 1];
201
202        let ha_close = (c.open + c.high + c.low + c.close) / 4.0;
203        let ha_open = (prev.open + prev.close) / 2.0;
204        let ha_high = c.high.max(ha_open).max(ha_close);
205        let ha_low = c.low.min(ha_open).min(ha_close);
206
207        ha.push(Candle {
208            time: c.time,
209            open: ha_open,
210            high: ha_high,
211            low: ha_low,
212            close: ha_close,
213            volume: c.volume,
214        });
215    }
216
217    ha
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn auto_range_basic() {
226        let candles = vec![
227            Candle { time: 1.0, open: 10.0, high: 15.0, low: 8.0, close: 12.0, volume: 100.0 },
228            Candle { time: 2.0, open: 12.0, high: 20.0, low: 10.0, close: 18.0, volume: 200.0 },
229            Candle { time: 3.0, open: 18.0, high: 22.0, low: 16.0, close: 20.0, volume: 150.0 },
230        ];
231        let (xr, yr) = auto_range(&candles);
232        // x span = 3 - 1 = 2, padding = 0.1
233        assert!((xr.min - 0.9).abs() < 1e-10);
234        assert!((xr.max - 3.1).abs() < 1e-10);
235        // y: low=8, high=22, span=14, padding=0.7
236        assert!((yr.min - 7.3).abs() < 1e-10);
237        assert!((yr.max - 22.7).abs() < 1e-10);
238    }
239
240    #[test]
241    fn auto_range_empty() {
242        let (xr, yr) = auto_range(&[]);
243        assert_eq!(xr.min, 0.0);
244        assert_eq!(xr.max, 1.0);
245        assert_eq!(yr.min, 0.0);
246        assert_eq!(yr.max, 1.0);
247    }
248
249    #[test]
250    fn auto_range_points_basic() {
251        let points = vec![
252            Point { x: 0.0, y: 10.0 },
253            Point { x: 100.0, y: 50.0 },
254        ];
255        let (xr, yr) = auto_range_points(&points);
256        assert!(xr.min < 0.0);
257        assert!(xr.max > 100.0);
258        assert!(yr.min < 10.0);
259        assert!(yr.max > 50.0);
260    }
261
262    #[test]
263    fn nice_ticks_basic() {
264        let range = Range { min: 0.0, max: 100.0 };
265        let ticks = nice_ticks(&range, 5);
266        assert!(!ticks.is_empty());
267        // All ticks should be within the padded range.
268        for &t in &ticks {
269            assert!(t >= -10.0 && t <= 110.0);
270        }
271        // Ticks should be monotonically increasing.
272        for w in ticks.windows(2) {
273            assert!(w[1] > w[0]);
274        }
275    }
276
277    #[test]
278    fn nice_ticks_zero_span() {
279        let range = Range { min: 5.0, max: 5.0 };
280        let ticks = nice_ticks(&range, 5);
281        assert_eq!(ticks, vec![5.0]);
282    }
283
284    #[test]
285    fn nice_ticks_zero_max() {
286        let range = Range { min: 0.0, max: 100.0 };
287        let ticks = nice_ticks(&range, 0);
288        assert!(ticks.is_empty());
289    }
290
291    #[test]
292    fn nice_number_rounds() {
293        assert!((nice_number(12.3, true) - 10.0).abs() < 1e-10);
294        assert!((nice_number(45.0, true) - 50.0).abs() < 1e-10);
295        assert!((nice_number(0.073, true) - 0.1).abs() < 1e-10);
296    }
297}