444 lines
15 KiB
Rust
444 lines
15 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Refineable,
|
|
RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
|
|
};
|
|
use jiff::tz::TimeZone;
|
|
|
|
use super::{
|
|
candles::{Candle, heikin_ashi, profile},
|
|
draw::{Pen, Scene, drawing},
|
|
labels::{Axes, labels},
|
|
quotes::moves,
|
|
series::{Drawn, Overlay, Study, overlay, study},
|
|
stage::{Visible, fit},
|
|
steer::{OnDraw, Stage, Steering},
|
|
tools::{Drawing, Tool},
|
|
};
|
|
use crate::{
|
|
charts::{Linear, Rect, measure},
|
|
theme::ActiveTheme,
|
|
typography::format::system_zone,
|
|
};
|
|
|
|
/// How a market chart draws its prices.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum ChartKind {
|
|
#[default]
|
|
Candles,
|
|
/// Open, high, low and close as bars with ticks.
|
|
Bars,
|
|
/// Candles smoothed by averaging, which hold a trend's color longer.
|
|
HeikinAshi,
|
|
Line,
|
|
Area,
|
|
}
|
|
|
|
/// What market charts that move together share: the candles in view and the one under the pointer. Create one entity and hand it to each chart.
|
|
#[derive(Default)]
|
|
pub struct ChartSync {
|
|
pub(crate) visible: Option<Visible>,
|
|
pub(crate) hover: Option<i64>,
|
|
/// The most candles the shared window has moved for: an append moves it once, a lagging chart never.
|
|
pub(crate) seen: usize,
|
|
}
|
|
|
|
/// A market's prices over time as candles, bars or a line, on a price scale at the right. Drag or scroll sideways to move through time, hold Cmd and scroll to zoom, and double-press to return to the newest; the crosshair reads each candle.
|
|
#[derive(IntoElement)]
|
|
pub struct CandlestickChart {
|
|
base: Div,
|
|
id: ElementId,
|
|
candles: Rc<Vec<Candle>>,
|
|
kind: ChartKind,
|
|
title: Option<SharedString>,
|
|
volume: bool,
|
|
profile: bool,
|
|
overlays: Vec<Overlay>,
|
|
studies: Vec<Study>,
|
|
alerts: Vec<(f64, SharedString)>,
|
|
compare: Option<(SharedString, Rc<Vec<Candle>>)>,
|
|
sync: Option<Entity<ChartSync>>,
|
|
red_up: bool,
|
|
zone: Option<TimeZone>,
|
|
drawings: Vec<Drawing>,
|
|
tool: Option<Tool>,
|
|
on_draw: Option<OnDraw>,
|
|
}
|
|
|
|
impl CandlestickChart {
|
|
/// Candles oldest first, one per period.
|
|
pub fn new(id: impl Into<ElementId>, candles: impl Into<Rc<Vec<Candle>>>) -> Self {
|
|
let candles = candles.into();
|
|
assert!(
|
|
candles.windows(2).all(|pair| pair[0].time < pair[1].time),
|
|
"candles run oldest first"
|
|
);
|
|
Self {
|
|
base: div(),
|
|
id: id.into(),
|
|
candles,
|
|
kind: ChartKind::Candles,
|
|
title: None,
|
|
volume: false,
|
|
profile: false,
|
|
overlays: Vec::new(),
|
|
studies: Vec::new(),
|
|
alerts: Vec::new(),
|
|
compare: None,
|
|
sync: None,
|
|
red_up: false,
|
|
zone: None,
|
|
drawings: Vec::new(),
|
|
tool: None,
|
|
on_draw: None,
|
|
}
|
|
}
|
|
|
|
/// Marks the owner keeps, drawn over the prices.
|
|
pub fn drawings(mut self, drawings: impl IntoIterator<Item = Drawing>) -> Self {
|
|
self.drawings = drawings.into_iter().collect();
|
|
self
|
|
}
|
|
|
|
/// A tool to draw with: a drag over the prices draws with it instead of moving through time.
|
|
pub fn tool(mut self, tool: Option<Tool>) -> Self {
|
|
self.tool = tool;
|
|
self
|
|
}
|
|
|
|
/// Gets each drawing a tool finishes, for the owner to keep.
|
|
pub fn on_draw(mut self, handler: impl Fn(Drawing, &mut Window, &mut App) + 'static) -> Self {
|
|
self.on_draw = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
pub fn kind(mut self, kind: ChartKind) -> Self {
|
|
self.kind = kind;
|
|
self
|
|
}
|
|
|
|
/// A name read at the top left, such as a symbol and its interval.
|
|
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
|
self.title = Some(title.into());
|
|
self
|
|
}
|
|
|
|
/// Volume as faint bars along the bottom.
|
|
pub fn volume(mut self) -> Self {
|
|
self.volume = true;
|
|
self
|
|
}
|
|
|
|
/// The volume traded at each price in view, as bars from the right edge.
|
|
pub fn profile(mut self) -> Self {
|
|
self.profile = true;
|
|
self
|
|
}
|
|
|
|
pub fn overlay(mut self, overlay: Overlay) -> Self {
|
|
self.overlays.push(overlay);
|
|
self
|
|
}
|
|
|
|
/// A study in a pane of its own under the prices.
|
|
pub fn study(mut self, study: Study) -> Self {
|
|
self.studies.push(study);
|
|
self
|
|
}
|
|
|
|
/// A dashed level with its label at the price scale, as a price alert.
|
|
pub fn alert(mut self, price: f64, label: impl Into<SharedString>) -> Self {
|
|
assert!(price.is_finite(), "an alert needs a finite price");
|
|
self.alerts.push((price, label.into()));
|
|
self
|
|
}
|
|
|
|
/// Another symbol's closes over the same periods, drawn from where this one starts in view so their moves compare.
|
|
pub fn compare(
|
|
mut self,
|
|
name: impl Into<SharedString>,
|
|
candles: impl Into<Rc<Vec<Candle>>>,
|
|
) -> Self {
|
|
let candles = candles.into();
|
|
assert_eq!(
|
|
candles.len(),
|
|
self.candles.len(),
|
|
"a compared symbol needs a candle for each period"
|
|
);
|
|
self.compare = Some((name.into(), candles));
|
|
self
|
|
}
|
|
|
|
/// Moves, zooms and points together with every chart given the same sync.
|
|
pub fn sync(mut self, sync: &Entity<ChartSync>) -> Self {
|
|
self.sync = Some(sync.clone());
|
|
self
|
|
}
|
|
|
|
/// Rising prices in red and falling in green, as markets in China and Japan read them.
|
|
pub fn red_up(mut self) -> Self {
|
|
self.red_up = true;
|
|
self
|
|
}
|
|
|
|
/// The time zone its times read in; the system's unless set.
|
|
pub fn zone(mut self, zone: TimeZone) -> Self {
|
|
self.zone = Some(zone);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Styled for CandlestickChart {
|
|
fn style(&mut self) -> &mut StyleRefinement {
|
|
self.base.style()
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for CandlestickChart {
|
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let zone = self
|
|
.zone
|
|
.clone()
|
|
.unwrap_or_else(|| system_zone("CandlestickChart"));
|
|
let sync = self.sync.clone();
|
|
let stage: Entity<Stage> =
|
|
window.use_keyed_state((self.id.clone(), "stage"), cx, |_, cx| Stage {
|
|
_synced: sync
|
|
.as_ref()
|
|
.map(|sync| cx.observe(sync, |_, _, cx| cx.notify())),
|
|
..Stage::default()
|
|
});
|
|
let theme = cx.theme();
|
|
let (sizes, rem) = (theme.chart(), window.rem_size());
|
|
let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem));
|
|
let bounds = stage.read(cx).bounds;
|
|
let (gutter, foot, inset) = (
|
|
pixels(sizes.gutter) * 1.25,
|
|
pixels(sizes.foot),
|
|
pixels(sizes.inset),
|
|
);
|
|
let frame = Rect {
|
|
x: inset,
|
|
y: inset,
|
|
w: (f32::from(bounds.size.width) - inset - gutter).max(0.0),
|
|
h: (f32::from(bounds.size.height) - inset - foot).max(0.0),
|
|
};
|
|
let study_tall = pixels(sizes.height) * 0.3;
|
|
let main = Rect {
|
|
h: (frame.h - study_tall * self.studies.len() as f32).max(0.0),
|
|
..frame
|
|
};
|
|
let total = self.candles.len();
|
|
let own = stage.read(cx).seen;
|
|
let (held, shared) = match &self.sync {
|
|
Some(sync) => {
|
|
let sync = sync.read(cx);
|
|
((sync.visible, sync.hover), sync.seen)
|
|
}
|
|
None => ((stage.read(cx).visible, stage.read(cx).hover), own),
|
|
};
|
|
let fresh = total.saturating_sub(own).min(total.saturating_sub(shared));
|
|
let visible = match held.0 {
|
|
Some(visible)
|
|
if fresh > 0
|
|
&& own > 0
|
|
&& visible.start + visible.count >= (total - fresh) as f64 - 0.5 =>
|
|
{
|
|
visible.pan(fresh as f64, total)
|
|
}
|
|
Some(visible) => visible.fitted(total),
|
|
None => Visible::latest(total, f64::from(main.w / pixels(sizes.candle)).max(1.0)),
|
|
};
|
|
if own != total {
|
|
log::debug!("market chart: {own} candles to {total}");
|
|
let shrunk = total < own;
|
|
let kept = held.0.filter(|_| fresh > 0 || shrunk).map(|_| visible);
|
|
stage.update(cx, |stage, _| stage.seen = total);
|
|
match &self.sync {
|
|
Some(sync) => sync.update(cx, |sync, _| {
|
|
sync.seen = if shrunk { total } else { sync.seen.max(total) };
|
|
sync.visible = kept.or(sync.visible);
|
|
}),
|
|
None => stage.update(cx, |stage, _| stage.visible = kept.or(stage.visible)),
|
|
}
|
|
}
|
|
let range = visible.range(total);
|
|
let shown = Rc::new(if self.kind == ChartKind::HeikinAshi {
|
|
heikin_ashi(&self.candles)
|
|
} else {
|
|
(*self.candles).clone()
|
|
});
|
|
let drawn: Rc<Vec<Drawn>> = Rc::new(
|
|
self.overlays
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, kind)| overlay(*kind, &self.candles, ix + 1))
|
|
.collect(),
|
|
);
|
|
let studied: Rc<Vec<Drawn>> = Rc::new(
|
|
self.studies
|
|
.iter()
|
|
.map(|kind| study(*kind, &self.candles, 1))
|
|
.collect(),
|
|
);
|
|
let compare = self.compare.as_ref().and_then(|(_, other)| {
|
|
let first = range.clone().next()?;
|
|
let ratio = self.candles[first].close / other[first].close;
|
|
Some(
|
|
other
|
|
.iter()
|
|
.map(|candle| Some(candle.close * ratio))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
});
|
|
let in_view = &shown[range.clone()];
|
|
let mut reach = in_view.iter().fold(None::<(f64, f64)>, |reach, candle| {
|
|
let (low, high) = reach.unwrap_or((candle.low, candle.high));
|
|
Some((low.min(candle.low), high.max(candle.high)))
|
|
});
|
|
for extra in drawn
|
|
.iter()
|
|
.filter_map(|drawn| drawn.reach(range.clone()))
|
|
.chain(compare.iter().filter_map(|compare| {
|
|
compare[range.clone()]
|
|
.iter()
|
|
.flatten()
|
|
.fold(None::<(f64, f64)>, |reach, value| {
|
|
let (low, high) = reach.unwrap_or((*value, *value));
|
|
Some((low.min(*value), high.max(*value)))
|
|
})
|
|
}))
|
|
{
|
|
let (low, high) = reach.unwrap_or(extra);
|
|
reach = Some((low.min(extra.0), high.max(extra.1)));
|
|
}
|
|
let ((low, high), ticks) = fit(
|
|
reach.map_or(0.0, |reach| reach.0),
|
|
reach.map_or(1.0, |reach| reach.1),
|
|
);
|
|
let mut panes = vec![(
|
|
main,
|
|
Linear::new((low, high), (main.y + main.h, main.y)),
|
|
ticks,
|
|
)];
|
|
for (ix, drawn) in studied.iter().enumerate() {
|
|
let pane = Rect {
|
|
x: frame.x,
|
|
y: main.y + main.h + study_tall * ix as f32,
|
|
w: frame.w,
|
|
h: study_tall,
|
|
};
|
|
let inner = Rect {
|
|
y: pane.y + inset,
|
|
h: (pane.h - inset * 2.0).max(0.0),
|
|
..pane
|
|
};
|
|
let (domain, ticks) = match drawn.reach(range.clone()) {
|
|
Some(range) if drawn.range.is_some() => (range, drawn.guides.clone()),
|
|
Some((low, high)) => fit(low, high),
|
|
None => ((0.0, 1.0), Vec::new()),
|
|
};
|
|
panes.push((
|
|
pane,
|
|
Linear::new(domain, (inner.y + inner.h, inner.y)),
|
|
ticks,
|
|
));
|
|
}
|
|
let (rise, fall) = moves(self.red_up, cx);
|
|
let pointer = stage.read(cx).pointer;
|
|
let hover = held.1.filter(|ix| *ix >= 0 && (*ix as usize) < total);
|
|
let row = pointer.and_then(|(_, y)| {
|
|
panes
|
|
.iter()
|
|
.position(|(pane, _, _)| y >= pane.y && y <= pane.y + pane.h)
|
|
.map(|pane| (pane, y))
|
|
});
|
|
let last = self
|
|
.candles
|
|
.last()
|
|
.map(|candle| (candle.close, candle.rose()));
|
|
let volume = self.volume.then(|| {
|
|
in_view
|
|
.iter()
|
|
.map(|candle| candle.volume)
|
|
.fold(0.0, f64::max)
|
|
});
|
|
let profiled = if self.profile {
|
|
profile(in_view, (low, high), 24)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let sketch = stage.read(cx).sketch;
|
|
let marks: Vec<Drawing> = self
|
|
.drawings
|
|
.iter()
|
|
.copied()
|
|
.chain(sketch.map(|(tool, from, to)| Drawing::of(tool, from, to)))
|
|
.collect();
|
|
let price = panes[0].1;
|
|
let axes = Axes {
|
|
panes: panes.clone(),
|
|
visible,
|
|
range: range.clone(),
|
|
hover,
|
|
row,
|
|
zone: zone.clone(),
|
|
title: self.title.clone(),
|
|
compare: self.compare.as_ref().map(|(name, _)| name.clone()),
|
|
drawings: marks.clone(),
|
|
};
|
|
let words = labels(
|
|
&axes,
|
|
(&shown, &self.candles),
|
|
(&drawn, &studied),
|
|
(&self.alerts, last),
|
|
(rise, fall),
|
|
window,
|
|
cx,
|
|
);
|
|
let theme = cx.theme();
|
|
let scene = Scene {
|
|
panes,
|
|
visible,
|
|
range,
|
|
candles: shown,
|
|
kind: self.kind,
|
|
volume,
|
|
profile: profiled,
|
|
overlays: drawn,
|
|
studies: studied,
|
|
compare,
|
|
last,
|
|
alerts: self.alerts.iter().map(|(price, _)| *price).collect(),
|
|
cross: hover.map(|ix| (ix, row)),
|
|
drawings: marks,
|
|
pen: Pen {
|
|
palette: theme.colors.clone(),
|
|
rise,
|
|
fall,
|
|
stroke: sizes.stroke.to_pixels(rem),
|
|
hairline: sizes.hairline,
|
|
},
|
|
};
|
|
let height = sizes.height + sizes.height * 0.3 * self.studies.len() as f32;
|
|
let mut root = div().relative().w_full().h(height);
|
|
root.style().refine(self.base.style());
|
|
let steering = Steering {
|
|
stage: stage.clone(),
|
|
sync: self.sync.clone(),
|
|
visible,
|
|
total,
|
|
main,
|
|
price,
|
|
tool: self.tool,
|
|
on_draw: self.on_draw.clone(),
|
|
};
|
|
steering
|
|
.attach(root.id((self.id.clone(), "market")))
|
|
.child(drawing(scene))
|
|
.children(words)
|
|
.child(measure(stage, |stage| &mut stage.bounds))
|
|
}
|
|
}
|