#![allow(clippy::cast_precision_loss)]
use starsight_layer_1::backends::DrawBackend;
use starsight_layer_1::errors::Result;
use starsight_layer_1::paths::{Path, PathStyle};
use starsight_layer_1::primitives::{Color, Point, Rect};
use starsight_layer_2::coords::{CartesianCoord, Coord};
use crate::marks::{DataExtent, LegendGlyph, Mark};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Ohlc {
pub timestamp: f64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
}
#[derive(Clone, Debug)]
pub struct CandlestickMark {
pub data: Vec<Ohlc>,
pub up_color: Color,
pub down_color: Color,
pub body_width: f32,
pub wick_width: f32,
pub label: Option<String>,
}
impl CandlestickMark {
#[must_use]
pub fn new(data: Vec<Ohlc>) -> Self {
Self {
data,
up_color: Color::from_hex(0x0026_A69A),
down_color: Color::from_hex(0x00EF_5350),
body_width: 0.7,
wick_width: 1.0,
label: None,
}
}
#[must_use]
pub fn up_color(mut self, c: Color) -> Self {
self.up_color = c;
self
}
#[must_use]
pub fn down_color(mut self, c: Color) -> Self {
self.down_color = c;
self
}
#[must_use]
pub fn body_width(mut self, w: f32) -> Self {
self.body_width = w;
self
}
#[must_use]
pub fn wick_width(mut self, w: f32) -> Self {
self.wick_width = w;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
fn body_color(&self, row: &Ohlc) -> Color {
if row.close >= row.open {
self.up_color
} else {
self.down_color
}
}
fn half_body_px(&self, coord: &CartesianCoord) -> f32 {
let area = &coord.plot_area;
let n = self.data.len();
if n < 2 {
return area.width() * 0.05 * self.body_width.clamp(0.05, 0.98);
}
let mut spacings: Vec<f64> = self
.data
.windows(2)
.map(|w| (w[1].timestamp - w[0].timestamp).abs())
.filter(|s| *s > 0.0)
.collect();
if spacings.is_empty() {
return area.width() * 0.05 * self.body_width.clamp(0.05, 0.98);
}
spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = spacings[spacings.len() / 2];
let span = (coord.x_axis.scale.map(median) - coord.x_axis.scale.map(0.0)).abs();
let band = (span as f32) * area.width();
band * 0.5 * self.body_width.clamp(0.05, 0.98)
}
}
impl Mark for CandlestickMark {
fn render(&self, coord: &dyn Coord, backend: &mut dyn DrawBackend) -> Result<()> {
let coord = crate::marks::require_cartesian(coord)?;
if self.data.is_empty() {
return Ok(());
}
let area = &coord.plot_area;
let to_x = |t: f64| -> f32 { area.left + coord.x_axis.scale.map(t) as f32 * area.width() };
let to_y =
|v: f64| -> f32 { area.bottom - coord.y_axis.scale.map(v) as f32 * area.height() };
let half = self.half_body_px(coord);
for row in &self.data {
let x_px = to_x(row.timestamp);
let open_px = to_y(row.open);
let close_px = to_y(row.close);
let high_px = to_y(row.high);
let low_px = to_y(row.low);
let color = self.body_color(row);
let body_top = open_px.min(close_px);
let body_bottom = open_px.max(close_px);
let mut body = Rect::new(x_px - half, body_top, x_px + half, body_bottom);
if (body.bottom - body.top).abs() < 1.0 {
body.top -= 0.5;
body.bottom += 0.5;
}
backend.fill_rect(body, color)?;
let upper = Path::new()
.move_to(Point::new(x_px, body.top))
.line_to(Point::new(x_px, high_px));
backend.draw_path(&upper, &PathStyle::stroke(color, self.wick_width))?;
let lower = Path::new()
.move_to(Point::new(x_px, body.bottom))
.line_to(Point::new(x_px, low_px));
backend.draw_path(&lower, &PathStyle::stroke(color, self.wick_width))?;
}
Ok(())
}
fn data_extent(&self) -> Option<DataExtent> {
if self.data.is_empty() {
return None;
}
let mut x_min = f64::INFINITY;
let mut x_max = f64::NEG_INFINITY;
let mut y_min = f64::INFINITY;
let mut y_max = f64::NEG_INFINITY;
for row in &self.data {
if row.timestamp < x_min {
x_min = row.timestamp;
}
if row.timestamp > x_max {
x_max = row.timestamp;
}
if row.low < y_min {
y_min = row.low;
}
if row.high > y_max {
y_max = row.high;
}
}
let half_band = if self.data.len() < 2 {
0.5
} else {
let mut spacings: Vec<f64> = self
.data
.windows(2)
.map(|w| (w[1].timestamp - w[0].timestamp).abs())
.filter(|s| *s > 0.0)
.collect();
if spacings.is_empty() {
0.5
} else {
spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
spacings[spacings.len() / 2] * 0.5
}
};
Some(DataExtent {
x_min: x_min - half_band,
x_max: x_max + half_band,
y_min,
y_max,
})
}
fn legend_color(&self) -> Option<Color> {
self.label.as_ref()?;
Some(self.up_color)
}
fn legend_label(&self) -> Option<&str> {
self.label.as_deref()
}
fn legend_glyph(&self) -> LegendGlyph {
LegendGlyph::Bar
}
}
#[cfg(test)]
mod tests {
use super::{CandlestickMark, Ohlc};
use crate::marks::{LegendGlyph, Mark};
use starsight_layer_1::primitives::Color;
fn sample() -> Vec<Ohlc> {
vec![
Ohlc {
timestamp: 0.0,
open: 100.0,
high: 110.0,
low: 95.0,
close: 105.0,
},
Ohlc {
timestamp: 1.0,
open: 105.0,
high: 115.0,
low: 100.0,
close: 98.0,
},
Ohlc {
timestamp: 2.0,
open: 98.0,
high: 108.0,
low: 90.0,
close: 107.0,
},
]
}
#[test]
fn data_extent_covers_low_high_per_row() {
let mark = CandlestickMark::new(sample());
let extent = mark.data_extent().expect("non-empty extent");
assert!((extent.x_min - (-0.5)).abs() < 1e-9);
assert!((extent.x_max - 2.5).abs() < 1e-9);
assert_eq!(extent.y_min, 90.0);
assert_eq!(extent.y_max, 115.0);
}
#[test]
fn data_extent_single_row_widens_by_half() {
let mark = CandlestickMark::new(vec![Ohlc {
timestamp: 10.0,
open: 1.0,
high: 2.0,
low: 0.5,
close: 1.5,
}]);
let extent = mark.data_extent().expect("non-empty extent");
assert!((extent.x_min - 9.5).abs() < 1e-9);
assert!((extent.x_max - 10.5).abs() < 1e-9);
}
#[test]
fn empty_has_no_extent() {
let mark = CandlestickMark::new(vec![]);
assert!(mark.data_extent().is_none());
}
#[test]
fn body_color_picks_up_for_close_ge_open() {
let mark = CandlestickMark::new(sample());
let up_row = Ohlc {
timestamp: 0.0,
open: 100.0,
high: 110.0,
low: 95.0,
close: 110.0,
};
let down_row = Ohlc {
timestamp: 0.0,
open: 110.0,
high: 115.0,
low: 100.0,
close: 100.0,
};
assert_eq!(mark.body_color(&up_row), mark.up_color);
assert_eq!(mark.body_color(&down_row), mark.down_color);
}
#[test]
fn legend_glyph_is_bar_and_color_uses_up_color() {
let mark = CandlestickMark::new(sample())
.up_color(Color::GREEN)
.label("ticker");
assert_eq!(mark.legend_glyph(), LegendGlyph::Bar);
assert_eq!(mark.legend_color(), Some(Color::GREEN));
}
#[test]
fn no_legend_when_unlabeled() {
let mark = CandlestickMark::new(sample());
assert!(mark.legend_color().is_none());
}
}