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

359 lines
11 KiB
Rust

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<ElementId>,
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<SharedString>, 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<f64>| {
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<f64> {
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<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()
}
/// 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<SharedString>,
equity: Vec<f64>,
benchmark: Option<(SharedString, Vec<f64>)>,
}
impl PerformanceChart {
/// The portfolio's value at each label, oldest first.
pub fn new(
id: impl Into<ElementId>,
labels: impl IntoIterator<Item = impl Into<SharedString>>,
equity: impl IntoIterator<Item = f64>,
) -> Self {
let labels: Vec<SharedString> = labels.into_iter().map(Into::into).collect();
let equity: Vec<f64> = 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<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));
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<Dividend>,
currency: SharedString,
}
impl DividendTable {
pub fn new(
id: impl Into<ElementId>,
dividends: impl IntoIterator<Item = Dividend>,
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<Row> = 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<f64>,
pub lines: Vec<Statement>,
}
impl Statement {
pub fn new(name: impl Into<SharedString>, values: impl IntoIterator<Item = f64>) -> Self {
Self {
name: name.into(),
values: values.into_iter().collect(),
lines: Vec::new(),
}
}
pub fn lines(mut self, lines: impl IntoIterator<Item = Statement>) -> 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<SharedString>,
lines: Vec<Statement>,
}
impl FinancialStatementTable {
pub fn new(
id: impl Into<ElementId>,
periods: impl IntoIterator<Item = impl Into<SharedString>>,
lines: impl IntoIterator<Item = Statement>,
) -> Self {
let periods: Vec<SharedString> = periods.into_iter().map(Into::into).collect();
let lines: Vec<Statement> = 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<TreeRow> = self
.lines
.iter()
.enumerate()
.map(|(ix, line)| row(line, format!("line-{ix}")))
.collect();
let open: Vec<String> = (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:?}"
);
}
}