use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::core::{Color, HorizontalAlignment, Rect};
use crate::event::EventHandler;
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::role_font;
use crate::widget::special_widgets::finance::indicators;
use crate::widget::special_widgets::finance::layout::{
panel_colors, PanelColors, PlotArea, PANEL_MIN_CONTRAST,
};
use crate::widget::special_widgets::finance::types::{Bar, PriceLevelKind, PriceLine, PriceSeries};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Overlay {
MovingAverage {
period: usize,
},
ExponentialMovingAverage {
period: usize,
},
BollingerBands {
period: usize,
multiplier: f64,
},
Vwap {
period: usize,
},
DonchianChannel {
period: usize,
},
}
impl Overlay {
pub fn moving_average(period: usize) -> Self {
Overlay::MovingAverage { period }
}
pub fn exponential_moving_average(period: usize) -> Self {
Overlay::ExponentialMovingAverage { period }
}
pub fn bollinger_bands(period: usize, multiplier: f64) -> Self {
Overlay::BollingerBands { period, multiplier }
}
pub fn vwap(period: usize) -> Self {
Overlay::Vwap { period }
}
pub fn donchian_channel(period: usize) -> Self {
Overlay::DonchianChannel { period }
}
pub fn warm_up_bars(self) -> usize {
match self {
Overlay::MovingAverage { period } => period,
Overlay::ExponentialMovingAverage { period } => period,
Overlay::BollingerBands { period, .. } => period,
Overlay::Vwap { period } => period,
Overlay::DonchianChannel { period } => period,
}
}
pub fn as_str(self) -> &'static str {
match self {
Overlay::MovingAverage { .. } => "moving_average",
Overlay::ExponentialMovingAverage { .. } => "exponential_moving_average",
Overlay::BollingerBands { .. } => "bollinger_bands",
Overlay::Vwap { .. } => "vwap",
Overlay::DonchianChannel { .. } => "donchian_channel",
}
}
}
enum CachedOverlay {
Single(Vec<f64>),
Triple(Vec<f64>, Vec<f64>, Vec<f64>),
}
pub struct CandlestickChart {
base: BaseWidget,
series: PriceSeries,
overlays: Vec<Overlay>,
price_lines: Vec<PriceLine>,
closes: Vec<f64>,
highs: Vec<f64>,
lows: Vec<f64>,
volumes: Vec<f64>,
overlay_values: Vec<CachedOverlay>,
overlay_visible: Vec<bool>,
inferred_decimals: usize,
extent_cache: (f64, f64),
show_overlay_legend: bool,
hovered_index: Option<usize>,
hovered_price: Option<f64>,
view: Option<(usize, usize)>,
pub bar_clicked: Signal1<usize>,
pub bar_hovered: Signal1<usize>,
pub bar_unhovered: Signal1<usize>,
pub view_changed: Signal1<(usize, usize)>,
}
impl CandlestickChart {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::CandlestickChart, geometry, "CandlestickChart"),
series: PriceSeries::new(),
overlays: Vec::new(),
price_lines: Vec::new(),
closes: Vec::new(),
highs: Vec::new(),
lows: Vec::new(),
volumes: Vec::new(),
overlay_values: Vec::new(),
overlay_visible: Vec::new(),
inferred_decimals: 2,
extent_cache: (0.0, 1.0),
show_overlay_legend: true,
hovered_index: None,
hovered_price: None,
view: None,
bar_clicked: Signal1::new(),
bar_hovered: Signal1::new(),
bar_unhovered: Signal1::new(),
view_changed: Signal1::new(),
}
}
pub fn series(&self) -> &PriceSeries {
&self.series
}
pub fn set_series(&mut self, series: PriceSeries) {
self.series = series;
if self.hovered_index.is_some_and(|index| index >= self.series.len()) {
self.hovered_index = None;
}
self.rebuild_series_cache();
self.base.request_redraw();
}
fn rebuild_series_cache(&mut self) {
self.closes = self.series.closes();
self.highs = self.series.highs();
self.lows = self.series.lows();
self.volumes = self.series.volumes();
self.inferred_decimals = Self::infer_decimals(self.series.bars());
self.rebuild_overlay_cache();
}
fn rebuild_overlay_cache(&mut self) {
self.overlay_visible.resize(self.overlays.len(), true);
self.overlay_values.clear();
self.overlay_values.reserve(self.overlays.len());
for overlay in &self.overlays {
let cached = match *overlay {
Overlay::MovingAverage { period } => {
CachedOverlay::Single(indicators::sma(&self.closes, period))
}
Overlay::ExponentialMovingAverage { period } => {
CachedOverlay::Single(indicators::ema(&self.closes, period))
}
Overlay::BollingerBands { period, multiplier } => {
let (lower, middle, upper) =
indicators::bollinger_bands(&self.closes, period, multiplier);
CachedOverlay::Triple(upper, middle, lower)
}
Overlay::Vwap { period } => CachedOverlay::Single(indicators::vwap(
&self.highs,
&self.lows,
&self.closes,
&self.volumes,
period,
)),
Overlay::DonchianChannel { period } => {
let (lower, middle, upper) =
indicators::donchian_channel(&self.highs, &self.lows, period);
CachedOverlay::Triple(upper, middle, lower)
}
};
self.overlay_values.push(cached);
}
self.rebuild_extent();
}
fn rebuild_extent(&mut self) {
let (mut low, mut high) = self.series.price_extent();
if !low.is_finite() || !high.is_finite() {
self.extent_cache = (0.0, 1.0);
return;
}
let mut consider = |values: &[f64]| {
let (value_low, value_high) = indicators::series_extent(values);
if value_low.is_finite() {
low = low.min(value_low);
}
if value_high.is_finite() {
high = high.max(value_high);
}
};
for (overlay_index, cached) in self.overlay_values.iter().enumerate() {
if !self.overlay_visible.get(overlay_index).copied().unwrap_or(true) {
continue;
}
match cached {
CachedOverlay::Single(values) => consider(values),
CachedOverlay::Triple(first, second, third) => {
consider(first);
consider(second);
consider(third);
}
}
}
for line in &self.price_lines {
if line.price.is_finite() {
low = low.min(line.price);
high = high.max(line.price);
}
}
let span = high - low;
let pad = if span > 0.0 { span * 0.02 } else { high.abs().max(1.0) * 0.01 };
self.extent_cache = (low - pad, high + pad);
}
fn infer_decimals(bars: &[Bar]) -> usize {
for bar in bars {
for value in [bar.open, bar.high, bar.low, bar.close] {
if !value.is_finite() {
continue;
}
for decimals in 0..=4usize {
let scaled = value * 10f64.powi(decimals as i32);
if (scaled - scaled.round()).abs() < 1e-6 {
return decimals;
}
}
}
}
2
}
pub fn overlays(&self) -> &[Overlay] {
&self.overlays
}
pub fn update_last_bar(&mut self, bar: Bar) -> bool {
let Some(index) = self.series.len().checked_sub(1) else { return false };
if !self.series.set_bar(index, bar) {
return false;
}
if let Some(slot) = self.closes.get_mut(index) {
*slot = bar.close;
}
if let Some(slot) = self.highs.get_mut(index) {
*slot = bar.high;
}
if let Some(slot) = self.lows.get_mut(index) {
*slot = bar.low;
}
if let Some(slot) = self.volumes.get_mut(index) {
*slot = bar.volume;
}
self.patch_overlay_tail(index);
self.grow_extent_with_bar(bar);
self.base.request_redraw();
true
}
pub fn append_bar(&mut self, bar: Bar) {
self.series.push(bar);
self.closes.push(bar.close);
self.highs.push(bar.high);
self.lows.push(bar.low);
self.volumes.push(bar.volume);
let index = self.series.len().saturating_sub(1);
self.append_overlay_tail(index);
self.grow_extent_with_bar(bar);
self.base.request_redraw();
}
fn grow_extent_with_bar(&mut self, bar: Bar) {
let (mut low, mut high) = self.extent_cache;
let pad = (high - low).abs().max(1e-9) * 0.02;
for value in [bar.low, bar.high, bar.open, bar.close] {
if value.is_finite() {
low = low.min(value - pad);
high = high.max(value + pad);
}
}
self.extent_cache = (low, high);
}
fn patch_overlay_tail(&mut self, index: usize) {
for (overlay_index, overlay) in self.overlays.iter().enumerate() {
let Some(cached) = self.overlay_values.get_mut(overlay_index) else { continue };
patch_overlay_at(
cached,
*overlay,
index,
&self.closes,
&self.highs,
&self.lows,
&self.volumes,
);
}
}
fn append_overlay_tail(&mut self, index: usize) {
for (overlay_index, overlay) in self.overlays.iter().enumerate() {
let Some(cached) = self.overlay_values.get_mut(overlay_index) else { continue };
append_overlay_at(
cached,
*overlay,
index,
&self.closes,
&self.highs,
&self.lows,
&self.volumes,
);
}
}
pub fn add_overlay(&mut self, overlay: Overlay) {
self.overlays.push(overlay);
self.overlay_visible.push(true);
self.rebuild_overlay_cache();
self.base.request_redraw();
}
pub fn add_overlay_hidden(&mut self, overlay: Overlay) {
self.overlays.push(overlay);
self.overlay_visible.push(false);
self.rebuild_overlay_cache();
self.base.request_redraw();
}
pub fn remove_overlay(&mut self, index: usize) -> bool {
if index >= self.overlays.len() {
return false;
}
self.overlays.remove(index);
if index < self.overlay_visible.len() {
self.overlay_visible.remove(index);
}
self.rebuild_overlay_cache();
self.base.request_redraw();
true
}
pub fn set_overlay_visible(&mut self, index: usize, visible: bool) -> bool {
let Some(slot) = self.overlay_visible.get_mut(index) else { return false };
if *slot == visible {
return false;
}
*slot = visible;
self.rebuild_extent();
self.base.request_redraw();
true
}
pub fn overlay_visible(&self, index: usize) -> bool {
self.overlay_visible.get(index).copied().unwrap_or(false)
}
pub fn overlay_values(&self, index: usize) -> Option<Vec<&[f64]>> {
match self.overlay_values.get(index)? {
CachedOverlay::Single(values) => Some(alloc::vec![values.as_slice()]),
CachedOverlay::Triple(first, second, third) => {
Some(alloc::vec![first.as_slice(), second.as_slice(), third.as_slice()])
}
}
}
pub fn overlay_legend_entries(&self) -> Vec<(String, Color, bool)> {
self.overlays
.iter()
.enumerate()
.map(|(index, overlay)| {
(
overlay.as_str().to_string(),
Self::overlay_color(index),
self.overlay_visible(index),
)
})
.collect()
}
pub fn clear_overlays(&mut self) {
self.overlays.clear();
self.overlay_visible.clear();
self.rebuild_overlay_cache();
self.base.request_redraw();
}
pub fn show_overlay_legend(&self) -> bool {
self.show_overlay_legend
}
pub fn set_show_overlay_legend(&mut self, show: bool) {
if self.show_overlay_legend == show {
return;
}
self.show_overlay_legend = show;
self.base.request_redraw();
}
pub fn price_lines(&self) -> &[PriceLine] {
&self.price_lines
}
pub fn add_price_line(&mut self, line: PriceLine) {
self.price_lines.push(line);
self.rebuild_extent();
self.base.request_redraw();
}
pub fn clear_price_lines(&mut self) {
self.price_lines.clear();
self.rebuild_extent();
self.base.request_redraw();
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered_index
}
pub fn hovered_price(&self) -> Option<f64> {
self.hovered_price
}
pub fn view_range(&self) -> Option<(usize, usize)> {
self.view
}
pub fn set_view_range(&mut self, range: Option<(usize, usize)>) {
let range = range.map(|(start, end)| {
let start = start.min(self.series.len().saturating_sub(1));
let end = end.clamp(start + 1, self.series.len().max(start + 1));
(start, end)
});
if self.view == range {
return;
}
self.view = range;
let (start, end) = self.view_bounds();
self.view_changed.emit((start, end));
self.base.request_redraw();
}
pub fn reset_view(&mut self) {
self.set_view_range(None);
}
fn view_bounds(&self) -> (usize, usize) {
match self.view {
Some((start, end)) => (start.min(self.series.len()), end.min(self.series.len())),
None => (0, self.series.len()),
}
}
pub fn hovered_bar(&self) -> Option<Bar> {
self.hovered_index.and_then(|index| self.series.bars().get(index).copied())
}
pub fn hovered_summary(&self) -> Vec<crate::widget::companions::TooltipLine> {
use crate::widget::companions::TooltipLine;
let Some(index) = self.hovered_index else { return Vec::new() };
let Some(bar) = self.series.bars().get(index).copied() else { return Vec::new() };
let decimals = self.inferred_decimals;
let mut lines = Vec::with_capacity(7);
lines.push(TooltipLine::new(match self.series.label_at(index) {
Some(label) => label.to_string(),
None => alloc::format!("#{index}"),
}));
lines.push(TooltipLine::new(alloc::format!("O {}", format_fixed(bar.open, decimals))));
lines.push(TooltipLine::new(alloc::format!("H {}", format_fixed(bar.high, decimals))));
lines.push(TooltipLine::new(alloc::format!("L {}", format_fixed(bar.low, decimals))));
lines.push(TooltipLine::new(alloc::format!("C {}", format_fixed(bar.close, decimals))));
lines.push(TooltipLine::new(alloc::format!("V {:.0}", bar.volume)));
let change = bar.close - bar.open;
let color = if change >= 0.0 { Self::rising_color() } else { Self::falling_color() };
lines.push(TooltipLine::colored(
alloc::format!("Δ {}", format_fixed(change, decimals)),
color,
));
lines
}
fn plot_area(&self) -> PlotArea {
PlotArea::price_pane(self.base.geometry())
}
fn chrome(&self) -> PanelColors {
panel_colors(Some(self.base.style()))
}
fn rising_color() -> Color {
Color::rgb(38, 166, 91)
}
fn falling_color() -> Color {
Color::rgb(220, 68, 70)
}
fn overlay_color(index: usize) -> Color {
const PALETTE: [Color; 6] = [
Color::rgb(255, 193, 7),
Color::rgb(33, 150, 243),
Color::rgb(156, 39, 176),
Color::rgb(0, 188, 212),
Color::rgb(255, 87, 34),
Color::rgb(139, 195, 74),
];
PALETTE[index % PALETTE.len()]
}
fn draw_series_line(
context: &mut RenderContext,
values: &[f64],
price_axis: &crate::widget::special_widgets::finance::layout::PriceAxis,
index_axis: &crate::widget::special_widgets::finance::layout::IndexAxis,
color: Color,
width: u32,
start: usize,
end: usize,
) {
let mut previous: Option<(i32, i32)> = None;
let visible = &values[start.min(values.len())..end.min(values.len())];
for (offset, value) in visible.iter().enumerate() {
if !value.is_finite() || offset >= index_axis.count {
previous = None;
continue;
}
let point = (index_axis.center_for(offset), price_axis.y_for(*value));
if let Some(from) = previous {
context.draw_line_stroke(
crate::core::Point { x: from.0, y: from.1 },
crate::core::Point { x: point.0, y: point.1 },
color,
width,
);
}
previous = Some(point);
}
}
fn draw_candles(&self, context: &mut RenderContext, area: &PlotArea) {
let bars = self.series.bars();
if bars.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let (start, end) = self.view_bounds();
let visible = &bars[start..end];
let index_axis = area.index_axis(visible.len());
let body_width = index_axis.body_width();
let wick_x_offset = body_width / 2;
for (position, bar) in visible.iter().enumerate() {
let index = start + position;
let _ = index;
if !bar.open.is_finite()
|| !bar.high.is_finite()
|| !bar.low.is_finite()
|| !bar.close.is_finite()
{
continue;
}
let slot_x = index_axis.x_for(position);
let color = if bar.is_rising() { Self::rising_color() } else { Self::falling_color() };
let wick_x = slot_x + wick_x_offset;
context.draw_line_stroke(
crate::core::Point { x: wick_x, y: price_axis.y_for(bar.high) },
crate::core::Point { x: wick_x, y: price_axis.y_for(bar.low) },
color,
1,
);
let y_open = price_axis.y_for(bar.open);
let y_close = price_axis.y_for(bar.close);
let body_top = y_open.min(y_close);
context.fill_rect(
Rect::new(
slot_x,
body_top,
body_width as u32,
(y_open - y_close).unsigned_abs().max(1),
),
color,
);
}
}
fn draw_overlays(&self, context: &mut RenderContext, area: &PlotArea) {
if self.series.bars().is_empty() || self.overlays.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let (start, end) = self.view_bounds();
let index_axis = area.index_axis(end.saturating_sub(start));
for (overlay_index, cached) in self.overlay_values.iter().enumerate() {
let color = Self::overlay_color(overlay_index);
match cached {
CachedOverlay::Single(values) => Self::draw_series_line(
context,
values,
&price_axis,
&index_axis,
color,
1,
start,
end,
),
CachedOverlay::Triple(first, second, third) => {
Self::draw_series_line(
context,
first,
&price_axis,
&index_axis,
color,
1,
start,
end,
);
Self::draw_series_line(
context,
second,
&price_axis,
&index_axis,
color,
1,
start,
end,
);
Self::draw_series_line(
context,
third,
&price_axis,
&index_axis,
color,
1,
start,
end,
);
}
}
}
}
fn draw_price_lines(&self, context: &mut RenderContext, area: &PlotArea) {
if self.price_lines.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
for line in &self.price_lines {
if !line.price.is_finite() {
continue;
}
let y = price_axis.y_for(line.price);
if line.price < price_axis.low || line.price > price_axis.high {
continue;
}
let color = match line.kind {
PriceLevelKind::Support => Self::rising_color(),
PriceLevelKind::Resistance => Self::falling_color(),
PriceLevelKind::PreviousClose => self.chrome().crosshair,
};
let mut x = area.rect.x;
while x < area.right() {
let dash_end = (x + 4).min(area.right());
context.draw_line_stroke(
crate::core::Point { x, y },
crate::core::Point { x: dash_end, y },
color,
1,
);
x += 8;
}
}
}
fn draw_price_labels(&self, context: &mut RenderContext, area: &PlotArea) {
let decimals = self.inferred_decimals();
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let grid = self.chrome().grid;
let font = role_font(self.style(), 10.0);
let label_left = area.rect.x - area.left_margin;
let label_right = area.label_x();
let label_width = (label_right - label_left).max(0) as u32;
for step in 0..=3 {
let fraction = step as f64 / 3.0;
let price = price_axis.low + fraction * (price_axis.high - price_axis.low);
let y = price_axis.y_for(price);
let text = format_fixed(price, decimals);
context.draw_line(
crate::core::Point { x: area.rect.x, y },
crate::core::Point { x: area.right(), y },
grid,
);
let line_height = context.measure_text(&text, &font).height.max(1);
let band = Rect::new(label_left, y - line_height as i32 / 2, label_width, line_height);
context.draw_text_fitted_cow(
band,
&text,
&font,
self.chrome().ink.with_alpha(190),
HorizontalAlignment::Right,
);
}
}
fn draw_empty_state(&self, context: &mut RenderContext, area: &PlotArea) {
let chrome = self.chrome();
context.draw_rect(area.rect, chrome.grid);
let mid_y = area.rect.y + area.rect.height as i32 / 2;
context.draw_line(
crate::core::Point { x: area.rect.x, y: mid_y },
crate::core::Point { x: area.right(), y: mid_y },
chrome.grid,
);
let font = role_font(self.style(), 12.0);
let line = context.text_line(area.rect, &font);
context.draw_text_fitted_cow(
line,
"No data",
&font,
chrome.ink.legible_on(chrome.surface, PANEL_MIN_CONTRAST).with_alpha(160),
HorizontalAlignment::Center,
);
}
fn inferred_decimals(&self) -> usize {
self.inferred_decimals
}
fn effective_price_extent(&self) -> (f64, f64) {
self.extent_cache
}
fn draw_overlay_legend(&self, context: &mut RenderContext, area: &PlotArea) {
let font = role_font(self.style(), 10.0);
let chrome = self.chrome();
let rows: Vec<(&str, Color)> = self
.overlays
.iter()
.enumerate()
.map(|(index, overlay)| (overlay.as_str(), Self::overlay_color(index)))
.collect();
let max_rows = (((area.bottom() - area.rect.y - 4) / 14).max(1)) as usize;
let rows = &rows[..rows.len().min(max_rows)];
let widest = rows
.iter()
.map(|(name, _)| context.measure_text(name, &font).width as i32)
.max()
.unwrap_or(0);
let box_width = (widest + 24).min(area.rect.width as i32).max(1);
let box_x = (area.right() - box_width).max(area.rect.x);
let box_height = (rows.len() as u32) * 14 + 4;
context
.fill_rect(Rect::new(box_x, area.rect.y, box_width as u32, box_height), chrome.surface);
let mut y = area.rect.y + 4;
for (name, color) in rows {
let text_width = context.measure_text(name, &font).width as i32;
let x = area.right() - text_width - 20;
if x < area.rect.x {
break;
}
context.fill_rect(Rect::new(x, y + 3, 8, 8), *color);
context.draw_text(
crate::core::Point { x: x + 12, y },
name,
&font,
chrome.ink,
HorizontalAlignment::Left,
);
y += 14;
}
}
fn draw_crosshair(&self, context: &mut RenderContext, area: &PlotArea) {
let Some(index) = self.hovered_index else {
return;
};
let bars = self.series.bars();
if index >= bars.len() {
return;
}
let (start, end) = self.view_bounds();
if index < start || index >= end {
return;
}
let index_axis = area.index_axis(end.saturating_sub(start));
let x = index_axis.center_for(index - start);
let chrome = self.chrome();
context.draw_line_stroke(
crate::core::Point { x, y: area.rect.y },
crate::core::Point { x, y: area.bottom() },
chrome.crosshair,
1,
);
if let Some(price) = self.hovered_price {
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let y = price_axis.y_for(price);
if y > area.rect.y && y < area.bottom() {
context.draw_line_stroke(
crate::core::Point { x: area.rect.x, y },
crate::core::Point { x: area.right(), y },
chrome.crosshair,
1,
);
context.draw_text(
crate::core::Point { x: area.rect.x + 2, y: y + 2 },
&format_fixed(price, 2),
&role_font(self.style(), 10.0),
chrome.ink,
HorizontalAlignment::Left,
);
}
}
let bar = bars[index];
let text =
format!("O {:.2} H {:.2} L {:.2} C {:.2}", bar.open, bar.high, bar.low, bar.close);
let width = text.len() as i32 * 7;
let label_x = if x + 8 + width > area.right() { x - 8 - width } else { x + 8 };
context.push_clip(area.rect.x, area.rect.y, area.rect.width, area.rect.height);
context.draw_text(
crate::core::Point { x: label_x.max(area.rect.x), y: area.rect.y + 2 },
&text,
&role_font(self.style(), 11.0),
chrome.ink,
HorizontalAlignment::Left,
);
context.pop_clip();
}
}
fn window_mean(values: &[f64], index: usize, period: usize) -> f64 {
if period == 0 || period > index + 1 || index >= values.len() {
return f64::NAN;
}
let window = &values[index + 1 - period..=index];
if !window.iter().all(|value| value.is_finite()) {
return f64::NAN;
}
window.iter().sum::<f64>() / period as f64
}
fn window_deviation(values: &[f64], index: usize, period: usize) -> f64 {
if period == 0 || period > index + 1 || index >= values.len() {
return f64::NAN;
}
let window = &values[index + 1 - period..=index];
if !window.iter().all(|value| value.is_finite()) {
return f64::NAN;
}
let mean = window.iter().sum::<f64>() / period as f64;
let variance =
window.iter().map(|value| (value - mean) * (value - mean)).sum::<f64>() / period as f64;
variance.sqrt()
}
fn window_vwap(
highs: &[f64],
lows: &[f64],
closes: &[f64],
volumes: &[f64],
index: usize,
period: usize,
) -> f64 {
if period == 0 || period > index + 1 || index >= closes.len() {
return f64::NAN;
}
let start = index + 1 - period;
let mut weighted = 0.0;
let mut total = 0.0;
for offset in start..=index {
let (Some(high), Some(low), Some(close), Some(volume)) = (
highs.get(offset).copied(),
lows.get(offset).copied(),
closes.get(offset).copied(),
volumes.get(offset).copied(),
) else {
return f64::NAN;
};
if !high.is_finite() || !low.is_finite() || !close.is_finite() || !volume.is_finite() {
return f64::NAN;
}
weighted += (high + low + close) / 3.0 * volume;
total += volume;
}
if total == 0.0 {
f64::NAN
} else {
weighted / total
}
}
fn window_donchian(highs: &[f64], lows: &[f64], index: usize, period: usize) -> (f64, f64, f64) {
if period == 0 || period > index + 1 || index >= highs.len() || index >= lows.len() {
return (f64::NAN, f64::NAN, f64::NAN);
}
let start = index + 1 - period;
let high_window = &highs[start..=index];
let low_window = &lows[start..=index];
if !high_window.iter().all(|value| value.is_finite())
|| !low_window.iter().all(|value| value.is_finite())
{
return (f64::NAN, f64::NAN, f64::NAN);
}
let upper = high_window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lower = low_window.iter().copied().fold(f64::INFINITY, f64::min);
(upper, (upper + lower) / 2.0, lower)
}
fn overlay_single_value(
overlay: Overlay,
index: usize,
closes: &[f64],
highs: &[f64],
lows: &[f64],
volumes: &[f64],
previous: Option<f64>,
) -> f64 {
match overlay {
Overlay::MovingAverage { period } => window_mean(closes, index, period),
Overlay::ExponentialMovingAverage { period } => {
let close = closes.get(index).copied().unwrap_or(f64::NAN);
if period == 0 || !close.is_finite() {
return close;
}
match previous.filter(|value| value.is_finite()) {
Some(previous) => previous + (close - previous) * (2.0 / (period as f64 + 1.0)),
None => indicators::ema(closes, period).get(index).copied().unwrap_or(f64::NAN),
}
}
Overlay::Vwap { period } => window_vwap(highs, lows, closes, volumes, index, period),
Overlay::BollingerBands { .. } | Overlay::DonchianChannel { .. } => f64::NAN,
}
}
fn put_value(values: &mut Vec<f64>, index: usize, value: f64) {
if values.len() <= index {
values.resize(index + 1, f64::NAN);
}
values[index] = value;
}
fn patch_overlay_at(
cached: &mut CachedOverlay,
overlay: Overlay,
index: usize,
closes: &[f64],
highs: &[f64],
lows: &[f64],
volumes: &[f64],
) {
match (cached, overlay) {
(CachedOverlay::Single(values), _) => {
let previous = index.checked_sub(1).and_then(|slot| values.get(slot)).copied();
let value =
overlay_single_value(overlay, index, closes, highs, lows, volumes, previous);
put_value(values, index, value);
}
(
CachedOverlay::Triple(upper, middle, lower),
Overlay::BollingerBands { period, multiplier },
) => {
let mean = window_mean(closes, index, period);
let deviation = window_deviation(closes, index, period);
put_value(middle, index, mean);
put_value(upper, index, mean + multiplier * deviation);
put_value(lower, index, mean - multiplier * deviation);
}
(CachedOverlay::Triple(upper, middle, lower), Overlay::DonchianChannel { period }) => {
let (top, center, bottom) = window_donchian(highs, lows, index, period);
put_value(upper, index, top);
put_value(middle, index, center);
put_value(lower, index, bottom);
}
_ => {}
}
}
fn append_overlay_at(
cached: &mut CachedOverlay,
overlay: Overlay,
index: usize,
closes: &[f64],
highs: &[f64],
lows: &[f64],
volumes: &[f64],
) {
match (cached, overlay) {
(CachedOverlay::Single(values), _) => {
let previous = index.checked_sub(1).and_then(|slot| values.get(slot)).copied();
let value =
overlay_single_value(overlay, index, closes, highs, lows, volumes, previous);
values.push(value);
}
(
CachedOverlay::Triple(upper, middle, lower),
Overlay::BollingerBands { period, multiplier },
) => {
let mean = window_mean(closes, index, period);
let deviation = window_deviation(closes, index, period);
middle.push(mean);
upper.push(mean + multiplier * deviation);
lower.push(mean - multiplier * deviation);
}
(CachedOverlay::Triple(upper, middle, lower), Overlay::DonchianChannel { period }) => {
let (top, center, bottom) = window_donchian(highs, lows, index, period);
upper.push(top);
middle.push(center);
lower.push(bottom);
}
_ => {}
}
}
fn format_fixed(value: f64, decimals: usize) -> String {
if !value.is_finite() {
return "—".to_string();
}
let factor = 10f64.powi(decimals as i32);
let rounded = (value * factor).round() / factor;
let mut text = alloc::format!("{:.1}", rounded);
if decimals > 1 {
let extra = (rounded * factor).round() as i64;
let digits = extra.unsigned_abs().to_string();
let padded = if digits.len() <= decimals {
let mut padded = String::new();
for _ in 0..(decimals - digits.len()) {
padded.push('0');
}
padded.push_str(&digits);
padded
} else {
digits
};
let sign = if extra < 0 { "-" } else { "" };
text = alloc::format!(
"{sign}{}.{}",
&padded[..padded.len() - decimals],
&padded[padded.len() - decimals..]
);
}
text
}
impl Widget for CandlestickChart {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn as_draw_mut(&mut self) -> Option<&mut dyn Draw> {
Some(self)
}
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"bar_clicked" => Some(EventSignalRef::mapped("bar_clicked", &self.bar_clicked, |v| {
CapabilityValue::UInt(*v as u64)
})),
"bar_hovered" => Some(EventSignalRef::mapped("bar_hovered", &self.bar_hovered, |v| {
CapabilityValue::UInt(*v as u64)
})),
"bar_unhovered" => {
Some(EventSignalRef::mapped("bar_unhovered", &self.bar_unhovered, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
"view_changed" => {
Some(EventSignalRef::mapped("view_changed", &self.view_changed, |v| {
CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
]))
}))
}
_ => None,
}
}
}
impl EventHandler for CandlestickChart {
fn handle_event(&mut self, event: &crate::event::Event) {
use crate::event::Event;
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
let area = self.plot_area();
let (start, end) = self.view_bounds();
let index_axis = area.index_axis(end.saturating_sub(start));
let next = index_axis
.index_at(pos.x)
.map(|index| start + index)
.filter(|index| *index < end);
if next != self.hovered_index {
if let Some(previous) = self.hovered_index {
self.bar_unhovered.emit(previous);
}
if let Some(index) = next {
self.bar_hovered.emit(index);
}
self.hovered_index = next;
self.base.request_redraw();
}
let (low, high) = self.effective_price_extent();
let plot_height = (area.bottom() - area.rect.y).max(1);
let ratio = ((pos.y - area.rect.y) as f64 / plot_height as f64).clamp(0.0, 1.0);
let price = high - (high - low) * ratio;
if self.hovered_price != Some(price) {
self.hovered_price = Some(price);
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if let Some(previous) = self.hovered_index {
self.bar_unhovered.emit(previous);
self.hovered_index = None;
self.hovered_price = None;
self.base.request_redraw();
}
}
Event::Wheel { delta, .. } => {
let total = self.series.len();
if total > 4 && delta.y != 0 {
let (start, end) = self.view_bounds();
let anchor = self.hovered_index.unwrap_or((start + end) / 2);
let span = (end - start).max(4);
let factor = if delta.y > 0 { 1.25 } else { 0.8 };
let new_span = ((span as f64 * factor).round() as usize).clamp(4, total);
let half = new_span / 2;
let new_start = anchor.saturating_sub(half).min(total - new_span);
self.set_view_range(Some((new_start, new_start + new_span)));
}
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
let area = self.plot_area();
let (start, end) = self.view_bounds();
let index_axis = area.index_axis(end.saturating_sub(start));
if let Some(index) = index_axis
.index_at(pos.x)
.map(|index| start + index)
.filter(|index| *index < end)
{
self.bar_clicked.emit(index);
}
}
_ => {}
}
self.base.handle_event(event);
}
}
impl Draw for CandlestickChart {
fn draw(&mut self, context: &mut RenderContext) {
let area = self.plot_area();
if area.rect.width == 0 || area.rect.height == 0 {
return;
}
let chrome = self.chrome();
context.fill_rect(area.rect, chrome.surface);
if self.series.bars().is_empty() {
self.draw_empty_state(context, &area);
return;
}
self.draw_price_labels(context, &area);
self.draw_candles(context, &area);
let (start, end) = self.view_bounds();
super::volume_chart::draw_index_ticks(
context,
&area,
start,
end,
|index| match self.series.label_at(index) {
Some(label) => label.to_string(),
None => alloc::format!("{index}"),
},
&role_font(self.style(), 10.0),
chrome.ink.with_alpha(190),
);
self.draw_overlays(context, &area);
self.draw_price_lines(context, &area);
if self.show_overlay_legend && !self.overlays.is_empty() {
self.draw_overlay_legend(context, &area);
}
self.draw_crosshair(context, &area);
}
}
impl WidgetProperties for CandlestickChart {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"series" => Ok(CapabilityValue::String(serialise_closes(&self.series))),
"overlay_count" => Ok(CapabilityValue::UInt(self.overlays.len() as u64)),
"show_price_levels" => Ok(CapabilityValue::Bool(!self.price_lines.is_empty())),
"show_overlay_legend" => Ok(CapabilityValue::Bool(self.show_overlay_legend)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"series" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
let mut series = PriceSeries::new();
for token in text.split([',', ' ', ';']).filter(|token| !token.is_empty()) {
let price: f64 =
token.trim().parse().map_err(|_| CapabilityAccessError::OutOfRange)?;
series.push(Bar::flat(price, 0.0));
}
self.set_series(series);
Ok(())
}
"overlay_count" => {
let CapabilityValue::UInt(count) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.overlays.truncate(count as usize);
while self.overlays.len() < count as usize {
self.overlays.push(Overlay::moving_average(20));
}
self.rebuild_overlay_cache();
self.base.request_redraw();
Ok(())
}
"show_price_levels" => {
let CapabilityValue::Bool(show) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
if !show {
self.clear_price_lines();
}
Ok(())
}
"show_overlay_legend" => {
let CapabilityValue::Bool(show) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.show_overlay_legend = show;
self.base.request_redraw();
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"series",
"overlay_count",
"show_price_levels",
"show_overlay_legend",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"add_overlay" => {
self.add_overlay(Overlay::moving_average(20));
Ok(())
}
"set_series" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
fn serialise_closes(series: &PriceSeries) -> alloc::string::String {
let mut text = alloc::string::String::new();
for (index, bar) in series.bars().iter().enumerate() {
if index > 0 {
text.push(',');
}
text.push_str(&alloc::format!("{}", bar.close));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::special_widgets::finance::types::fixtures;
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn a_chart_reports_the_series_it_was_given() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
assert!(chart.series().is_empty());
chart.set_series(fixtures::sample_series(40));
assert_eq!(chart.series().len(), 40);
}
#[test]
fn overlays_accumulate_and_clear() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.add_overlay(Overlay::moving_average(20));
chart.add_overlay(Overlay::exponential_moving_average(9));
assert_eq!(chart.overlays().len(), 2);
assert_eq!(chart.overlays()[0], Overlay::MovingAverage { period: 20 });
chart.clear_overlays();
assert!(chart.overlays().is_empty());
}
#[test]
fn every_overlay_reports_its_warm_up() {
for overlay in [
Overlay::moving_average(20),
Overlay::exponential_moving_average(9),
Overlay::bollinger_bands(20, 2.0),
Overlay::vwap(14),
Overlay::donchian_channel(20),
] {
assert!(overlay.warm_up_bars() > 0, "{overlay:?} must report a warm-up");
assert!(!overlay.as_str().is_empty());
}
}
#[test]
fn overlay_names_are_stable() {
for overlay in [
Overlay::moving_average(1),
Overlay::exponential_moving_average(1),
Overlay::bollinger_bands(1, 1.0),
Overlay::vwap(1),
Overlay::donchian_channel(1),
] {
let name = overlay.as_str();
assert!(!name.is_empty(), "every overlay needs a factory spelling");
}
}
#[test]
fn price_lines_accumulate_and_clear() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.add_price_line(PriceLine::new(95.0, PriceLevelKind::Support));
chart.add_price_line(PriceLine::new(120.0, PriceLevelKind::Resistance));
assert_eq!(chart.price_lines().len(), 2);
chart.clear_price_lines();
assert!(chart.price_lines().is_empty());
}
#[test]
fn replacing_the_series_drops_a_stale_hover() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(50));
chart.set_geometry(Rect::new(0, 0, 640, 360));
chart.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 640 - 4, y: 100 },
});
chart.set_series(fixtures::sample_series(3));
assert!(
chart.hovered_index().is_none_or(|index| index < 3),
"a hover must not survive past the series it pointed into"
);
}
#[test]
fn an_empty_series_draws_without_panicking() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.add_overlay(Overlay::moving_average(20));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn a_malformed_series_draws_without_panicking() {
let mut series = PriceSeries::new();
series.push(Bar::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN));
series.push(Bar::new(10.0, 5.0, 20.0, 12.0, 100.0));
series.push(Bar::flat(10.0, 0.0));
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.set_series(series);
chart.add_overlay(Overlay::bollinger_bands(20, 2.0));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn a_flat_series_draws_without_panicking() {
let mut series = PriceSeries::new();
for _ in 0..10 {
series.push(Bar::flat(50.0, 10.0));
}
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.set_series(series);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn every_overlay_kind_draws() {
for overlay in [
Overlay::moving_average(10),
Overlay::exponential_moving_average(10),
Overlay::bollinger_bands(10, 2.0),
Overlay::vwap(10),
Overlay::donchian_channel(10),
] {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 480, 320));
chart.set_series(fixtures::sample_series(60));
chart.add_overlay(overlay);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(480, 320), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
}
#[test]
fn a_view_range_reports_and_maps_hits_to_global_indices() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 300, 200));
let mut series = PriceSeries::new();
for index in 0..10 {
let price = 10.0 + index as f64;
series.push(Bar::flat(price, 1.0));
}
chart.set_series(series);
chart.add_overlay(Overlay::moving_average(2));
chart.add_overlay(Overlay::bollinger_bands(3, 2.0));
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(usize, usize)>::new()));
let sink = std::sync::Arc::clone(&seen);
chart.view_changed.connect(move |range| sink.lock().unwrap().push(*range));
let clicked = std::sync::Arc::new(std::sync::Mutex::new(Vec::<usize>::new()));
let click_sink = std::sync::Arc::clone(&clicked);
chart.bar_clicked.connect(move |index| click_sink.lock().unwrap().push(*index));
chart.set_view_range(Some((4, 7)));
assert_eq!(*seen.lock().unwrap(), vec![(4, 7)]);
let area = chart.plot_area();
chart.handle_event(&crate::event::Event::mouse_press(area.rect.x + 1, area.rect.y + 5, 1));
assert_eq!(*clicked.lock().unwrap(), vec![4], "the left edge is the first visible bar");
assert!(chart.show_overlay_legend(), "the legend is on by default when overlays exist");
}
#[test]
fn clicking_a_bar_emits_its_index() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(20));
let seen = Arc::new(AtomicUsize::new(usize::MAX));
let sink = Arc::clone(&seen);
chart.bar_clicked.connect(move |index| sink.store(*index, Ordering::SeqCst));
chart.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 70, y: 100 },
button: 0,
modifiers: 0,
});
let index = seen.load(Ordering::SeqCst);
assert_ne!(index, usize::MAX, "a press inside the plot must emit a bar index");
assert!(index < 20, "and the index must be within the series");
}
#[test]
fn clicking_outside_the_plot_emits_nothing() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(20));
let count = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&count);
chart.bar_clicked.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
chart.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 5, y: 100 },
button: 0,
modifiers: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 0, "a press on the price axis is not on a bar");
}
#[test]
fn hovering_emits_and_exiting_un_hovers() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(20));
let hovered = Arc::new(AtomicUsize::new(usize::MAX));
let unhovered = Arc::new(AtomicUsize::new(usize::MAX));
let hovered_sink = Arc::clone(&hovered);
let unhovered_sink = Arc::clone(&unhovered);
chart.bar_hovered.connect(move |index| hovered_sink.store(*index, Ordering::SeqCst));
chart.bar_unhovered.connect(move |index| unhovered_sink.store(*index, Ordering::SeqCst));
chart.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 70, y: 100 },
});
let hovered_index = hovered.load(Ordering::SeqCst);
assert_ne!(hovered_index, usize::MAX, "moving over a bar emits it");
chart.handle_event(&crate::event::Event::MouseLeave {
pos: crate::core::Point { x: 0, y: 0 },
});
assert_eq!(
unhovered.load(Ordering::SeqCst),
hovered_index,
"leaving reports the bar that was hovered"
);
assert!(chart.hovered_index().is_none());
}
}