T16 Finance: market charts with kinds, volume, profile, studies, drawings, compare, alerts and sync (T16a–b); quotes, book, tape and DOM (T16c); order entry, blotter, risk, options and payoff (T16d); markets, calendars, portfolio and wallet (T16e); typography::Ellipsis ends lines that gpui's truncate clipped; chart notes stack and ticks label round values
This commit is contained in:
76 files changed
+10311
-173
No files matched your search
@@ -0,0 +1,378 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{Timestamp, civil::Date, tz::TimeZone};
|
||||
|
||||
use crate::{
|
||||
data_display::{Badge, Tone},
|
||||
forms::flag,
|
||||
tables::{Cell, Column, DataTable, Row},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::{Ellipsis, format, tabular},
|
||||
};
|
||||
|
||||
/// An economic release: when, for which region, what, how much it moves markets from one to three, and its figure as it came, as forecast and as before.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Release {
|
||||
pub time: Timestamp,
|
||||
pub region: SharedString,
|
||||
pub event: SharedString,
|
||||
pub weight: u8,
|
||||
pub actual: Option<f64>,
|
||||
pub forecast: Option<f64>,
|
||||
pub previous: Option<f64>,
|
||||
pub unit: SharedString,
|
||||
}
|
||||
|
||||
/// Economic releases by day: the time, the region's flag, the event and how much it matters, then the figure as it came beside the forecast and the one before.
|
||||
#[derive(IntoElement)]
|
||||
pub struct EconomicCalendar {
|
||||
releases: Vec<Release>,
|
||||
zone: TimeZone,
|
||||
}
|
||||
|
||||
impl EconomicCalendar {
|
||||
pub fn new(releases: impl IntoIterator<Item = Release>) -> Self {
|
||||
let releases: Vec<Release> = releases.into_iter().collect();
|
||||
assert!(
|
||||
releases
|
||||
.iter()
|
||||
.all(|release| (1..=3).contains(&release.weight)),
|
||||
"a release weighs one to three"
|
||||
);
|
||||
Self {
|
||||
releases,
|
||||
zone: TimeZone::system(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = zone;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EconomicCalendar {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
self.releases.sort_by_key(|release| release.time);
|
||||
let figure = |value: Option<f64>, unit: &SharedString| {
|
||||
value.map_or("—".to_string(), |value| {
|
||||
format!("{}{unit}", format::number(value, 1, format::Separators::EN))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<gpui::AnyElement> = Vec::new();
|
||||
let mut day: Option<Date> = None;
|
||||
for release in &self.releases {
|
||||
let zoned = release.time.to_zoned(self.zone.clone());
|
||||
if day != Some(zoned.date()) {
|
||||
day = Some(zoned.date());
|
||||
out.push(
|
||||
div()
|
||||
.pt_3()
|
||||
.pb_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(zoned.strftime("%A, %B %-d").to_string())
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
let dots = (1..=3u8).map(|step| {
|
||||
div()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.bg(if step <= release.weight {
|
||||
colors.fg
|
||||
} else {
|
||||
colors.border
|
||||
})
|
||||
});
|
||||
let beat = release
|
||||
.actual
|
||||
.zip(release.forecast)
|
||||
.map(|(actual, forecast)| actual.total_cmp(&forecast));
|
||||
let actual_ink = match beat {
|
||||
Some(std::cmp::Ordering::Greater) => colors.success,
|
||||
Some(std::cmp::Ordering::Less) => colors.danger,
|
||||
_ => colors.fg,
|
||||
};
|
||||
let number = |words: String, ink| {
|
||||
tabular(div())
|
||||
.w(theme.label_width() * 0.45)
|
||||
.text_right()
|
||||
.text_color(ink)
|
||||
.child(words)
|
||||
};
|
||||
out.push(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.py_1p5()
|
||||
.border_b_1()
|
||||
.border_color(colors.border.opacity(0.5))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
tabular(div())
|
||||
.w(theme.label_width() * 0.3)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(zoned.strftime("%H:%M").to_string()),
|
||||
)
|
||||
.child(div().child(flag(&release.region)))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg)
|
||||
.child(Ellipsis::new(release.event.clone())),
|
||||
)
|
||||
.child(div().flex().gap_0p5().children(dots))
|
||||
.child(number(figure(release.actual, &release.unit), actual_ink))
|
||||
.child(number(
|
||||
figure(release.forecast, &release.unit),
|
||||
colors.fg_muted,
|
||||
))
|
||||
.child(number(
|
||||
figure(release.previous, &release.unit),
|
||||
colors.fg_subtle,
|
||||
))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
let heading = |words: &'static str| {
|
||||
div()
|
||||
.w(theme.label_width() * 0.45)
|
||||
.text_right()
|
||||
.child(words)
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(heading("Actual"))
|
||||
.child(heading("Forecast"))
|
||||
.child(heading("Previous")),
|
||||
)
|
||||
.children(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A company's earnings date: when it reports, before the open or after the close, and earnings per share as expected and, once reported, as they came.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Earnings {
|
||||
pub date: Date,
|
||||
pub symbol: SharedString,
|
||||
pub name: SharedString,
|
||||
pub before_open: bool,
|
||||
pub estimate: f64,
|
||||
pub reported: Option<f64>,
|
||||
}
|
||||
|
||||
/// Earnings by date: each company, when in the day it reports, what is expected, and once reported, how far it beat or missed.
|
||||
#[derive(IntoElement)]
|
||||
pub struct EarningsCalendar {
|
||||
id: ElementId,
|
||||
earnings: Vec<Earnings>,
|
||||
}
|
||||
|
||||
impl EarningsCalendar {
|
||||
pub fn new(id: impl Into<ElementId>, earnings: impl IntoIterator<Item = Earnings>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
earnings: earnings.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EarningsCalendar {
|
||||
fn render(mut self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
self.earnings.sort_by_key(|earnings| earnings.date);
|
||||
let columns = [
|
||||
Column::new("date", "Date"),
|
||||
Column::new("symbol", "Symbol"),
|
||||
Column::new("name", "Company"),
|
||||
Column::new("when", "When"),
|
||||
Column::new("estimate", "Expected").end().decimals(2),
|
||||
Column::new("reported", "Reported").end().decimals(2),
|
||||
Column::new("surprise", "Surprise").end(),
|
||||
];
|
||||
let rows: Vec<Row> = self
|
||||
.earnings
|
||||
.iter()
|
||||
.map(|earnings| {
|
||||
let when = Cell::Tag(
|
||||
if earnings.before_open {
|
||||
"Before open"
|
||||
} else {
|
||||
"After close"
|
||||
}
|
||||
.into(),
|
||||
Tone::Neutral,
|
||||
);
|
||||
let surprise = match earnings.reported {
|
||||
Some(reported) if earnings.estimate != 0.0 => {
|
||||
let share = reported / earnings.estimate - 1.0;
|
||||
Cell::Tag(
|
||||
format::percent(share, 1, true).into(),
|
||||
if share >= 0.0 {
|
||||
Tone::Success
|
||||
} else {
|
||||
Tone::Danger
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => Cell::Empty,
|
||||
};
|
||||
let reported = earnings.reported.map_or(Cell::Empty, Cell::from);
|
||||
Row::new(
|
||||
earnings.symbol.clone(),
|
||||
[
|
||||
Cell::Text(earnings.date.strftime("%b %-d").to_string().into()),
|
||||
Cell::Text(earnings.symbol.clone()),
|
||||
Cell::Text(earnings.name.clone()),
|
||||
when,
|
||||
earnings.estimate.into(),
|
||||
reported,
|
||||
surprise,
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
DataTable::new(self.id, columns).rows(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// A story: where it ran, when, its headline, the symbols it touches, and which way it leans for them, if either.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Story {
|
||||
pub source: SharedString,
|
||||
pub time: Timestamp,
|
||||
pub headline: SharedString,
|
||||
pub symbols: Vec<SharedString>,
|
||||
pub lean: Option<bool>,
|
||||
}
|
||||
|
||||
type OnStory = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// Stories newest first: a dot for how each leans, where and when it ran, its headline, and the symbols it touches. A press opens one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct NewsFeed {
|
||||
id: ElementId,
|
||||
stories: Vec<Story>,
|
||||
zone: TimeZone,
|
||||
on_open: Option<OnStory>,
|
||||
}
|
||||
|
||||
impl NewsFeed {
|
||||
pub fn new(id: impl Into<ElementId>, stories: impl IntoIterator<Item = Story>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
stories: stories.into_iter().collect(),
|
||||
zone: TimeZone::system(),
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = zone;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the place of the story pressed, as given.
|
||||
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NewsFeed {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mut order: Vec<usize> = (0..self.stories.len()).collect();
|
||||
order.sort_by_key(|ix| std::cmp::Reverse(self.stories[*ix].time));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(order.into_iter().map(|ix| {
|
||||
let story = &self.stories[ix];
|
||||
let lean = match story.lean {
|
||||
Some(true) => colors.success,
|
||||
Some(false) => colors.danger,
|
||||
None => colors.border_strong,
|
||||
};
|
||||
let open = self.on_open.clone();
|
||||
let when = story
|
||||
.time
|
||||
.to_zoned(self.zone.clone())
|
||||
.strftime("%b %-d, %H:%M")
|
||||
.to_string();
|
||||
div()
|
||||
.id((self.id.clone(), format!("story-{ix}")))
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_2p5()
|
||||
.rounded(theme.radius(crate::theme::Radius::Md))
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(open, |row, open| {
|
||||
row.on_click(move |_, window, cx| {
|
||||
log::info!("news feed: opened story {ix}");
|
||||
open(ix, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.pt_1p5()
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(lean)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(story.source.clone()),
|
||||
)
|
||||
.child(div().text_color(colors.fg_subtle).child(when)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(story.headline.clone()),
|
||||
)
|
||||
.child(
|
||||
div().flex().gap_1().children(
|
||||
story
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|symbol| Badge::new(symbol.clone())),
|
||||
),
|
||||
),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user