=
+ window.use_keyed_state((id.clone(), "board"), cx, |_, _| Tiles::default());
+ let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover);
+ let theme = cx.theme();
+ let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size());
+ let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem));
+ let (inset, gutter, foot) = (
+ pixels(sizes.inset),
+ pixels(sizes.gutter) * 1.25,
+ pixels(sizes.foot),
+ );
+ 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 (low, high) = marks
+ .iter()
+ .fold((f64::MAX, f64::MIN), |(low, high), mark| {
+ (
+ low.min(mark.span.0.min(mark.span.1)),
+ high.max(mark.span.0.max(mark.span.1)),
+ )
+ });
+ let ((low, high), ticks) = if marks.is_empty() {
+ fit(0.0, 1.0)
+ } else {
+ fit(low, high)
+ };
+ let ys = Linear::new((low, high), (frame.y + frame.h, frame.y));
+ let slots = marks.iter().map(|mark| mark.slot + 1).max().unwrap_or(1);
+ let wide = frame.w / slots as f32;
+ let (rise, fall) = moves(red_up, cx);
+ let digits = decimals(
+ ticks
+ .windows(2)
+ .next()
+ .map_or(1.0, |pair| pair[1] - pair[0]),
+ )
+ .max(2);
+ let edge = frame.x + frame.w;
+ let text = |words: String| {
+ div()
+ .flex_none()
+ .whitespace_nowrap()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(colors.fg_subtle)
+ .child(words)
+ };
+ let mut words: Vec = ticks
+ .iter()
+ .map(|tick| {
+ div()
+ .absolute()
+ .left(Pixels::from(edge))
+ .top(Pixels::from(ys.at(*tick)))
+ .h_0()
+ .flex()
+ .items_center()
+ .pl_2()
+ .child(tabular(text(format::number(
+ *tick,
+ digits,
+ format::Separators::EN,
+ ))))
+ })
+ .collect();
+ let place_of = |mark: &Mark| {
+ let (top, bottom) = (
+ ys.at(mark.span.0.max(mark.span.1)),
+ ys.at(mark.span.0.min(mark.span.1)),
+ );
+ Rect {
+ x: frame.x + wide * mark.slot as f32,
+ y: top,
+ w: wide,
+ h: bottom - top,
+ }
+ };
+ if let Some(mark) = hover.and_then(|ix| marks.get(ix)) {
+ let rect = place_of(mark);
+ let card = ChartTooltip::new(mark.title.clone()).row(
+ Some(if mark.rose { rise } else { fall }),
+ "Prices",
+ mark.reading.clone(),
+ );
+ words.push(anchored(
+ (rect.x + rect.w, rect.y + rect.h / 2.0),
+ rect.x > f32::from(bounds.size.width) * 0.6,
+ card,
+ ));
+ }
+ let rects: Vec<(Rect, bool)> = marks
+ .iter()
+ .map(|mark| (place_of(mark), mark.rose))
+ .collect();
+ let hit = rects.clone();
+ let (stroke, hairline, palette, corner) = (
+ sizes.stroke.to_pixels(rem),
+ sizes.hairline,
+ colors.clone(),
+ theme.radius(crate::theme::Radius::Sm).to_pixels(rem),
+ );
+ let box_px = boxes.map(|size| (ys.at(0.0) - ys.at(size)).abs());
+ let mut root = div().relative().w_full().h(sizes.height);
+ root.style().refine(base.style());
+ tracked(root, (id, "marks").into(), &tiles, move |place| {
+ hit.iter().position(|(rect, _)| rect.contains(place))
+ })
+ .child(
+ canvas(
+ |_, _, _| {},
+ move |bounds, _, window, _| {
+ let origin = bounds.origin;
+ for tick in &ticks {
+ let line = Rect {
+ x: frame.x,
+ y: ys.at(*tick),
+ w: frame.w,
+ h: f32::from(hairline),
+ };
+ window.paint_quad(fill(place(origin, line), palette.border.opacity(0.5)));
+ }
+ for (ix, (rect, rose)) in rects.iter().enumerate() {
+ let ink = if *rose { rise } else { fall };
+ let ink = if hover.is_some_and(|on| on != ix) {
+ ink.opacity(0.5)
+ } else {
+ ink
+ };
+ let gap = wide * 0.12;
+ let Some(side) = box_px else {
+ let brick = Rect {
+ x: rect.x + gap,
+ y: rect.y,
+ w: rect.w - gap * 2.0,
+ h: rect.h,
+ };
+ window.paint_quad(
+ fill(place(origin, brick), ink.opacity(0.85))
+ .corner_radii(Pixels::from(f32::from(corner).min(brick.w / 4.0))),
+ );
+ continue;
+ };
+ let count = (rect.h / side).round().max(1.0) as usize;
+ let half = (side.min(wide) / 2.0 - gap).max(f32::from(hairline));
+ for step in 0..count {
+ let center = (rect.x + wide / 2.0, rect.y + side * (step as f32 + 0.5));
+ if *rose {
+ let mut cross = PathBuilder::stroke(stroke * 0.75);
+ cross.move_to(at(origin, (center.0 - half, center.1 - half)));
+ cross.line_to(at(origin, (center.0 + half, center.1 + half)));
+ cross.move_to(at(origin, (center.0 + half, center.1 - half)));
+ cross.line_to(at(origin, (center.0 - half, center.1 + half)));
+ finish(cross, ink, window);
+ } else {
+ ring(
+ at(origin, center),
+ Pixels::from(half),
+ (palette.bg.opacity(0.0), ink),
+ stroke * 0.75,
+ window,
+ );
+ }
+ }
+ }
+ },
+ )
+ .absolute()
+ .inset_0(),
+ )
+ .children(words)
+ .child(measure(tiles, |tiles| &mut tiles.bounds))
+}
+
+/// Prices as bricks of one size, laid only when the close moves a whole brick past the last, so time drops out and trends stand clear. Hover reads a brick.
+#[derive(IntoElement)]
+pub struct RenkoChart {
+ base: Div,
+ id: ElementId,
+ candles: Rc>,
+ size: f64,
+ zone: TimeZone,
+ red_up: bool,
+}
+
+impl RenkoChart {
+ pub fn new(id: impl Into, candles: impl Into>>, size: f64) -> Self {
+ assert!(
+ size.is_finite() && size > 0.0,
+ "a brick needs a positive size"
+ );
+ Self {
+ base: div(),
+ id: id.into(),
+ candles: candles.into(),
+ size,
+ zone: TimeZone::system(),
+ red_up: false,
+ }
+ }
+
+ pub fn red_up(mut self) -> Self {
+ self.red_up = true;
+ self
+ }
+
+ /// The time zone its dates read in; the system's unless set.
+ pub fn zone(mut self, zone: TimeZone) -> Self {
+ self.zone = zone;
+ self
+ }
+}
+
+impl Styled for RenkoChart {
+ fn style(&mut self) -> &mut StyleRefinement {
+ self.base.style()
+ }
+}
+
+impl RenderOnce for RenkoChart {
+ fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let marks = renko(&self.candles, self.size)
+ .into_iter()
+ .enumerate()
+ .map(|(slot, brick)| {
+ let day = self.candles[brick.at]
+ .time
+ .to_zoned(self.zone.clone())
+ .strftime("%b %-d, %Y")
+ .to_string();
+ let reading = format!(
+ "{} → {}",
+ format::number(brick.from, 2, format::Separators::EN),
+ format::number(brick.to, 2, format::Separators::EN)
+ );
+ Mark {
+ slot,
+ span: (brick.from, brick.to),
+ rose: brick.to > brick.from,
+ title: day,
+ reading,
+ }
+ })
+ .collect();
+ board(
+ self.id.clone(),
+ marks,
+ None,
+ (&mut self.base, self.red_up),
+ window,
+ cx,
+ )
+ }
+}
+
+/// Prices as columns of Xs where they rose and Os where they fell, a new column each time they turn by the reversal. Hover reads a column.
+#[derive(IntoElement)]
+pub struct PointFigureChart {
+ base: Div,
+ id: ElementId,
+ candles: Rc>,
+ size: f64,
+ reversal: i64,
+ red_up: bool,
+}
+
+impl PointFigureChart {
+ /// Boxes of `size`, and a turn after `reversal` boxes the other way.
+ pub fn new(
+ id: impl Into,
+ candles: impl Into>>,
+ size: f64,
+ reversal: i64,
+ ) -> Self {
+ assert!(
+ size.is_finite() && size > 0.0 && reversal > 0,
+ "boxes need a positive size and reversal"
+ );
+ Self {
+ base: div(),
+ id: id.into(),
+ candles: candles.into(),
+ size,
+ reversal,
+ red_up: false,
+ }
+ }
+
+ pub fn red_up(mut self) -> Self {
+ self.red_up = true;
+ self
+ }
+}
+
+impl Styled for PointFigureChart {
+ fn style(&mut self) -> &mut StyleRefinement {
+ self.base.style()
+ }
+}
+
+impl RenderOnce for PointFigureChart {
+ fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let size = self.size;
+ let marks = point_and_figure(&self.candles, size, self.reversal)
+ .into_iter()
+ .enumerate()
+ .map(|(slot, column)| {
+ let (low, high) = (
+ column.from.min(column.to) as f64 * size,
+ (column.from.max(column.to) + 1) as f64 * size,
+ );
+ let boxes = column.from.abs_diff(column.to) + 1;
+ let title = format!("{} {}", boxes, if column.rising { "X" } else { "O" });
+ let reading = format!(
+ "{} – {}",
+ format::number(low, 2, format::Separators::EN),
+ format::number(high, 2, format::Separators::EN)
+ );
+ Mark {
+ slot,
+ span: (low, high),
+ rose: column.rising,
+ title,
+ reading,
+ }
+ })
+ .collect();
+ board(
+ self.id.clone(),
+ marks,
+ Some(size),
+ (&mut self.base, self.red_up),
+ window,
+ cx,
+ )
+ }
+}
diff --git a/src/finance/risk.rs b/src/finance/risk.rs
new file mode 100644
index 0000000..ca4f81a
--- /dev/null
+++ b/src/finance/risk.rs
@@ -0,0 +1,198 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
+ relative,
+};
+
+use super::quotes::price;
+use crate::{
+ charts::nice_step,
+ data_display::{Gauge, Meter},
+ forms::Slider,
+ theme::{ActiveTheme, TextSize},
+ typography::tabular,
+};
+
+/// Where leverage up to `max` is marked: one times, round steps, and the most, none crowding it.
+fn marks(max: f64) -> Vec {
+ let step = nice_step(max, 5).max(1.0);
+ let between = (1..)
+ .map(|ix| step * f64::from(ix))
+ .take_while(|mark| *mark <= max - step / 2.0)
+ .filter(|mark| *mark > 1.0);
+ std::iter::once(1.0)
+ .chain(between)
+ .chain(std::iter::once(max))
+ .collect()
+}
+
+type OnValue = Rc;
+
+/// Leverage from one times up to `max`, marked at the common steps, its number turning amber, then red, as the risk climbs.
+#[derive(IntoElement)]
+pub struct LeverageSlider {
+ id: ElementId,
+ value: f64,
+ max: f64,
+ on_change: Option,
+}
+
+impl LeverageSlider {
+ pub fn new(id: impl Into, value: f64, max: f64) -> Self {
+ assert!(
+ max > 1.0 && max.fract() == 0.0 && (1.0..=max).contains(&value),
+ "leverage runs in whole steps from one up to its most"
+ );
+ Self {
+ id: id.into(),
+ value,
+ max,
+ on_change: None,
+ }
+ }
+
+ pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
+ self.on_change = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for LeverageSlider {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let share = (self.value - 1.0) / (self.max - 1.0);
+ let ink = if share >= 0.5 {
+ colors.danger
+ } else if share >= 0.2 {
+ colors.warning
+ } else {
+ colors.fg
+ };
+ let slider = Slider::new(self.id.clone(), self.value)
+ .range(1.0, self.max)
+ .step(1.0);
+ let slider = match self.on_change {
+ Some(on_change) => {
+ slider.on_change(move |value, window, cx| on_change(value, window, cx))
+ }
+ None => slider,
+ };
+ let marks = marks(self.max).into_iter().map(|step| {
+ let at = ((step - 1.0) / (self.max - 1.0)) as f32;
+ div()
+ .absolute()
+ .top_0()
+ .left(relative(at))
+ .w_0()
+ .flex()
+ .justify_center()
+ .child(tabular(div()).child(format!("{step:.0}×")))
+ });
+ div()
+ .flex()
+ .flex_col()
+ .gap_1()
+ .child(
+ div()
+ .flex()
+ .justify_between()
+ .text_size(theme.text_size(TextSize::Sm))
+ .child(div().text_color(colors.fg_muted).child("Leverage"))
+ .child(
+ tabular(div())
+ .font_weight(FontWeight::SEMIBOLD)
+ .text_color(ink)
+ .child(format!("{:.0}×", self.value)),
+ ),
+ )
+ .child(slider)
+ .child(
+ div()
+ .relative()
+ .h(theme.text_size(TextSize::Xs) * 1.5)
+ .mx(theme.slider_thumb() * 0.5)
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(colors.fg_subtle)
+ .children(marks),
+ )
+ }
+}
+
+/// Margin in use against what the account holds, as a bar that turns amber, then red, and what is left.
+#[derive(IntoElement)]
+pub struct MarginIndicator {
+ id: ElementId,
+ used: f64,
+ equity: f64,
+}
+
+impl MarginIndicator {
+ pub fn new(id: impl Into, used: f64, equity: f64) -> Self {
+ assert!(
+ equity > 0.0 && used >= 0.0,
+ "margin needs equity and a use of zero or more"
+ );
+ Self {
+ id: id.into(),
+ used,
+ equity,
+ }
+ }
+}
+
+impl RenderOnce for MarginIndicator {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ let share = (self.used / self.equity) as f32;
+ let detail = format!(
+ "${} of ${} · ${} free",
+ price(self.used, 0),
+ price(self.equity, 0),
+ price((self.equity - self.used).max(0.0), 0)
+ );
+ Meter::new(self.id, "Margin used", share.min(1.0))
+ .detail(detail)
+ .thresholds(0.6, 0.85)
+ }
+}
+
+/// How much risk a position carries, from none to all, on a dial that turns amber, then red.
+#[derive(IntoElement)]
+pub struct RiskMeter {
+ id: ElementId,
+ risk: f64,
+}
+
+impl RiskMeter {
+ /// `risk` from 0 to 100.
+ pub fn new(id: impl Into, risk: f64) -> Self {
+ assert!((0.0..=100.0).contains(&risk), "risk reads from 0 to 100");
+ Self {
+ id: id.into(),
+ risk,
+ }
+ }
+}
+
+impl RenderOnce for RiskMeter {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ Gauge::new(self.id, "Risk", self.risk, 0.0, 100.0)
+ .decimals(0)
+ .thresholds(0.5, 0.8)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::marks;
+
+ #[test]
+ fn leverage_marks_step_evenly_to_the_most() {
+ assert_eq!(marks(100.0), [1.0, 20.0, 40.0, 60.0, 80.0, 100.0]);
+ assert_eq!(marks(125.0), [1.0, 50.0, 100.0, 125.0]);
+ assert_eq!(marks(20.0), [1.0, 5.0, 10.0, 15.0, 20.0]);
+ assert_eq!(marks(3.0), [1.0, 2.0, 3.0]);
+ assert_eq!(marks(2.0), [1.0, 2.0]);
+ }
+}
diff --git a/src/finance/series.rs b/src/finance/series.rs
new file mode 100644
index 0000000..c5f065c
--- /dev/null
+++ b/src/finance/series.rs
@@ -0,0 +1,187 @@
+use gpui::SharedString;
+
+use super::{
+ candles::Candle,
+ indicators::{Reading, bollinger, ema, kdj, macd, obv, rsi, sma, vwap},
+};
+
+/// A line drawn over the prices.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Overlay {
+ /// A simple moving average of this many closes.
+ Sma(usize),
+ /// An exponential moving average of this many closes.
+ Ema(usize),
+ /// Bollinger bands over this many closes, this many deviations wide.
+ Bollinger(usize, f64),
+ /// The volume-weighted average price.
+ Vwap,
+}
+
+/// A pane of its own under the prices.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Study {
+ Macd,
+ Rsi,
+ Kdj,
+ Obv,
+}
+
+/// What an overlay or a study draws: its name, its lines in palette colors, a band to shade, bars about zero, guide levels, and a fixed range when it has one.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct Drawn {
+ pub name: SharedString,
+ pub lines: Vec<(usize, Reading)>,
+ pub band: Option<(Reading, Reading)>,
+ pub bars: Option,
+ pub guides: Vec,
+ pub range: Option<(f64, f64)>,
+}
+
+fn closes(candles: &[Candle]) -> Vec