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"),
),
),
)
+3 -1
View File
@@ -30,7 +30,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca
- [x] P13 Lists & Trees — `src/lists`
- [x] P14 Tables — `src/tables`
- [x] P15 Charts — `src/charts`
- [ ] P16 Finance — `src/finance`
- [x] P16 Finance — `src/finance`
- [ ] P17 Code Editor — `src/editor`
- [ ] P18 Terminal — `src/terminal`
- [ ] P19 Git — `src/git`
@@ -80,3 +80,5 @@ One line per item: what changed, what stayed and why, review rounds.
- P13: a directory listing's selected name gone from its entries picks none. Kept: one-value contracts; activation, folder split, single-select sections and tree rows hold by construction. Review: 1 round.
- P14: a pivot field missing from its data draws an error line; a gone group-by column leaves the table ungrouped; a merge past the sheet is skipped; each logs. Kept: row shapes of one snapshot, one-value contracts, builder misuse, construction. Review: 1 round.
- P15: chart values are finite and within ±`charts::LIMIT` (1e30), so sums and pixels stay in `f32`; scales work in halves and bound a share to ±1e4 ranges; chord and sankey size by shares; quartiles, weekday labels, flat tangents and a zero-width sankey's hover hold; notes, links, edges and waits past their lists are skipped. Review: 5 rounds, the cap; round 5's finding (points at one x) is fixed and tested, and P16's review checks it.
- P16: crossed and locked books, empty sizes, leverage and margin past their bounds, stale range and interval keys, short layouts and comparisons, growth from zero, long QR addresses and grids too fine to draw all log and show; ladders mark their middle rung and key cells by row; every system-zone time goes through `datetime`; `nice_step` stays positive. Kept: one-snapshot shapes and one-value contracts. Review: 5 rounds, passed on the fifth; it also passed P15's last fix.
- Noted for later items: `chat/params.rs` and `editor/settings.rs` pass `value.abs()` to `decimals`, which asserts a positive step.