Panics · P16 Finance: live market states, fine grids and lagging overlays draw or say why; system-zone times say UTC when unknown

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 20:09:15 -04:00
1 parent a6b1bb0929
commit f0b7f779f6
18 files changed
+504 -140

No files matched your search

+6 -1
View File
@@ -58,7 +58,12 @@ pub(crate) fn nice_step(span: f64, count: usize) -> f64 {
} else {
10.0
};
nice * power
let step = nice * power;
if step > 0.0 && step.is_finite() {
step
} else {
raw
}
}
/// A domain widened to round steps that hold `low..=high`, and the ticks along it.
+4 -10
View File
@@ -180,11 +180,8 @@ impl RenderOnce for OrderTable {
} else {
order.price.into()
};
let time: SharedString = working
.time
.to_zoned(zone.clone())
.strftime("%H:%M:%S")
.to_string()
let time: SharedString = format::datetime(working.time, &zone, "%H:%M:%S")
.expect("a fixed pattern")
.into();
let filled = working.filled / order.quantity;
Row::new(
@@ -270,11 +267,8 @@ impl RenderOnce for TradeHistoryTable {
.map(|(ix, fill)| {
let signed = if fill.side == Side::Buy { -1.0 } else { 1.0 };
let total = signed * fill.quantity * fill.price - fill.fee;
let time: SharedString = fill
.time
.to_zoned(zone.clone())
.strftime("%b %-d %H:%M")
.to_string()
let time: SharedString = format::datetime(fill.time, &zone, "%b %-d %H:%M")
.expect("a fixed pattern")
.into();
Row::new(
format!("fill-{ix}"),
+10 -7
View File
@@ -86,7 +86,10 @@ impl RenderOnce for EconomicCalendar {
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(zoned.strftime("%A, %B %-d").to_string())
.child(
format::datetime(release.time, &zone, "%A, %B %-d")
.expect("a fixed pattern"),
)
.into_any_element(),
);
}
@@ -129,7 +132,10 @@ impl RenderOnce for EconomicCalendar {
tabular(div())
.w(theme.label_width() * 0.3)
.text_color(colors.fg_muted)
.child(zoned.strftime("%H:%M").to_string()),
.child(
format::datetime(release.time, &zone, "%H:%M")
.expect("a fixed pattern"),
),
)
.child(div().child(flag(&release.region)))
.child(
@@ -327,11 +333,8 @@ impl RenderOnce for NewsFeed {
None => colors.border_strong,
};
let open = self.on_open.clone();
let when = story
.time
.to_zoned(zone.clone())
.strftime("%b %-d, %H:%M")
.to_string();
let when =
format::datetime(story.time, &zone, "%b %-d, %H:%M").expect("a fixed pattern");
div()
.id((self.id.clone(), format!("story-{ix}")))
.flex()
+35
View File
@@ -73,6 +73,26 @@ pub(crate) struct Brick {
pub at: usize,
}
/// Most boxes of one size a chart draws; finer ones cannot be told apart.
const MOST_BOXES: f64 = 1e5;
/// Whether boxes of `size` grid the candles: few enough by their travel, and exact in whole steps.
pub(crate) fn griddable(candles: &[Candle], size: f64) -> bool {
let travel: f64 = candles
.iter()
.map(|candle| candle.high - candle.low)
.sum::<f64>()
+ candles
.windows(2)
.map(|pair| (pair[1].close - pair[0].close).abs())
.sum::<f64>();
let reach = candles
.iter()
.map(|candle| candle.high.abs().max(candle.low.abs()))
.fold(0.0, f64::max);
travel / size <= MOST_BOXES && reach / size < 2f64.powi(53)
}
/// Renko bricks of `size`: one each time the close moves a whole brick past the last brick's far side, so turning takes two.
pub(crate) fn renko(candles: &[Candle], size: f64) -> Vec<Brick> {
assert!(size > 0.0, "a brick needs a size");
@@ -193,6 +213,21 @@ pub(crate) fn profile(
.collect()
}
#[cfg(test)]
mod grid {
use jiff::Timestamp;
use super::{Candle, griddable};
#[test]
fn boxes_too_fine_for_their_prices_do_not_grid() {
let candle = Candle::new(Timestamp::UNIX_EPOCH, (10.0, 11.0, 9.0, 10.0), 1.0);
assert!(griddable(&[candle], 1.0));
assert!(!griddable(&[candle], 1e-18), "past exact steps");
assert!(!griddable(&[candle], 1e-5), "past the boxes a chart draws");
}
}
#[cfg(test)]
mod tests {
use super::*;
+18 -15
View File
@@ -42,10 +42,9 @@ impl TimeRangeSelector {
/// `selected` is one of `RANGES`.
pub fn new(id: impl Into<ElementId>, selected: impl Into<SharedString>) -> Self {
let selected = selected.into();
assert!(
RANGES.contains(&selected.as_ref()),
"{selected} is not a range"
);
if !RANGES.contains(&selected.as_ref()) {
log::error!("time range selector: {selected} is not a range; none marked");
}
Self {
id: id.into(),
selected,
@@ -89,10 +88,9 @@ impl IntervalSelector {
/// `selected` is one of the keys of `INTERVALS`.
pub fn new(id: impl Into<ElementId>, selected: impl Into<SharedString>) -> Self {
let selected = selected.into();
assert!(
INTERVALS.iter().any(|(key, _)| *key == selected.as_ref()),
"{selected} is not an interval"
);
if !INTERVALS.iter().any(|(key, _)| *key == selected.as_ref()) {
log::error!("interval selector: {selected} is not an interval; none chosen");
}
Self {
id: id.into(),
selected,
@@ -267,12 +265,13 @@ impl MultiChartLayout {
impl RenderOnce for MultiChartLayout {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let wanted = self.arrangement.cells();
assert!(
self.cells.len() >= wanted,
"{:?} holds {wanted} charts; {} were given",
self.arrangement,
self.cells.len()
);
if self.cells.len() < wanted {
log::error!(
"multi-chart layout: {:?} holds {wanted} charts; {} given, the rest empty",
self.arrangement,
self.cells.len()
);
}
let theme = cx.theme();
let key = Arrangement::ALL
.iter()
@@ -298,7 +297,11 @@ impl RenderOnce for MultiChartLayout {
let border = theme.colors.border;
let cell = |child: AnyElement| div().flex_1().min_w_0().min_h_0().p_2().child(child);
let mut cells = self.cells.into_iter().take(wanted).map(cell);
let mut next = || cells.next().expect("counted above");
let mut next = || {
cells
.next()
.unwrap_or_else(|| cell(div().into_any_element()))
};
let grid = match self.arrangement {
Arrangement::One => div().flex().child(next()),
Arrangement::Across => div()
+5 -10
View File
@@ -78,15 +78,6 @@ impl DepthChart {
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(),
@@ -117,7 +108,11 @@ impl RenderOnce for DepthChart {
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 reach = bids
.iter()
.chain(&asks)
.map(|(price, _)| (price - mid).abs())
.fold(0.0, f64::max);
let most = bids
.last()
.expect("bids")
+7 -3
View File
@@ -42,7 +42,6 @@ fn places(ticks: &[f64]) -> usize {
/// How a candle's time reads: the month and year for monthly candles, the day for daily, the hour within a day.
fn stamp(candle: &Candle, interval: i64, zone: &TimeZone, full: bool) -> String {
let zoned = candle.time.to_zoned(zone.clone());
let pattern = match (interval, full) {
(seconds, _) if seconds >= 86_400 * 28 => "%b %Y",
(seconds, true) if seconds >= 86_400 => "%a %b %-d, %Y",
@@ -50,7 +49,7 @@ fn stamp(candle: &Candle, interval: i64, zone: &TimeZone, full: bool) -> String
(_, true) => "%b %-d %H:%M",
(_, false) => "%H:%M",
};
zoned.strftime(pattern).to_string()
format::datetime(candle.time, zone, pattern).expect("a fixed pattern")
}
/// Price alerts with their words, and the last price with whether it rose.
@@ -198,7 +197,10 @@ pub(crate) fn labels(
);
}
if let Some(name) = &axes.compare {
readout = readout.child(text(format!("vs {name}"), colors.fg_muted));
readout = readout.child(
text(format!("vs {name}"), colors.fg_muted)
.debug_selector(|| "chart-compared".into()),
);
}
out.push(readout);
for ((pane, _, _), drawn) in axes.panes.iter().skip(1).zip(studies) {
@@ -366,6 +368,8 @@ mod tests {
assert_eq!(stamp(&candle, 86_400, &utc, false), "May 28");
assert_eq!(stamp(&candle, 3_600, &utc, false), "20:26");
assert_eq!(stamp(&candle, 86_400 * 30, &utc, false), "May 2026");
let unknown = TimeZone::unknown();
assert_eq!(stamp(&candle, 3_600, &unknown, false), "20:26 UTC");
assert_eq!(places(&[100.0, 105.0]), 2, "prices keep cents");
assert_eq!(places(&[0.001, 0.002]), 3);
}
+52 -15
View File
@@ -17,7 +17,7 @@ use super::{
tools::{Drawing, Tool},
};
use crate::{
charts::{Linear, Rect, measure},
charts::{LIMIT, Linear, Rect, measure},
theme::ActiveTheme,
typography::format::system_zone,
};
@@ -160,13 +160,17 @@ impl CandlestickChart {
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));
let (name, candles) = (name.into(), candles.into());
if candles.len() != self.candles.len() {
log::error!(
"market chart: {name} has {} candles for {} periods; not compared",
candles.len(),
self.candles.len()
);
self.compare = None;
return self;
}
self.compare = Some((name, candles));
self
}
@@ -285,12 +289,17 @@ impl RenderOnce for CandlestickChart {
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 closes: Vec<f64> = other.iter().map(|candle| candle.close * ratio).collect();
if !closes
.iter()
.all(|close| close.is_finite() && close.abs() <= LIMIT)
{
log::error!(
"market chart: the comparison scaled by {ratio} leaves what a chart draws"
);
return None;
}
Some(closes.into_iter().map(Some).collect::<Vec<_>>())
});
let in_view = &shown[range.clone()];
let mut reach = in_view.iter().fold(None::<(f64, f64)>, |reach, candle| {
@@ -385,7 +394,10 @@ impl RenderOnce for CandlestickChart {
row,
zone: zone.clone(),
title: self.title.clone(),
compare: self.compare.as_ref().map(|(name, _)| name.clone()),
compare: compare
.as_ref()
.and(self.compare.as_ref())
.map(|(name, _)| name.clone()),
drawings: marks.clone(),
};
let words = labels(
@@ -444,3 +456,28 @@ impl RenderOnce for CandlestickChart {
.child(measure(stage, |stage| &mut stage.bounds))
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use jiff::Timestamp;
use super::{Candle, CandlestickChart};
fn days(count: i64) -> Rc<Vec<Candle>> {
let candle = |ix: i64| {
let time = Timestamp::from_second(ix * 86_400).expect("a day");
Candle::new(time, (10.0, 11.0, 9.0, 10.0), 1.0)
};
Rc::new((0..count).map(candle).collect())
}
#[test]
fn a_comparison_that_lags_clears_the_one_before() {
let chart = CandlestickChart::new("chart", days(2))
.compare("one", days(2))
.compare("two", days(1));
assert!(chart.compare.is_none());
}
}
+3 -3
View File
@@ -95,9 +95,9 @@ impl Styled for PayoffDiagram {
impl RenderOnce for PayoffDiagram {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let step = nice_step(self.range.1 - self.range.0, 6);
let ((start, end), _) = nice(self.range.0, self.range.1, 6);
let samples = ((end - start) / step).round() as usize * PER_STEP + 1;
let ((start, end), ticks) = nice(self.range.0, self.range.1, 6);
let step = nice_step(end - start, 6);
let samples = ticks.len().saturating_sub(1).max(1) * PER_STEP + 1;
let prices: Vec<f64> = (0..samples)
.map(|ix| start + (end - start) * ix as f64 / (samples - 1) as f64)
.collect();
+60 -23
View File
@@ -4,10 +4,10 @@ use gpui::{
use jiff::civil::Date;
use crate::{
charts::{AreaChart, LineChart, Series},
charts::{AreaChart, LIMIT, LineChart, Series},
data_display::{KpiCard, Statistic, Tone, TrendIndicator, UsageBar},
tables::{Cell, Column, DataTable, Row, TreeRow, TreeTable},
theme::ActiveTheme,
theme::{ActiveTheme, TextSize},
typography::format,
};
@@ -103,14 +103,14 @@ pub(crate) fn drawdown(values: &[f64]) -> Vec<f64> {
.collect()
}
/// Each value as a share gained since the first.
fn growth(values: &[f64]) -> Vec<f64> {
let first = values
.first()
.copied()
.filter(|first| *first != 0.0)
.expect("growth runs from a first value other than zero");
values.iter().map(|value| value / first - 1.0).collect()
/// Each value as a share gained since the first; none from zero or past what a chart draws.
fn growth(values: &[f64]) -> Option<Vec<f64>> {
let first = *values.first()?;
let grown: Vec<f64> = values.iter().map(|value| value / first - 1.0).collect();
grown
.iter()
.all(|share| share.is_finite() && share.abs() <= LIMIT)
.then_some(grown)
}
/// How a portfolio grew against a benchmark, both from the same start, and below it how far it fell from each high.
@@ -149,35 +149,63 @@ impl PerformanceChart {
name: impl Into<SharedString>,
values: impl IntoIterator<Item = f64>,
) -> Self {
let values: Vec<f64> = values.into_iter().collect();
assert_eq!(
values.len(),
self.labels.len(),
"a benchmark needs a value per label"
);
self.benchmark = Some((name.into(), values));
let (name, values) = (name.into(), values.into_iter().collect::<Vec<f64>>());
if values.len() != self.labels.len() {
log::error!(
"performance chart: {name} has {} values for {} labels; left out",
values.len(),
self.labels.len()
);
self.benchmark = None;
return self;
}
self.benchmark = Some((name, values));
self
}
}
impl RenderOnce for PerformanceChart {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let height = cx.theme().chart().height;
let theme = cx.theme();
let height = theme.chart().height;
let percent = |value: f64| format::percent(value, 0, true);
let falls = drawdown(&self.equity);
let drawn = falls
.iter()
.all(|fall| fall.is_finite() && fall.abs() <= LIMIT);
let Some(grown) = growth(&self.equity).filter(|_| drawn) else {
log::error!(
"performance chart {:?}: no growth from {:?}",
self.id,
self.equity.first()
);
return div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.danger)
.child("No growth to draw from this first value")
.into_any_element();
};
let curve = LineChart::new((self.id.clone(), "growth"), self.labels.clone())
.series(Series::new("Portfolio", growth(&self.equity)));
.series(Series::new("Portfolio", grown));
let curve = match &self.benchmark {
Some((name, values)) => curve.series(Series::new(name.clone(), growth(values))),
Some((name, values)) => match growth(values) {
Some(grown) => curve.series(Series::new(name.clone(), grown)),
None => {
log::error!("performance chart {:?}: no growth for {name}", self.id);
curve
}
},
None => curve,
};
let falls = AreaChart::new((self.id.clone(), "drawdown"), self.labels)
.series(Series::new("Drawdown", drawdown(&self.equity)));
.series(Series::new("Drawdown", falls));
div()
.flex()
.flex_col()
.gap_2()
.child(curve.format(percent).h(height * 0.8))
.child(falls.format(percent).h(height * 0.4))
.into_any_element()
}
}
@@ -338,7 +366,15 @@ impl RenderOnce for FinancialStatementTable {
#[cfg(test)]
mod tests {
use super::{drawdown, growth};
use super::{PerformanceChart, drawdown, growth};
#[test]
fn a_benchmark_that_lags_clears_the_one_before() {
let chart = PerformanceChart::new("perf", ["a", "b"], [1.0, 2.0])
.benchmark("index", [1.0, 2.0])
.benchmark("index", [1.0]);
assert!(chart.benchmark.is_none());
}
#[test]
fn falls_read_from_each_high_and_growth_from_the_start() {
@@ -346,7 +382,8 @@ mod tests {
drawdown(&[100.0, 120.0, 90.0, 130.0]),
[0.0, 0.0, -0.25, 0.0]
);
let grown = growth(&[100.0, 110.0, 90.0]);
assert_eq!(growth(&[0.0, 10.0]), None, "no growth from zero");
let grown = growth(&[100.0, 110.0, 90.0]).expect("growth from a hundred");
assert!(
grown
.iter()
+27 -10
View File
@@ -1,14 +1,15 @@
use std::rc::Rc;
use gpui::{
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, PathBuilder,
Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill,
AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
PathBuilder, Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div,
fill,
};
use jiff::tz::TimeZone;
use super::{
candles::{Candle, point_and_figure, renko},
candles::{Candle, griddable, point_and_figure, renko},
quotes::moves,
stage::fit,
};
@@ -18,7 +19,7 @@ use crate::{
},
theme::{ActiveTheme, TextSize},
typography::{
format::{self, decimals, system_zone},
format::{self, datetime, decimals, system_zone},
tabular,
},
};
@@ -32,6 +33,17 @@ struct Mark {
reading: String,
}
/// A box size too fine for the candles: logged, and said in the chart's place.
fn too_fine(id: &ElementId, size: f64, cx: &App) -> AnyElement {
log::error!("{id:?}: boxes of {size} are too fine for these prices");
let theme = cx.theme();
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.danger)
.child(format!("Boxes of {size} are too fine for these prices"))
.into_any_element()
}
/// What both price-only charts share: a slot per mark along the bottom, prices at the right, and a tooltip for the mark under the pointer.
fn board(
id: ElementId,
@@ -176,7 +188,7 @@ fn board(
ink
};
let gap = wide * 0.12;
let Some(side) = box_px else {
let Some(side) = box_px.filter(|side| *side >= 1.0) else {
let brick = Rect {
x: rect.x + gap,
y: rect.y,
@@ -271,15 +283,15 @@ impl RenderOnce for RenkoChart {
.zone
.clone()
.unwrap_or_else(|| system_zone("RenkoChart"));
if !griddable(&self.candles, self.size) {
return too_fine(&self.id, self.size, cx);
}
let marks = renko(&self.candles, self.size)
.into_iter()
.enumerate()
.map(|(slot, brick)| {
let day = self.candles[brick.at]
.time
.to_zoned(zone.clone())
.strftime("%b %-d, %Y")
.to_string();
let day = datetime(self.candles[brick.at].time, &zone, "%b %-d, %Y")
.expect("a fixed pattern");
let reading = format!(
"{} → {}",
format::number(brick.from, 2, format::Separators::EN),
@@ -302,6 +314,7 @@ impl RenderOnce for RenkoChart {
window,
cx,
)
.into_any_element()
}
}
@@ -353,6 +366,9 @@ impl Styled for PointFigureChart {
impl RenderOnce for PointFigureChart {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let size = self.size;
if !griddable(&self.candles, size) {
return too_fine(&self.id, size, cx);
}
let marks = point_and_figure(&self.candles, size, self.reversal)
.into_iter()
.enumerate()
@@ -385,5 +401,6 @@ impl RenderOnce for PointFigureChart {
window,
cx,
)
.into_any_element()
}
}
+16 -5
View File
@@ -41,7 +41,7 @@ pub struct LeverageSlider {
impl LeverageSlider {
pub fn new(id: impl Into<ElementId>, value: f64, max: f64) -> Self {
assert!(
max > 1.0 && max.fract() == 0.0 && (1.0..=max).contains(&value),
max > 1.0 && max.fract() == 0.0 && value.is_finite(),
"leverage runs in whole steps from one up to its most"
);
Self {
@@ -62,7 +62,15 @@ 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);
if !(1.0..=self.max).contains(&self.value) {
log::error!(
"leverage {:?}: {} is outside 1..={}",
self.id,
self.value,
self.max
);
}
let share = ((self.value - 1.0) / (self.max - 1.0)).clamp(0.0, 1.0);
let ink = if share >= 0.5 {
colors.danger
} else if share >= 0.2 {
@@ -131,8 +139,8 @@ pub struct MarginIndicator {
impl MarginIndicator {
pub fn new(id: impl Into<ElementId>, used: f64, equity: f64) -> Self {
assert!(
equity > 0.0 && used >= 0.0,
"margin needs equity and a use of zero or more"
equity.is_finite() && used.is_finite() && used >= 0.0,
"margin needs finite equity and a use of zero or more"
);
Self {
id: id.into(),
@@ -144,7 +152,10 @@ impl MarginIndicator {
impl RenderOnce for MarginIndicator {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let share = (self.used / self.equity) as f32;
let share = match self.equity > 0.0 {
true => (self.used / self.equity) as f32,
false => 1.0,
};
let detail = format!(
"${} of ${} · ${} free",
price(self.used, 0),
+10 -8
View File
@@ -18,8 +18,8 @@ pub struct SpreadIndicator {
impl SpreadIndicator {
pub fn new(bid: f64, ask: f64) -> Self {
assert!(
bid.is_finite() && ask.is_finite() && bid < ask,
"a spread needs a bid below its ask"
bid.is_finite() && ask.is_finite(),
"a spread needs finite prices"
);
Self {
bid,
@@ -89,10 +89,7 @@ pub struct BidAskBar {
impl BidAskBar {
/// The size waiting on each side.
pub fn new(bids: f64, asks: f64) -> Self {
assert!(
bids >= 0.0 && asks >= 0.0 && bids + asks > 0.0,
"a balance needs some size"
);
assert!(bids >= 0.0 && asks >= 0.0, "sizes are zero or more");
Self {
bids,
asks,
@@ -110,11 +107,16 @@ impl RenderOnce for BidAskBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (rise, fall) = moves(self.red_up, cx);
let theme = cx.theme();
let share = self.bids / (self.bids + self.asks);
let total = self.bids + self.asks;
let share = if total > 0.0 { self.bids / total } else { 0.5 };
let words = |name: &'static str, value: f64| {
let shown = match total > 0.0 {
true => format::percent(value, 0, false),
false => "—".to_string(),
};
tabular(div())
.text_color(theme.colors.fg_muted)
.child(format!("{name} {}", format::percent(value, 0, false)))
.child(format!("{name} {shown}"))
};
let part = |grow: f64, ink| {
let mut part = div().h_full().flex_basis(relative(0.0)).bg(ink);
+32 -16
View File
@@ -15,7 +15,7 @@ use crate::{
motion::Flash,
theme::{ActiveTheme, Density, TextSize},
typography::{
format::{decimals, system_zone},
format::{datetime, decimals, system_zone},
tabular,
},
};
@@ -85,11 +85,7 @@ impl RenderOnce for TimeAndSales {
.enumerate()
.map(|(ix, trade)| {
let ink = if trade.side == Side::Buy { rise } else { fall };
let time = trade
.time
.to_zoned(zone.clone())
.strftime("%H:%M:%S")
.to_string();
let time = datetime(trade.time, &zone, "%H:%M:%S").expect("a fixed pattern");
let row = div()
.flex()
.items_center()
@@ -187,12 +183,17 @@ impl DomLadder {
}
}
/// The prices a ladder shows around `last`, highest first, each on a whole step.
/// The prices a ladder shows around `last`, highest first, each on a whole step when the step can say it.
fn rungs(last: f64, tick: f64, rows: usize) -> Vec<f64> {
let middle = (last / tick).round() as i64;
let above = rows as i64 / 2;
(0..rows as i64)
.map(|ix| (middle + above - ix) as f64 * tick)
let steps = (last / tick).round();
let middle = if steps.is_finite() {
steps * tick
} else {
last
};
let above = (rows / 2) as f64;
(0..rows)
.map(|ix| middle + (above - ix as f64) * tick)
.collect()
}
@@ -204,7 +205,7 @@ impl RenderOnce for DomLadder {
let colors = theme.colors.clone();
let near = |side: &[(f64, f64)], at: f64| {
side.iter()
.find(|(level, _)| (level - at).abs() < self.tick / 2.0)
.find(|(level, _)| (level - at).abs() <= self.tick / 2.0)
.map(|(_, size)| *size)
};
let deepest = self
@@ -213,13 +214,14 @@ impl RenderOnce for DomLadder {
.chain(&self.asks)
.map(|(_, size)| *size)
.fold(0.0, f64::max);
let last = (self.last / self.tick).round() * self.tick;
let middle = self.rows / 2;
let rows = rungs(self.last, self.tick, self.rows)
.into_iter()
.map(|at| {
.enumerate()
.map(|(ix, at)| {
let (bid, ask) = (near(&self.bids, at), near(&self.asks, at));
let side_cell = |size: Option<f64>, side: Side, ink: Hsla| {
let key = format!("{}-{:?}", at, side);
let key = format!("{at}-{ix}-{side:?}");
let body = level(
size.unwrap_or(0.0) / deepest.max(f64::EPSILON),
ink,
@@ -231,6 +233,7 @@ impl RenderOnce for DomLadder {
let trade = self.on_trade.clone();
div()
.id((self.id.clone(), SharedString::from(key)))
.debug_selector(move || format!("ladder-cell-{ix}-{side:?}"))
.flex_1()
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
@@ -243,7 +246,7 @@ impl RenderOnce for DomLadder {
})
.child(body)
};
let marked = (at - last).abs() < self.tick / 2.0;
let marked = ix == middle;
div()
.flex()
.items_center()
@@ -257,6 +260,7 @@ impl RenderOnce for DomLadder {
.text_size(theme.text_size(TextSize::Sm))
.when(marked, |price| {
price
.debug_selector(|| "ladder-last".into())
.bg(colors.active)
.text_color(colors.fg)
.font_weight(FontWeight::SEMIBOLD)
@@ -281,6 +285,18 @@ impl RenderOnce for DomLadder {
mod tests {
use super::rungs;
#[test]
fn a_tick_finer_than_the_price_repeats_rungs() {
for tick in [1e-18, 1e-310] {
let prices = rungs(10.0, tick, 15);
assert_eq!(prices.len(), 15);
assert!(
prices.iter().all(|price| (price - 10.0).abs() < 1e-9),
"{prices:?}"
);
}
}
#[test]
fn rungs_step_around_the_last() {
let prices = rungs(100.02, 0.05, 5);
+202 -4
View File
@@ -1,14 +1,16 @@
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement, Render, Styled, TestAppContext,
VisualTestContext, Window, div, px,
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement, Render, Styled,
TestAppContext, VisualTestContext, Window, div, px,
};
use jiff::Timestamp;
use crate::finance::{
CandlestickChart, ChartSync, CurrencyConverter, DepthChart, Leg, MarketHeatmap, PayoffDiagram,
PointFigureChart, RenkoChart, candles::Candle, stage::Visible,
Arrangement, BidAskBar, CandlestickChart, ChartSync, CryptoWalletCard, CurrencyConverter,
DepthChart, DomLadder, IntervalSelector, Leg, LeverageSlider, MarginIndicator, MarketHeatmap,
MultiChartLayout, PayoffDiagram, PerformanceChart, PointFigureChart, QuickTradeButtons,
RenkoChart, SpreadIndicator, TimeRangeSelector, candles::Candle, stage::Visible,
};
use crate::{layout::tests::narrow_width, theme::Theme};
@@ -174,3 +176,199 @@ fn a_squeezed_converter_keeps_room_for_its_currencies(cx: &mut TestAppContext) {
.expect("the second currency's text");
assert_eq!(text.size.width, px(26.), "the frame's inset for its name");
}
fn leg() -> Leg {
Leg {
call: true,
strike: 1e-200,
quantity: 1.0,
premium: 0.0,
}
}
/// Candles whose prices are each of `closes`.
fn scaled(closes: &[f64]) -> Rc<Vec<Candle>> {
Rc::new(
closes
.iter()
.enumerate()
.map(|(ix, close)| {
let time = Timestamp::from_second(ix as i64 * 86_400).expect("a day");
Candle::new(time, (*close, *close, *close, *close), 1.0)
})
.collect(),
)
}
/// Market states a live feed reaches: a crossed book, no size, margin past equity, a lagging comparison.
struct Live(u8);
impl Render for Live {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let chart: AnyElement = match self.0 {
0 => DepthChart::new("crossed", [(101.0, 2.0)], [(100.0, 3.0)]).into_any_element(),
1 => SpreadIndicator::new(101.0, 100.0).into_any_element(),
2 => QuickTradeButtons::new("locked", 100.0, 100.0).into_any_element(),
3 => BidAskBar::new(0.0, 0.0).into_any_element(),
4 => LeverageSlider::new("leverage", 40.0, 20.0).into_any_element(),
5 => MarginIndicator::new("margin", 500.0, -200.0).into_any_element(),
6 => TimeRangeSelector::new("range", "2Y5D").into_any_element(),
7 => IntervalSelector::new("interval", "7s").into_any_element(),
8 => MultiChartLayout::new("layout", Arrangement::Four)
.cell(div())
.into_any_element(),
9 => CandlestickChart::new("compare", candles(20))
.compare("other", candles(12))
.into_any_element(),
10 => {
PerformanceChart::new("zero", ["a", "b", "c"], [0.0, 10.0, 12.0]).into_any_element()
}
11 => PerformanceChart::new("lagging", ["a", "b", "c"], [10.0, 11.0, 12.0])
.benchmark("index", [1.0, 2.0])
.into_any_element(),
12 => DepthChart::new(
"crossed-deep",
[(101.0, 1.0), (100.75, 1.0)],
[(100.0, 1.0), (100.25, 1.0)],
)
.into_any_element(),
13 => PerformanceChart::new("negative", ["a", "b", "c"], [-1.0, 1e-310, -1.0])
.into_any_element(),
14 => CandlestickChart::new("far", candles(2))
.compare("other", scaled(&[1e-299, 1e30]))
.into_any_element(),
15 => PayoffDiagram::new("narrow", [leg()], (1e-200, 1.000000000000001e-200), 1e-200)
.into_any_element(),
16 => PointFigureChart::new("fine", candles(3), 1e-18, 3).into_any_element(),
17 => RenkoChart::new("fine-bricks", candles(3), 1e-18).into_any_element(),
18 => DomLadder::new("fine-ladder", [(10.0, 1.0)], [(10.1, 1.0)], 10.0, 1e-18)
.into_any_element(),
_ => CryptoWalletCard::new(
"wallet",
("Coin", "Chain"),
(1.0, "BTC"),
(1.0, "USD"),
"x".repeat(8_000),
)
.into_any_element(),
};
div().w(px(480.0)).h(px(320.0)).child(chart)
}
}
#[gpui::test]
fn live_market_states_draw(cx: &mut TestAppContext) {
cx.update(Theme::init);
for case in 0..=19 {
let (_, cx) = cx.add_window_view(move |_, _| Live(case));
cx.run_until_parked();
}
}
/// A box too thin for a pixel, and a comparison its scale cannot hold.
struct Thin(bool);
impl Render for Thin {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let chart: AnyElement = match self.0 {
true => PointFigureChart::new("thin", scaled(&[100_000.0]), 1e-5, 3).into_any_element(),
false => CandlestickChart::new("unheld", candles(2))
.compare("retired", scaled(&[1e-299, 1e30]))
.into_any_element(),
};
div().w(px(480.0)).h(px(320.0)).child(chart)
}
}
#[gpui::test]
fn boxes_thinner_than_a_pixel_draw_as_a_block(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Thin(true));
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
#[gpui::test]
fn a_comparison_left_out_names_nothing(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Thin(false));
cx.run_until_parked();
assert!(cx.debug_bounds("chart-compared").is_none());
}
/// A ladder at a tick, last price and book; or a depth book over a subnormal span.
struct Fine(Option<(f64, f64)>);
impl Render for Fine {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let chart: AnyElement = match self.0 {
Some((last, tick)) => {
DomLadder::new("ladder", [(last, 1.0)], [(last + 1.0, 1.0)], last, tick)
.into_any_element()
}
None => DepthChart::new("subnormal", [(0.0, 1.0)], [(3e-323, 1.0)]).into_any_element(),
};
div().w(px(480.0)).h(px(320.0)).child(chart)
}
}
#[gpui::test]
fn a_ladder_marks_its_last_price_at_any_tick(cx: &mut TestAppContext) {
cx.update(Theme::init);
for (last, tick) in [
(10.0, 0.05),
(10.0, 1e-18),
(10.0, 1e-310),
(0.0, f64::from_bits(1)),
] {
let (_, cx) = cx.add_window_view(move |_, _| Fine(Some((last, tick))));
cx.run_until_parked();
assert!(cx.debug_bounds("ladder-last").is_some(), "{last} at {tick}");
}
}
#[gpui::test]
fn a_depth_book_over_a_subnormal_span_draws(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Fine(None));
cx.run_until_parked();
}
/// A ladder that keeps each trade it hears.
struct Trading(Rc<std::cell::RefCell<Vec<f64>>>, f64);
impl Render for Trading {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.0.clone();
div().w(px(480.0)).child(
DomLadder::new("ladder", [(10.0, 1.0)], [(10.1, 1.0)], 10.0, self.1)
.on_trade(move |_, at, _, _| heard.borrow_mut().push(at)),
)
}
}
#[gpui::test]
fn every_rung_trades_even_where_prices_repeat(cx: &mut TestAppContext) {
cx.update(Theme::init);
for tick in [0.05, 1e-18] {
let heard = Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = heard.clone();
let (_, cx) = cx.add_window_view(move |_, _| Trading(seen, tick));
cx.run_until_parked();
for row in [
"ladder-cell-0-Buy",
"ladder-cell-7-Buy",
"ladder-cell-14-Buy",
] {
heard.borrow_mut().clear();
let at = cx.debug_bounds(row).expect("a cell").center();
cx.simulate_mouse_move(at, None, gpui::Modifiers::none());
cx.simulate_click(at, gpui::Modifiers::none());
cx.run_until_parked();
assert_eq!(heard.borrow().len(), 1, "tick {tick}, row {row}");
}
}
}
+1 -1
View File
@@ -259,7 +259,7 @@ pub struct QuickTradeButtons {
impl QuickTradeButtons {
pub fn new(id: impl Into<ElementId>, bid: f64, ask: f64) -> Self {
assert!(bid < ask, "a bid sits below its ask");
assert!(bid.is_finite() && ask.is_finite(), "quotes are finite");
Self {
id: id.into(),
bid,
+13 -8
View File
@@ -208,9 +208,17 @@ impl RenderOnce for CryptoWalletCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let code = QrCode::new(self.address.as_bytes())
.expect("an address fits a code")
.size(theme.qr_code() * 0.6);
let code = match QrCode::new(self.address.as_bytes()) {
Ok(code) => code.size(theme.qr_code() * 0.6).into_any_element(),
Err(error) => {
log::error!("wallet card: no code for its address: {error:?}");
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.danger)
.child("Address too long for a code")
.into_any_element()
}
};
let address = self.address.clone();
div()
.flex()
@@ -403,11 +411,8 @@ impl RenderOnce for TransactionList {
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(
transfer
.time
.to_zoned(zone.clone())
.strftime("%b %-d, %H:%M")
.to_string(),
format::datetime(transfer.time, &zone, "%b %-d, %H:%M")
.expect("a fixed pattern"),
),
),
)