Files
Ely-GPUI-Components/src/finance/calendars.rs
T

407 lines
15 KiB
Rust

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::{self, system_zone},
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: Option<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: None,
}
}
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
}
impl RenderOnce for EconomicCalendar {
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self
.zone
.clone()
.unwrap_or_else(|| system_zone("EconomicCalendar"));
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(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(
format::datetime(release.time, &zone, "%A, %B %-d")
.expect("a fixed pattern"),
)
.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(
format::datetime(release.time, &zone, "%H:%M")
.expect("a fixed pattern"),
),
)
.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>,
}
/// How far a report beat its estimate, as a share of the estimate's size: positive when better, whatever the sign.
pub(crate) fn surprise(estimate: f64, reported: f64) -> f64 {
(reported - estimate) / estimate.abs()
}
/// 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 = surprise(earnings.estimate, reported);
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: Option<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: None,
on_open: None,
}
}
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(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 zone = self.zone.clone().unwrap_or_else(|| system_zone("NewsFeed"));
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 =
format::datetime(story.time, &zone, "%b %-d, %H:%M").expect("a fixed pattern");
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())),
),
),
)
}))
}
}
#[cfg(test)]
mod tests {
use super::surprise;
#[test]
fn a_smaller_loss_than_expected_is_a_beat() {
assert_eq!(surprise(-1.0, -0.5), 0.5);
assert_eq!(surprise(2.0, 1.5), -0.25);
}
}