use gpui::{ App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, }; use jiff::civil::Date; use crate::{ charts::{AreaChart, LineChart, Series}, data_display::{KpiCard, Statistic, Tone, TrendIndicator, UsageBar}, tables::{Cell, Column, DataTable, Row, TreeRow, TreeTable}, theme::ActiveTheme, typography::format, }; /// A portfolio at a glance: what it is worth, how it moved today and in all, the cash in it, and how it splits across holdings. #[derive(IntoElement)] pub struct PortfolioSummary { id: ElementId, value: f64, day: (f64, f64), total: (f64, f64), cash: f64, currency: SharedString, holdings: Vec<(SharedString, f64)>, } impl PortfolioSummary { /// Its worth, today's move and the move since bought, each an amount and a share, and its cash, in an ISO 4217 currency. pub fn new( id: impl Into, value: f64, (day, total): ((f64, f64), (f64, f64)), cash: f64, currency: &str, ) -> Self { Self { id: id.into(), value, day, total, cash, currency: currency.to_string().into(), holdings: Vec::new(), } } /// A holding and what it is worth; with the cash they fill the bar below. pub fn holding(mut self, name: impl Into, worth: f64) -> Self { self.holdings.push((name.into(), worth)); self } } impl RenderOnce for PortfolioSummary { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let (symbol, places, _) = format::currency_parts(&self.currency); let symbol = SharedString::from(symbol.to_string()); let figure = |key: &'static str, label: &'static str, value: f64, trend: Option| { let statistic = Statistic::new((self.id.clone(), key), label, value) .decimals(places) .prefix(symbol.clone()); KpiCard::new(match trend { Some(share) => statistic.trend(TrendIndicator::new(share)), None => statistic, }) }; let bar = self.holdings.iter().fold( UsageBar::new(self.value.max(f64::EPSILON)), |bar, (name, worth)| bar.part(name.clone(), *worth), ); let bar = if self.cash > 0.0 { bar.part("Cash", self.cash) } else { bar }; div() .flex() .flex_col() .gap_4() .child( div() .flex() .gap_4() .child(figure("value", "Worth", self.value, None).flex_1()) .child(figure("day", "Today", self.day.0, Some(self.day.1)).flex_1()) .child( figure("total", "Since bought", self.total.0, Some(self.total.1)).flex_1(), ) .child(figure("cash", "Cash", self.cash, None).flex_1()), ) .child(bar) } } /// How far below its highest so far each value sits, as a share: zero at a new high. pub(crate) fn drawdown(values: &[f64]) -> Vec { let mut high = f64::MIN; values .iter() .map(|value| { high = high.max(*value); if high > 0.0 { value / high - 1.0 } else { 0.0 } }) .collect() } /// Each value as a share gained since the first. fn growth(values: &[f64]) -> Vec { 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() } /// How a portfolio grew against a benchmark, both from the same start, and below it how far it fell from each high. #[derive(IntoElement)] pub struct PerformanceChart { id: ElementId, labels: Vec, equity: Vec, benchmark: Option<(SharedString, Vec)>, } impl PerformanceChart { /// The portfolio's value at each label, oldest first. pub fn new( id: impl Into, labels: impl IntoIterator>, equity: impl IntoIterator, ) -> Self { let labels: Vec = labels.into_iter().map(Into::into).collect(); let equity: Vec = equity.into_iter().collect(); assert!( equity.len() == labels.len() && equity.len() >= 2, "a curve needs a value per label and two of them" ); Self { id: id.into(), labels, equity, benchmark: None, } } /// A benchmark's values at the same labels. pub fn benchmark( mut self, name: impl Into, values: impl IntoIterator, ) -> Self { let values: Vec = values.into_iter().collect(); assert_eq!( values.len(), self.labels.len(), "a benchmark needs a value per label" ); self.benchmark = Some((name.into(), values)); self } } impl RenderOnce for PerformanceChart { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let height = cx.theme().chart().height; let percent = |value: f64| format::percent(value, 0, true); let curve = LineChart::new((self.id.clone(), "growth"), self.labels.clone()) .series(Series::new("Portfolio", growth(&self.equity))); let curve = match &self.benchmark { Some((name, values)) => curve.series(Series::new(name.clone(), growth(values))), None => curve, }; let falls = AreaChart::new((self.id.clone(), "drawdown"), self.labels) .series(Series::new("Drawdown", drawdown(&self.equity))); div() .flex() .flex_col() .gap_2() .child(curve.format(percent).h(height * 0.8)) .child(falls.format(percent).h(height * 0.4)) } } /// A dividend: when shares must be held by, when it pays, how much for each share, its yield as a share, and how often it pays. #[derive(Clone, Debug, PartialEq)] pub struct Dividend { pub ex: Date, pub pay: Date, pub amount: f64, pub dividend_yield: f64, pub frequency: SharedString, } /// Dividends by date: when to hold by, when each pays, how much, its yield and how often. #[derive(IntoElement)] pub struct DividendTable { id: ElementId, dividends: Vec, currency: SharedString, } impl DividendTable { pub fn new( id: impl Into, dividends: impl IntoIterator, currency: &str, ) -> Self { Self { id: id.into(), dividends: dividends.into_iter().collect(), currency: currency.to_string().into(), } } } impl RenderOnce for DividendTable { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let (symbol, places, _) = format::currency_parts(&self.currency); let columns = [ Column::new("ex", "Hold by"), Column::new("pay", "Pays"), Column::new("amount", "Amount") .end() .decimals(places) .prefix(symbol.to_string()), Column::new("yield", "Yield").end(), Column::new("frequency", "Frequency"), ]; let day = |date: Date| Cell::Text(date.strftime("%b %-d, %Y").to_string().into()); let rows: Vec = self .dividends .iter() .enumerate() .map(|(ix, dividend)| { Row::new( format!("dividend-{ix}"), [ day(dividend.ex), day(dividend.pay), dividend.amount.into(), Cell::Text(format::percent(dividend.dividend_yield, 2, false).into()), Cell::Tag(dividend.frequency.clone(), Tone::Neutral), ], ) }) .collect(); DataTable::new(self.id, columns).rows(rows) } } /// A line of a financial statement: its name, a value for each period, and the lines inside it. #[derive(Clone, Debug, PartialEq)] pub struct Statement { pub name: SharedString, pub values: Vec, pub lines: Vec, } impl Statement { pub fn new(name: impl Into, values: impl IntoIterator) -> Self { Self { name: name.into(), values: values.into_iter().collect(), lines: Vec::new(), } } pub fn lines(mut self, lines: impl IntoIterator) -> Self { self.lines.extend(lines); self } } /// A financial statement by period: its lines as rows that open to the lines inside them, a column for each period. #[derive(IntoElement)] pub struct FinancialStatementTable { id: ElementId, periods: Vec, lines: Vec, } impl FinancialStatementTable { pub fn new( id: impl Into, periods: impl IntoIterator>, lines: impl IntoIterator, ) -> Self { let periods: Vec = periods.into_iter().map(Into::into).collect(); let lines: Vec = lines.into_iter().collect(); fn fits(lines: &[Statement], count: usize) -> bool { lines .iter() .all(|line| line.values.len() == count && fits(&line.lines, count)) } assert!( fits(&lines, periods.len()), "every line needs a value per period" ); Self { id: id.into(), periods, lines, } } } impl RenderOnce for FinancialStatementTable { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { fn row(line: &Statement, key: String) -> TreeRow { let cells = std::iter::once(Cell::Text(line.name.clone())) .chain(line.values.iter().map(|value| Cell::from(*value))); TreeRow::new(key.clone(), cells).children( line.lines .iter() .enumerate() .map(|(ix, inner)| row(inner, format!("{key}-{ix}"))), ) } let columns = std::iter::once(Column::new("line", "")).chain( self.periods.iter().enumerate().map(|(ix, period)| { Column::new(format!("period-{ix}"), period.clone()) .end() .decimals(0) }), ); let rows: Vec = self .lines .iter() .enumerate() .map(|(ix, line)| row(line, format!("line-{ix}"))) .collect(); let open: Vec = (0..self.lines.len()) .map(|ix| format!("line-{ix}")) .collect(); TreeTable::new(self.id, columns, rows).open(open) } } #[cfg(test)] mod tests { use super::{drawdown, growth}; #[test] fn falls_read_from_each_high_and_growth_from_the_start() { assert_eq!( 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!( grown .iter() .zip([0.0, 0.1, -0.1]) .all(|(got, want)| (got - want).abs() < 1e-12), "{grown:?}" ); } }