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:
1 parent
a6b1bb0929
commit
f0b7f779f6
18 files changed
+504
-140
No files matched your search
+6
-1
@@ -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
@@ -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}"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user