use scope::display::format::price_decimals;
use scope::display::theme::{Painter, Role};
use scope::market::Candle;
pub const CHART_ROWS: usize = 12;
pub const MIN_COLUMNS: usize = 10;
pub fn columns_for(width: usize, axis_cols: usize) -> usize {
width.saturating_sub(2 + axis_cols + 2).max(MIN_COLUMNS)
}
fn axis_decimals(low: f64, high: f64) -> usize {
price_decimals(low).max(price_decimals(high))
}
pub fn axis_cols(low: f64, high: f64) -> usize {
let d = axis_decimals(low, high);
format!("{low:.d$}").len().max(format!("{high:.d$}").len())
}
pub fn chart(
candles: &[Candle],
rows: usize,
painter: Painter,
time: impl Fn(u64) -> String,
) -> Vec<String> {
if candles.is_empty() || rows == 0 {
return Vec::new();
}
let low = candles.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
let high = candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let span = (high - low).max(f64::EPSILON);
let steps = rows * 2;
let step = |v: f64| (((v - low) / span) * (steps - 1) as f64).round() as usize;
let label_cols = axis_cols(low, high);
let decimals = axis_decimals(low, high);
let mut lines = Vec::with_capacity(rows + 1);
for r in 0..rows {
let (top, bottom) = ((rows - 1 - r) * 2 + 1, (rows - 1 - r) * 2);
let price = high - span * r as f64 / (rows - 1).max(1) as f64;
let labelled = r == 0 || r == rows - 1 || (r % 4 == 0 && r + 2 < rows);
let axis = if labelled {
format!("{price:>label_cols$.decimals$} ┤")
} else {
format!("{:>label_cols$} │", "")
};
let mut line = painter.paint(Role::Secondary, &axis);
for c in candles {
let (wick_hi, wick_lo) = (step(c.high), step(c.low));
let (body_hi, body_lo) = (step(c.open.max(c.close)), step(c.open.min(c.close)));
let body = |s: usize| body_lo <= s && s <= body_hi;
let wick = |s: usize| wick_lo <= s && s <= wick_hi;
let glyph = match (body(top), body(bottom)) {
(true, true) => "█",
(true, false) => "▀",
(false, true) => "▄",
(false, false) => match (wick(top), wick(bottom)) {
(true, true) => "│",
(true, false) => "╵",
(false, true) => "╷",
(false, false) => " ",
},
};
if glyph == " " {
line.push(' ');
} else {
let role = if c.close >= c.open {
Role::Ok
} else {
Role::Err
};
line.push_str(&painter.paint(role, glyph));
}
}
lines.push(line);
}
lines.push(painter.paint(Role::Secondary, &time_axis(candles, label_cols + 2, &time)));
lines
}
fn time_axis(candles: &[Candle], indent: usize, time: &impl Fn(u64) -> String) -> String {
let first = time(candles[0].open_time);
let last = time(candles[candles.len() - 1].open_time);
let width = candles.len();
let mut axis = format!("{:indent$}", "");
if first.chars().count() + 2 + last.chars().count() <= width {
let gap = width - first.chars().count() - last.chars().count();
axis.push_str(&format!("{first}{:gap$}{last}", ""));
} else if last.chars().count() <= width {
axis.push_str(&format!("{last:>width$}"));
}
axis
}
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub change_pct: Option<f64>,
pub volume: f64,
}
pub fn summary(candles: &[Candle]) -> Option<Summary> {
let (first, last) = (candles.first()?, candles.last()?);
Some(Summary {
open: first.open,
high: candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max),
low: candles.iter().map(|c| c.low).fold(f64::INFINITY, f64::min),
close: last.close,
change_pct: (first.open != 0.0).then(|| (last.close - first.open) / first.open * 100.0),
volume: candles.iter().map(|c| c.volume).sum::<f64>() + 0.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn candle(t: u64, open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle {
open_time: t,
open,
high,
low,
close,
volume: 10.0,
close_time: t + 59,
}
}
fn plain(lines: &[String]) -> Vec<String> {
lines.to_vec()
}
fn time(ms: u64) -> String {
format!("t{ms}")
}
#[test]
fn an_up_and_a_down_candle_have_bodies_and_wicks_in_their_columns() {
let candles = [
candle(1, 102.0, 110.0, 100.0, 108.0),
candle(2, 108.0, 109.0, 101.0, 103.0),
];
let lines = plain(&chart(&candles, 4, Painter::new(false), time));
assert_eq!(lines.len(), 5, "{lines:#?}");
let col = |i: usize| -> String {
lines[..4]
.iter()
.map(|l| l.chars().rev().nth(1 - i).unwrap())
.collect()
};
assert_eq!(col(0), "▄██▀", "{lines:#?}");
assert_eq!(col(1), "▄██╵", "{lines:#?}");
assert!(lines[0].starts_with("110.0000 ┤"), "{lines:#?}");
assert!(lines[3].starts_with("100.0000 ┤"), "{lines:#?}");
}
#[test]
fn colour_follows_the_close_against_the_open() {
let candles = [candle(1, 1.0, 2.0, 0.5, 1.5), candle(2, 1.5, 2.0, 0.5, 1.0)];
let lines = chart(&candles, 3, Painter::new(true), time);
let painted = |role: Role, glyph: &str| Painter::new(true).paint(role, glyph);
assert!(lines.iter().any(|l| l.contains(&painted(Role::Ok, "█"))));
assert!(lines.iter().any(|l| l.contains(&painted(Role::Err, "█"))));
}
#[test]
fn a_flat_candle_still_shows_and_a_flat_window_does_not_divide_by_zero() {
let candles = vec![candle(1, 1.0, 1.0, 1.0, 1.0); 3];
let lines = chart(&candles, 4, Painter::new(false), time);
let marks: usize = lines[..4]
.iter()
.map(|l| l.chars().filter(|c| "▀▄█".contains(*c)).count())
.sum();
assert_eq!(marks, 3, "{lines:#?}");
assert!(chart(&[], 4, Painter::new(false), time).is_empty());
}
#[test]
fn the_time_axis_spans_the_candles_or_keeps_only_the_last() {
let candles: Vec<Candle> = (0..20).map(|i| candle(i, 1.0, 2.0, 0.5, 1.5)).collect();
let lines = chart(&candles, 2, Painter::new(false), |ms| format!("{ms:>02}h"));
let axis = lines.last().unwrap();
assert!(axis.trim_start().starts_with("00h"), "{axis}");
assert!(axis.ends_with("19h"), "{axis}");
assert_eq!(axis.chars().count(), lines[0].chars().count(), "{axis}");
let narrow = chart(&candles[..6], 2, Painter::new(false), |_| {
"10-26 08:00".into()
});
assert!(
narrow.last().unwrap().trim().is_empty(),
"too wide for both"
);
}
#[test]
fn the_summary_spans_the_window() {
let candles = [
candle(1, 100.0, 105.0, 99.0, 104.0),
candle(2, 104.0, 112.0, 101.0, 110.0),
];
let s = summary(&candles).unwrap();
assert_eq!(
(s.open, s.high, s.low, s.close, s.volume),
(100.0, 112.0, 99.0, 110.0, 20.0)
);
assert!((s.change_pct.unwrap() - 10.0).abs() < 1e-9);
assert!(summary(&[]).is_none());
assert_eq!(
summary(&[candle(1, 0.0, 1.0, 0.0, 1.0)])
.unwrap()
.change_pct,
None
);
}
#[test]
fn columns_fit_the_width_but_never_fall_below_the_minimum() {
assert_eq!(columns_for(100, 8), 100 - 2 - 8 - 2);
assert_eq!(columns_for(12, 8), MIN_COLUMNS);
assert_eq!(axis_cols(0.9999, 1.0001), "1.000100".len());
assert_eq!(axis_cols(0.5, 2.0), "2.000000".len());
}
}