Skip to main content

scope_cli/cli/
candles.rs

1//! The candlestick chart of `market ohlc` (mockup approved 2026-10-26).
2//!
3//! One column per candle, newest on the right, sized to the terminal (or
4//! the TUI pane). Each row holds two price steps: a body is drawn with half
5//! blocks (`▀` `▄` `█`), a wick with thin lines (`│` `╵` `╷`). Up candles
6//! (close ≥ open) are green, down candles red. A price axis on the left and
7//! a time axis under the candles. Pure: no I/O.
8
9use scope::display::format::price_decimals;
10use scope::display::theme::{Painter, Role};
11use scope::market::Candle;
12
13/// Rows of the chart (each two price steps).
14pub const CHART_ROWS: usize = 12;
15
16/// Fewest candles the chart is drawn for.
17pub const MIN_COLUMNS: usize = 10;
18
19/// The candle columns that fit in `width` terminal columns, after the
20/// rail (`│ `) and the price axis.
21pub fn columns_for(width: usize, axis_cols: usize) -> usize {
22    width.saturating_sub(2 + axis_cols + 2).max(MIN_COLUMNS)
23}
24
25/// The decimals of every price axis label for candles between `low` and
26/// `high`: the finer of the two prices' own, so labels line up.
27fn axis_decimals(low: f64, high: f64) -> usize {
28    price_decimals(low).max(price_decimals(high))
29}
30
31/// The width of the price axis labels for candles between `low` and `high`.
32pub fn axis_cols(low: f64, high: f64) -> usize {
33    let d = axis_decimals(low, high);
34    format!("{low:.d$}").len().max(format!("{high:.d$}").len())
35}
36
37/// The chart of `candles` (oldest first) in `rows` rows, one column per
38/// candle, each line starting with the price axis. `time` formats a candle's
39/// open time (ms) for the time axis.
40pub fn chart(
41    candles: &[Candle],
42    rows: usize,
43    painter: Painter,
44    time: impl Fn(u64) -> String,
45) -> Vec<String> {
46    if candles.is_empty() || rows == 0 {
47        return Vec::new();
48    }
49    let low = candles.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
50    let high = candles
51        .iter()
52        .map(|c| c.high)
53        .fold(f64::NEG_INFINITY, f64::max);
54    let span = (high - low).max(f64::EPSILON);
55    let steps = rows * 2;
56    // The half-row step a price falls on, 0 at the bottom.
57    let step = |v: f64| (((v - low) / span) * (steps - 1) as f64).round() as usize;
58    let label_cols = axis_cols(low, high);
59    let decimals = axis_decimals(low, high);
60    let mut lines = Vec::with_capacity(rows + 1);
61    for r in 0..rows {
62        let (top, bottom) = ((rows - 1 - r) * 2 + 1, (rows - 1 - r) * 2);
63        let price = high - span * r as f64 / (rows - 1).max(1) as f64;
64        // A label on the first and last rows and about every fourth between.
65        let labelled = r == 0 || r == rows - 1 || (r % 4 == 0 && r + 2 < rows);
66        let axis = if labelled {
67            format!("{price:>label_cols$.decimals$} ┤")
68        } else {
69            format!("{:>label_cols$} │", "")
70        };
71        let mut line = painter.paint(Role::Secondary, &axis);
72        for c in candles {
73            let (wick_hi, wick_lo) = (step(c.high), step(c.low));
74            let (body_hi, body_lo) = (step(c.open.max(c.close)), step(c.open.min(c.close)));
75            let body = |s: usize| body_lo <= s && s <= body_hi;
76            let wick = |s: usize| wick_lo <= s && s <= wick_hi;
77            let glyph = match (body(top), body(bottom)) {
78                (true, true) => "█",
79                (true, false) => "▀",
80                (false, true) => "▄",
81                (false, false) => match (wick(top), wick(bottom)) {
82                    (true, true) => "│",
83                    (true, false) => "╵",
84                    (false, true) => "╷",
85                    (false, false) => " ",
86                },
87            };
88            if glyph == " " {
89                line.push(' ');
90            } else {
91                let role = if c.close >= c.open {
92                    Role::Ok
93                } else {
94                    Role::Err
95                };
96                line.push_str(&painter.paint(role, glyph));
97            }
98        }
99        lines.push(line);
100    }
101    lines.push(painter.paint(Role::Secondary, &time_axis(candles, label_cols + 2, &time)));
102    lines
103}
104
105/// The time axis under the candles: the first candle's time under the
106/// first column, the last's ending under the last, when both fit.
107fn time_axis(candles: &[Candle], indent: usize, time: &impl Fn(u64) -> String) -> String {
108    let first = time(candles[0].open_time);
109    let last = time(candles[candles.len() - 1].open_time);
110    let width = candles.len();
111    let mut axis = format!("{:indent$}", "");
112    if first.chars().count() + 2 + last.chars().count() <= width {
113        let gap = width - first.chars().count() - last.chars().count();
114        axis.push_str(&format!("{first}{:gap$}{last}", ""));
115    } else if last.chars().count() <= width {
116        axis.push_str(&format!("{last:>width$}"));
117    }
118    axis
119}
120
121/// What the candles of a window add up to.
122#[derive(Debug, Clone, PartialEq)]
123pub struct Summary {
124    pub open: f64,
125    pub high: f64,
126    pub low: f64,
127    pub close: f64,
128    /// Close against open, in percent; `None` when the open is zero.
129    pub change_pct: Option<f64>,
130    /// The candles' base volume, summed.
131    pub volume: f64,
132}
133
134/// The summary of `candles` (oldest first), or `None` when there are none.
135pub fn summary(candles: &[Candle]) -> Option<Summary> {
136    let (first, last) = (candles.first()?, candles.last()?);
137    Some(Summary {
138        open: first.open,
139        high: candles
140            .iter()
141            .map(|c| c.high)
142            .fold(f64::NEG_INFINITY, f64::max),
143        low: candles.iter().map(|c| c.low).fold(f64::INFINITY, f64::min),
144        close: last.close,
145        change_pct: (first.open != 0.0).then(|| (last.close - first.open) / first.open * 100.0),
146        volume: candles.iter().map(|c| c.volume).sum::<f64>() + 0.0,
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn candle(t: u64, open: f64, high: f64, low: f64, close: f64) -> Candle {
155        Candle {
156            open_time: t,
157            open,
158            high,
159            low,
160            close,
161            volume: 10.0,
162            close_time: t + 59,
163        }
164    }
165
166    fn plain(lines: &[String]) -> Vec<String> {
167        lines.to_vec()
168    }
169
170    fn time(ms: u64) -> String {
171        format!("t{ms}")
172    }
173
174    #[test]
175    fn an_up_and_a_down_candle_have_bodies_and_wicks_in_their_columns() {
176        // Range 100..110 over 4 rows = 8 half-steps.
177        let candles = [
178            candle(1, 102.0, 110.0, 100.0, 108.0),
179            candle(2, 108.0, 109.0, 101.0, 103.0),
180        ];
181        let lines = plain(&chart(&candles, 4, Painter::new(false), time));
182        assert_eq!(lines.len(), 5, "{lines:#?}");
183        let col = |i: usize| -> String {
184            lines[..4]
185                .iter()
186                .map(|l| l.chars().rev().nth(1 - i).unwrap())
187                .collect()
188        };
189        // The up candle, body 102..108: a cell holds a body half or a wick
190        // half, so the wick ends beside the body halves are not drawn.
191        assert_eq!(col(0), "▄██▀", "{lines:#?}");
192        // The down candle, body 103..108: its lower wick shows under it.
193        assert_eq!(col(1), "▄██╵", "{lines:#?}");
194        assert!(lines[0].starts_with("110.0000 ┤"), "{lines:#?}");
195        assert!(lines[3].starts_with("100.0000 ┤"), "{lines:#?}");
196    }
197
198    #[test]
199    fn colour_follows_the_close_against_the_open() {
200        let candles = [candle(1, 1.0, 2.0, 0.5, 1.5), candle(2, 1.5, 2.0, 0.5, 1.0)];
201        let lines = chart(&candles, 3, Painter::new(true), time);
202        let painted = |role: Role, glyph: &str| Painter::new(true).paint(role, glyph);
203        assert!(lines.iter().any(|l| l.contains(&painted(Role::Ok, "█"))));
204        assert!(lines.iter().any(|l| l.contains(&painted(Role::Err, "█"))));
205    }
206
207    #[test]
208    fn a_flat_candle_still_shows_and_a_flat_window_does_not_divide_by_zero() {
209        let candles = vec![candle(1, 1.0, 1.0, 1.0, 1.0); 3];
210        let lines = chart(&candles, 4, Painter::new(false), time);
211        let marks: usize = lines[..4]
212            .iter()
213            .map(|l| l.chars().filter(|c| "▀▄█".contains(*c)).count())
214            .sum();
215        assert_eq!(marks, 3, "{lines:#?}");
216        assert!(chart(&[], 4, Painter::new(false), time).is_empty());
217    }
218
219    #[test]
220    fn the_time_axis_spans_the_candles_or_keeps_only_the_last() {
221        let candles: Vec<Candle> = (0..20).map(|i| candle(i, 1.0, 2.0, 0.5, 1.5)).collect();
222        let lines = chart(&candles, 2, Painter::new(false), |ms| format!("{ms:>02}h"));
223        let axis = lines.last().unwrap();
224        assert!(axis.trim_start().starts_with("00h"), "{axis}");
225        assert!(axis.ends_with("19h"), "{axis}");
226        assert_eq!(axis.chars().count(), lines[0].chars().count(), "{axis}");
227        let narrow = chart(&candles[..6], 2, Painter::new(false), |_| {
228            "10-26 08:00".into()
229        });
230        assert!(
231            narrow.last().unwrap().trim().is_empty(),
232            "too wide for both"
233        );
234    }
235
236    #[test]
237    fn the_summary_spans_the_window() {
238        let candles = [
239            candle(1, 100.0, 105.0, 99.0, 104.0),
240            candle(2, 104.0, 112.0, 101.0, 110.0),
241        ];
242        let s = summary(&candles).unwrap();
243        assert_eq!(
244            (s.open, s.high, s.low, s.close, s.volume),
245            (100.0, 112.0, 99.0, 110.0, 20.0)
246        );
247        assert!((s.change_pct.unwrap() - 10.0).abs() < 1e-9);
248        assert!(summary(&[]).is_none());
249        assert_eq!(
250            summary(&[candle(1, 0.0, 1.0, 0.0, 1.0)])
251                .unwrap()
252                .change_pct,
253            None
254        );
255    }
256
257    #[test]
258    fn columns_fit_the_width_but_never_fall_below_the_minimum() {
259        assert_eq!(columns_for(100, 8), 100 - 2 - 8 - 2);
260        assert_eq!(columns_for(12, 8), MIN_COLUMNS);
261        assert_eq!(axis_cols(0.9999, 1.0001), "1.000100".len());
262        // One precision for the whole axis: 0.5 needs six decimals, so 2.0 gets them too.
263        assert_eq!(axis_cols(0.5, 2.0), "2.000000".len());
264    }
265}