1use scope::display::format::price_decimals;
10use scope::display::theme::{Painter, Role};
11use scope::market::Candle;
12
13pub const CHART_ROWS: usize = 12;
15
16pub const MIN_COLUMNS: usize = 10;
18
19pub fn columns_for(width: usize, axis_cols: usize) -> usize {
22 width.saturating_sub(2 + axis_cols + 2).max(MIN_COLUMNS)
23}
24
25fn axis_decimals(low: f64, high: f64) -> usize {
28 price_decimals(low).max(price_decimals(high))
29}
30
31pub 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
37pub 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 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 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
105fn 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#[derive(Debug, Clone, PartialEq)]
123pub struct Summary {
124 pub open: f64,
125 pub high: f64,
126 pub low: f64,
127 pub close: f64,
128 pub change_pct: Option<f64>,
130 pub volume: f64,
132}
133
134pub 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 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 assert_eq!(col(0), "▄██▀", "{lines:#?}");
192 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 assert_eq!(axis_cols(0.5, 2.0), "2.000000".len());
264 }
265}