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,358 @@
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user