T16 Finance: market charts with kinds, volume, profile, studies, drawings, compare, alerts and sync (T16a–b); quotes, book, tape and DOM (T16c); order entry, blotter, risk, options and payoff (T16d); markets, calendars, portfolio and wallet (T16e); typography::Ellipsis ends lines that gpui's truncate clipped; chart notes stack and ticks label round values

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 23:30:04 -04:00
1 parent efde9d64b0
commit 8d43b7a880
76 files changed
+10311 -173

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
use gpui::{
App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Point,
Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill,
};
use super::quotes::moves;
use crate::{
charts::{
ChartTooltip, Linear, Rect, Tiles, anchored, at, compact, finish, measure, nice, place,
ring, tracked,
},
theme::{ActiveTheme, TextSize},
typography::{format, tabular},
};
/// Prices, each with a running total of size.
pub(crate) type Totals = Vec<(f64, f64)>;
/// A book's running totals, best price first: each bid's price with all size bid at it or higher, and each ask's with all size asked at it or lower.
pub(crate) fn depth(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> (Totals, Totals) {
let run = |mut side: Vec<(f64, f64)>, best_high: bool| {
side.sort_by(|a, b| {
if best_high {
b.0.total_cmp(&a.0)
} else {
a.0.total_cmp(&b.0)
}
});
let mut total = 0.0;
side.into_iter()
.map(|(price, size)| {
total += size;
(price, total)
})
.collect::<Vec<_>>()
};
(run(bids.to_vec(), true), run(asks.to_vec(), false))
}
/// The total on one side at a price: everything bid at it or higher, or asked at it or lower.
fn total_at(side: &[(f64, f64)], price: f64, bids: bool) -> Option<f64> {
side.iter()
.take_while(|(level, _)| {
if bids {
*level >= price
} else {
*level <= price
}
})
.last()
.map(|(_, total)| *total)
}
/// Orders waiting to buy and to sell as the running total at each price: bids step up to the left of the spread, asks to the right. Hover reads the total to any price.
#[derive(IntoElement)]
pub struct DepthChart {
base: Div,
id: ElementId,
bids: Vec<(f64, f64)>,
asks: Vec<(f64, f64)>,
red_up: bool,
}
impl DepthChart {
/// Bids and asks, each a price and the size waiting there; the best bid sits below the best ask.
pub fn new(
id: impl Into<ElementId>,
bids: impl IntoIterator<Item = (f64, f64)>,
asks: impl IntoIterator<Item = (f64, f64)>,
) -> Self {
let (bids, asks): (Vec<_>, Vec<_>) =
(bids.into_iter().collect(), asks.into_iter().collect());
let fine = |side: &[(f64, f64)]| {
side.iter()
.all(|(price, size)| price.is_finite() && size.is_finite() && *size > 0.0)
};
assert!(
fine(&bids) && fine(&asks) && !bids.is_empty() && !asks.is_empty(),
"a book needs levels with positive sizes on both sides"
);
let best_bid = bids
.iter()
.map(|(price, _)| *price)
.fold(f64::MIN, f64::max);
let best_ask = asks
.iter()
.map(|(price, _)| *price)
.fold(f64::MAX, f64::min);
assert!(best_bid < best_ask, "the best bid sits below the best ask");
Self {
base: div(),
id: id.into(),
bids,
asks,
red_up: false,
}
}
/// Bids in red and asks in green, as markets in China and Japan read rises.
pub fn red_up(mut self) -> Self {
self.red_up = true;
self
}
}
impl Styled for DepthChart {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for DepthChart {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let tiles: Entity<Tiles> =
window.use_keyed_state((self.id.clone(), "depth"), cx, |_, _| Tiles::default());
let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover);
let (bids, asks) = depth(&self.bids, &self.asks);
let (best_bid, best_ask) = (bids[0].0, asks[0].0);
let mid = (best_bid + best_ask) / 2.0;
let reach = (mid - bids.last().expect("bids").0).max(asks.last().expect("asks").0 - mid);
let most = bids
.last()
.expect("bids")
.1
.max(asks.last().expect("asks").1);
let ((_, top), ticks) = nice(0.0, most, 4);
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),
pixels(sizes.foot),
);
let frame = Rect {
x: inset,
y: inset * 3.0,
w: (f32::from(bounds.size.width) - inset - gutter).max(0.0),
h: (f32::from(bounds.size.height) - inset * 3.0 - foot).max(0.0),
};
let xs = Linear::new((mid - reach, mid + reach), (frame.x, frame.x + frame.w));
let ys = Linear::new((0.0, top), (frame.y + frame.h, frame.y));
let (rise, fall) = moves(self.red_up, cx);
let text = |words: String, color| {
div()
.flex_none()
.whitespace_nowrap()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color)
.child(words)
};
let edge = frame.x + frame.w;
let mut words: Vec<Div> = 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(compact(*tick), colors.fg_subtle)))
})
.collect();
let (_, price_ticks) = nice(mid - reach, mid + reach, 5);
let digits = super::stage::decimals(
price_ticks
.get(1)
.zip(price_ticks.first())
.map_or(1.0, |(b, a)| b - a),
)
.max(2);
words.extend(
price_ticks
.iter()
.filter(|tick| (mid - reach..=mid + reach).contains(*tick))
.map(|tick| {
let label = tabular(text(
format::number(*tick, digits, format::Separators::EN),
colors.fg_subtle,
));
div()
.absolute()
.left(Pixels::from(xs.at(*tick)))
.top(Pixels::from(frame.y + frame.h))
.w_0()
.flex()
.justify_center()
.pt_1p5()
.child(label)
}),
);
let spread = best_ask - best_bid;
let heading = format!(
"Spread {} · {}",
format::number(spread, digits, format::Separators::EN),
format::percent(spread / mid, 3, false)
);
words.push(
div()
.absolute()
.left(Pixels::from(xs.at(mid)))
.top_0()
.w_0()
.flex()
.justify_center()
.child(text(heading, colors.fg_muted)),
);
let steps = |side: &[(f64, f64)]| -> Vec<(f32, f32)> {
let mut points = vec![(xs.at(side[0].0), ys.at(0.0))];
let mut last = 0.0;
for (price, total) in side {
points.push((xs.at(*price), ys.at(last)));
points.push((xs.at(*price), ys.at(*total)));
last = *total;
}
points
};
let (bid_steps, ask_steps) = (steps(&bids), steps(&asks));
let reading = hover.map(|column| {
let price = xs.value(frame.x + column as f32);
let is_bid = price < mid;
let total = if is_bid {
total_at(&bids, price, true)
} else {
total_at(&asks, price, false)
};
(price, is_bid, total.unwrap_or(0.0))
});
if let Some((price, is_bid, total)) = reading {
let card = ChartTooltip::new(format::number(price, digits, format::Separators::EN))
.row(
Some(if is_bid { rise } else { fall }),
if is_bid {
"Bids at or above"
} else {
"Asks at or below"
},
compact(total),
);
let x = xs.at(price);
words.push(anchored(
(x, ys.at(total)),
x > f32::from(bounds.size.width) * 0.6,
card,
));
}
let (stroke, bottom) = (sizes.stroke.to_pixels(rem), frame.y + frame.h);
let mark = reading.map(|(price, _, total)| (xs.at(price), ys.at(total)));
let palette = colors.clone();
let mut root = div().relative().w_full().h(sizes.height);
root.style().refine(self.base.style());
tracked(
root,
(self.id.clone(), "book").into(),
&tiles,
move |(x, y)| {
frame
.contains((x, y))
.then(|| (x - frame.x).round() as usize)
},
)
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let origin: Point<Pixels> = bounds.origin;
for tick in &ticks {
let line = Rect {
x: frame.x,
y: ys.at(*tick),
w: frame.w,
h: f32::from(sizes.hairline),
};
window.paint_quad(fill(place(origin, line), palette.border.opacity(0.5)));
}
for (points, ink) in [(&bid_steps, rise), (&ask_steps, fall)] {
let (Some(first), Some(last)) = (points.first(), points.last()) else {
continue;
};
let mut area = PathBuilder::fill();
let mut edge = PathBuilder::stroke(stroke);
area.move_to(at(origin, *first));
edge.move_to(at(origin, *first));
points[1..].iter().for_each(|point| {
area.line_to(at(origin, *point));
edge.line_to(at(origin, *point));
});
area.line_to(at(origin, (last.0, bottom)));
area.close();
finish(area, ink.opacity(0.14), window);
finish(edge, ink, window);
}
let middle = Rect {
x: xs.at(mid),
y: frame.y,
w: f32::from(sizes.hairline),
h: frame.h,
};
window.paint_quad(fill(place(origin, middle), palette.border_strong));
if let Some(point) = mark {
let upright = Rect {
x: point.0,
y: frame.y,
w: f32::from(sizes.hairline),
h: frame.h,
};
window.paint_quad(fill(place(origin, upright), palette.fg_subtle));
ring(
at(origin, point),
stroke * 2.0,
(palette.bg, palette.fg),
stroke,
window,
);
}
},
)
.absolute()
.inset_0(),
)
.children(words)
.child(measure(tiles, |tiles| &mut tiles.bounds))
}
}
#[cfg(test)]
mod tests {
use super::{depth, total_at};
#[test]
fn a_book_runs_totals_outward_from_the_spread() {
let (bids, asks) = depth(
&[(99.0, 2.0), (100.0, 1.0), (98.0, 3.0)],
&[(102.0, 4.0), (101.0, 1.0)],
);
assert_eq!(bids, [(100.0, 1.0), (99.0, 3.0), (98.0, 6.0)]);
assert_eq!(asks, [(101.0, 1.0), (102.0, 5.0)]);
assert_eq!(
total_at(&bids, 98.5, true),
Some(3.0),
"bids at 99 and above"
);
assert_eq!(total_at(&asks, 101.5, false), Some(1.0));
assert_eq!(total_at(&bids, 100.5, true), None, "nothing bid that high");
}
}