diff --git a/AGENTS.md b/AGENTS.md index f112fe1..3192c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,10 +98,12 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. - Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). +- gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. +- Rise reads green and fall red unless a component's `red_up` swaps them, as markets in East Asia read. `finance::quotes::moves` picks the pair. - Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. - A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. - Tokens live in `src/theme`; the raw `px()` scan skips that folder only. diff --git a/TASKS.md b/TASKS.md index fc7c84e..0a29274 100644 --- a/TASKS.md +++ b/TASKS.md @@ -216,3 +216,8 @@ Per-component lines live in `tasks/`. Tags there: | T15b | 2 | PASS | | | T15c | 1 | FAIL | eight charts kept a pointed part past their data, and the Gantt today line ran past the timeline | | T15c | 2 | PASS | | +| T16a | 1 | — | pending review | +| T16b | 1 | — | pending review | +| T16c | 1 | — | pending review | +| T16d | 1 | — | pending review | +| T16e | 1 | — | pending review | diff --git a/assets/icons/align-justify.svg b/assets/icons/align-justify.svg new file mode 100644 index 0000000..ed9caa3 --- /dev/null +++ b/assets/icons/align-justify.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/chart-area.svg b/assets/icons/chart-area.svg new file mode 100644 index 0000000..c06957c --- /dev/null +++ b/assets/icons/chart-area.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/chart-column.svg b/assets/icons/chart-column.svg new file mode 100644 index 0000000..fdf27dd --- /dev/null +++ b/assets/icons/chart-column.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/columns-2.svg b/assets/icons/columns-2.svg new file mode 100644 index 0000000..658ac75 --- /dev/null +++ b/assets/icons/columns-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/rows-2.svg b/assets/icons/rows-2.svg new file mode 100644 index 0000000..f059365 --- /dev/null +++ b/assets/icons/rows-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/pages/charts/cartesian.rs b/examples/gallery/pages/charts/cartesian.rs index 01fb032..d77089b 100644 --- a/examples/gallery/pages/charts/cartesian.rs +++ b/examples/gallery/pages/charts/cartesian.rs @@ -12,24 +12,13 @@ use gpui::{App, ClipboardItem, IntoElement, ParentElement, SharedString, Styled, use crate::{ probe::probe, - ui::{keep, row, section, set}, + ui::{keep, noise, row, section, set}, }; const MONTHS: [&str; 12] = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; -/// A steady pseudo-random series, so captures repeat. -pub(super) fn noise(seed: u64) -> impl FnMut() -> f64 { - let mut state = seed; - move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (state >> 33) as f64 / (1u64 << 31) as f64 - } -} - pub fn lines(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let turn = keep("lines-turn", || 0usize, window, cx); let (now, next) = (*turn.read(cx), turn.clone()); diff --git a/examples/gallery/pages/charts/flows.rs b/examples/gallery/pages/charts/flows.rs index 4e5ccae..959adb0 100644 --- a/examples/gallery/pages/charts/flows.rs +++ b/examples/gallery/pages/charts/flows.rs @@ -5,8 +5,10 @@ use ely_gpui_component::charts::{ use gpui::{App, IntoElement, ParentElement, Styled, div, px}; use jiff::civil::date; -use super::cartesian::noise; -use crate::{probe::probe, ui::section}; +use crate::{ + probe::probe, + ui::{noise, section}, +}; pub fn flows(cx: &mut App) -> impl IntoElement + use<> { let budget = SankeyChart::new( diff --git a/examples/gallery/pages/charts/spread.rs b/examples/gallery/pages/charts/spread.rs index 9f92f22..99b8cb0 100644 --- a/examples/gallery/pages/charts/spread.rs +++ b/examples/gallery/pages/charts/spread.rs @@ -1,8 +1,7 @@ use ely_gpui_component::charts::{BoxPlot, Histogram, ViolinPlot, WaterfallChart}; use gpui::{App, IntoElement, ParentElement, Styled, div, px}; -use super::cartesian::noise; -use crate::ui::section; +use crate::ui::{noise, section}; pub fn distributions(cx: &mut App) -> impl IntoElement + use<> { let mut rng = noise(13); diff --git a/examples/gallery/pages/charts/wholes.rs b/examples/gallery/pages/charts/wholes.rs index 4501317..8fcaa16 100644 --- a/examples/gallery/pages/charts/wholes.rs +++ b/examples/gallery/pages/charts/wholes.rs @@ -4,8 +4,10 @@ use ely_gpui_component::charts::{ use gpui::{App, IntoElement, ParentElement, Styled, div, px}; use jiff::civil::date; -use super::cartesian::noise; -use crate::{probe::probe, ui::section}; +use crate::{ + probe::probe, + ui::{noise, section}, +}; pub fn pies(cx: &mut App) -> impl IntoElement + use<> { let traffic = [ diff --git a/examples/gallery/pages/finance/assets.rs b/examples/gallery/pages/finance/assets.rs new file mode 100644 index 0000000..52bd42e --- /dev/null +++ b/examples/gallery/pages/finance/assets.rs @@ -0,0 +1,223 @@ +use std::time::{Duration, Instant}; + +use ely_gpui_component::{ + charts::PieChart, + data_display::Gauge, + feedback::Countdown, + finance::{ + CryptoWalletCard, CurrencyConverter, Dividend, DividendTable, FinancialStatementTable, + PerformanceChart, PortfolioSummary, Settled, Statement, TransactionList, Transfer, + }, + theme::TextSize, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, civil::date, tz::TimeZone}; + +use crate::ui::{keep, noise, row, section, set}; + +const HOLDINGS: [(&str, f64); 5] = [ + ("ELY", 48_210.0), + ("SOLACE", 31_050.0), + ("Bonds", 26_400.0), + ("NOVA", 12_480.0), + ("PINE", 8_196.0), +]; + +pub fn portfolio(cx: &mut App) -> impl IntoElement + use<> { + let cash = 9_864.0; + let worth = HOLDINGS.iter().map(|(_, worth)| worth).sum::() + cash; + let summary = HOLDINGS.iter().fold( + PortfolioSummary::new( + "portfolio", + worth, + ((1_284.50, 0.0092), (18_402.3, 0.1553)), + cash, + "USD", + ), + |summary, (name, worth)| summary.holding(*name, *worth), + ); + let allocation = HOLDINGS + .iter() + .fold(PieChart::new("allocation"), |pie, (name, worth)| { + pie.slice(*name, *worth) + }) + .slice("Cash", cash) + .donut("$136 K", "Invested"); + let months = [ + "Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", + ]; + let mut rng = noise(83); + let (mut equity, mut index) = (100.0, 100.0); + let (equity, index): (Vec, Vec) = months + .iter() + .map(|_| { + equity *= 1.0 + (rng() - 0.38) * 0.07; + index *= 1.0 + (rng() - 0.42) * 0.04; + (equity, index) + }) + .unzip(); + section( + "PortfolioSummary / AssetAllocationChart / PerformanceChart / SentimentGauge", + "What the portfolio is worth and how it moved, split across holdings; the same split as a donut; its growth against the market with each fall from a high below; and the market's mood on a dial.", + cx, + ) + .child(summary) + .child( + div() + .flex() + .gap_8() + .items_center() + .child(div().w(px(420.)).child(allocation)) + .child(Gauge::new("sentiment", "Sentiment", 64.0, 0.0, 100.0).decimals(0)), + ) + .child(PerformanceChart::new("performance", months, equity).benchmark("EGX 500", index)) +} + +pub fn statements(cx: &mut App) -> impl IntoElement + use<> { + let dividends = [ + (date(2025, 11, 7), date(2025, 11, 14), 0.24), + (date(2026, 2, 6), date(2026, 2, 13), 0.24), + (date(2026, 5, 8), date(2026, 5, 15), 0.26), + (date(2026, 8, 7), date(2026, 8, 14), 0.26), + ] + .map(|(ex, pay, amount)| Dividend { + ex, + pay, + amount, + dividend_yield: amount * 4.0 / 187.25, + frequency: "Quarterly".into(), + }); + let line = |name: &str, values: [f64; 4]| Statement::new(name.to_string(), values); + let lines = [ + line("Revenue", [18_420.0, 19_106.0, 20_388.0, 21_950.0]).lines([ + line("Products", [11_210.0, 11_380.0, 11_902.0, 12_430.0]), + line("Services", [7_210.0, 7_726.0, 8_486.0, 9_520.0]), + ]), + line("Cost of revenue", [-7_920.0, -8_120.0, -8_480.0, -8_940.0]), + line( + "Operating expenses", + [-5_880.0, -6_010.0, -6_240.0, -6_530.0], + ) + .lines([ + line("Research", [-3_120.0, -3_210.0, -3_360.0, -3_520.0]), + line("Sales and admin", [-2_760.0, -2_800.0, -2_880.0, -3_010.0]), + ]), + line("Operating income", [4_620.0, 4_976.0, 5_668.0, 6_480.0]), + line("Net income", [3_710.0, 3_990.0, 4_560.0, 5_190.0]), + ]; + section( + "DividendTable / FinancialStatementTable", + "Dividends by date with their yield, and a statement by quarter whose lines open into their parts.", + cx, + ) + .child(DividendTable::new("dividends", dividends, "USD")) + .child(FinancialStatementTable::new( + "statement", + ["Q4 2025", "Q1 2026", "Q2 2026", "Q3 2026"], + lines, + )) +} + +/// Demo rates in US dollars per unit; codes outside the table get a steady made-up rate. +fn dollars(code: &str) -> f64 { + match code { + "USD" => 1.0, + "EUR" => 1.09, + "GBP" => 1.27, + "CHF" => 1.13, + "CAD" => 0.73, + "AUD" => 0.66, + "SGD" => 0.75, + "HKD" => 0.128, + "CNY" => 0.14, + "INR" => 0.012, + "JPY" => 0.0067, + "KRW" => 0.00073, + _ => { + let seed = code + .bytes() + .fold(7, |seed: u64, byte| seed * 31 + u64::from(byte)); + 0.001 + noise(seed)() * 0.5 + } + } +} + +pub fn money(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let exchange = keep( + "finance-exchange", + || { + ( + 1_000.0, + SharedString::from("USD"), + SharedString::from("EUR"), + ) + }, + window, + cx, + ); + let (amount, from, to) = exchange.read(cx).clone(); + let store = exchange.clone(); + let candle = keep( + "finance-candle", + || Instant::now() + Duration::from_secs(222), + window, + cx, + ); + let until = *candle.read(cx); + let restart = candle.clone(); + let transfers = [ + (15, 40, "Salary", 6_250.0, "USD", Settled::Done), + (14, 5, "Card ending 4417", -86.40, "USD", Settled::Pending), + (11, 52, "Wallet bc1q…yg3c", 0.0421, "BTC", Settled::Done), + (9, 18, "Rent", -2_400.0, "USD", Settled::Done), + (8, 2, "Exchange withdrawal", -500.0, "EUR", Settled::Failed), + ] + .map(|(hour, minute, party, amount, unit, settled)| Transfer { + time: Timestamp::from_second(1_790_294_400 + hour * 3_600 + minute * 60).expect("a time"), + party: party.into(), + amount, + unit: unit.into(), + settled, + }); + section( + "CurrencyConverter / CryptoWalletCard / TransactionList / Candle Countdown", + "An amount in another currency, at an illustrative rate; a wallet with its balance and an address to scan; transfers in and out with whether each settled; and the time left in the current candle.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child( + div().w(px(460.)).child( + CurrencyConverter::new("converter", amount, (&from, &to), dollars(&from) / dollars(&to)) + .on_change(move |amount, from, to, _, cx| set(&store, (amount, from, to), cx)), + ), + ) + .child(div().flex_1().child(CryptoWalletCard::new( + "wallet", + ("Savings", "Bitcoin"), + (0.8412, "BTC"), + (54_310.22, "USD"), + "bc1q9h7garjy5kd5nwmrq2z6yufl0yq0p0a9w6yg3c", + ))), + ) + .child( + div() + .flex() + .gap_8() + .child(div().flex_1().child(TransactionList::new(transfers).zone(TimeZone::UTC))) + .child( + div() + .w(px(220.)) + .flex() + .flex_col() + .gap_2() + .child(Caption::new("5m candle closes in")) + .child(row().child(Countdown::new("candle", until).size(TextSize::Xl).on_done( + move |_, cx| set(&restart, Instant::now() + Duration::from_secs(300), cx), + ))), + ), + ) +} diff --git a/examples/gallery/pages/finance/book.rs b/examples/gallery/pages/finance/book.rs new file mode 100644 index 0000000..2b7f972 --- /dev/null +++ b/examples/gallery/pages/finance/book.rs @@ -0,0 +1,166 @@ +use ely_gpui_component::{ + finance::{ + BidAskBar, DomLadder, Level2Quotes, OrderBook, PriceChangeBadge, PriceText, QuoteCard, + Side, SpreadIndicator, TickerTape, TimeAndSales, Trade, + }, + theme::TextSize, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::markets::day; +use crate::ui::{keep, noise, row, section, set}; + +/// Prices, each with the size waiting there. +type Levels = Vec<(f64, f64)>; + +/// A book around a price: bids below it and asks above, a tick apart, sizes drawn steadily. +fn levels(seed: u64, middle: f64, tick: f64, count: usize) -> (Levels, Levels) { + let mut rng = noise(seed); + let bids = (0..count) + .map(|ix| { + ( + middle - tick * (ix as f64 + 1.0), + (120.0 + rng() * 800.0 + ix as f64 * 60.0).round(), + ) + }) + .collect(); + let asks = (0..count) + .map(|ix| { + ( + middle + tick * (ix as f64 + 1.0), + (120.0 + rng() * 800.0 + ix as f64 * 55.0).round(), + ) + }) + .collect(); + (bids, asks) +} + +pub fn quotes(cx: &mut App) -> impl IntoElement + use<> { + let tape = [ + ("ELY", 187.24, 0.0124), + ("NOVA", 62.10, -0.0081), + ("ORBIT", 131.52, 0.0203), + ("PINE", 27.33, -0.0152), + ("QUILL", 88.05, 0.0041), + ("RIVER", 45.60, 0.0), + ("SOLACE", 312.90, 0.0077), + ("TERRA", 9.84, -0.0312), + ] + .iter() + .fold(TickerTape::new("tape"), |tape, (symbol, now, share)| { + tape.quote(*symbol, *now, *share) + }); + let cards = [ + ("ELY", "Ely Systems", 41, 184.9), + ("NOVA", "Nova Energy", 43, 62.6), + ("ORBIT", "Orbit Freight", 47, 128.9), + ] + .map(|(symbol, name, seed, close)| { + let line = day(seed, close); + let now = f64::from(*line.last().expect("a day")); + div().flex_1().child(QuoteCard::new( + SharedString::from(format!("card-{symbol}")), + (symbol, name), + (now, close), + line, + )) + }); + section( + "Ticker / TickerTape / QuoteCard / PriceText / PriceChangeBadge", + "Quotes running past in a band, and symbols at a glance: the price flashes green or red as it moves, and the badge reads the move since the last close.", + cx, + ) + .child(div().w_full().child(tape)) + .child(div().flex().gap_4().children(cards)) + .child( + row() + .child(PriceText::new("price-up", 187.26).from(187.24).size(TextSize::Lg)) + .child(PriceText::new("price-down", 62.08).from(62.10).size(TextSize::Lg)) + .child(PriceChangeBadge::new(184.9, 187.26)) + .child(PriceChangeBadge::new(62.6, 62.08)) + .child(PriceChangeBadge::new(187.26, 187.26)), + ) +} + +pub fn book(cx: &mut App) -> impl IntoElement + use<> { + let (bids, asks) = levels(51, 187.25, 0.05, 12); + let (bid_size, ask_size) = ( + bids.iter().map(|level| level.1).sum::(), + asks.iter().map(|level| level.1).sum::(), + ); + let mut rng = noise(61); + let venues = ["NYSE", "ARCA", "BATS", "EDGX", "IEX", "NSDQ"]; + let level2 = (0..10).fold(Level2Quotes::new(), |quotes, ix| { + let venue = venues[ix % venues.len()]; + let step = (ix / 2) as f64 * 0.05; + quotes + .bid(venue, 187.20 - step, (100.0 + rng() * 900.0).round()) + .ask( + venues[(ix + 3) % venues.len()], + 187.30 + step, + (100.0 + rng() * 900.0).round(), + ) + }); + section( + "OrderBook / SpreadIndicator / BidAskBar / Level2 Quotes", + "Orders waiting on each side, best in the middle, each level shaded by its running total; the spread and the balance between sides; and each venue's quotes side by side.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child(div().w(px(320.)).child(OrderBook::new("book", bids.clone(), asks.clone()))) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_6() + .child(SpreadIndicator::new(bids[0].0, asks[0].0)) + .child(BidAskBar::new(bid_size, ask_size)) + .child(level2), + ), + ) +} + +pub fn tape(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let mut rng = noise(71); + let start = 1_790_342_400; + let trades: Vec = (0..40) + .map(|ix| { + let buy = rng() > 0.45; + Trade { + time: Timestamp::from_second(start + ix * 7).expect("a time"), + price: 187.25 + (rng() - 0.5) * 0.4, + size: (rng().powi(3) * 5_000.0 + 20.0).round(), + side: if buy { Side::Buy } else { Side::Sell }, + } + }) + .collect(); + let (bids, asks) = levels(81, 187.25, 0.05, 10); + let traded = keep("finance-traded", || None::, window, cx); + let (told, tell) = (traded.read(cx).clone(), traded.clone()); + section( + "TimeAndSales / TradeTape / MarketDepthLadder / DOM", + "Trades as they print, newest on top and large prints bold; and the book as a ladder around the last price. Press a size to trade at its price.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child(div().w(px(320.)).child(TimeAndSales::new("prints", trades).large(2_000.0).zone(TimeZone::UTC))) + .child( + div().flex_1().flex().flex_col().gap_2().child( + DomLadder::new("ladder", bids, asks, 187.25, 0.05).on_trade(move |side, at, _, cx| { + let words = format!("{} at {at:.2}", if side == Side::Buy { "Bid" } else { "Offer" }); + set(&tell, Some(words.into()), cx) + }), + ) + .children(told.map(|words| Caption::new(format!("Placed: {words}.")))), + ), + ) +} diff --git a/examples/gallery/pages/finance/markets.rs b/examples/gallery/pages/finance/markets.rs new file mode 100644 index 0000000..c080b5f --- /dev/null +++ b/examples/gallery/pages/finance/markets.rs @@ -0,0 +1,284 @@ +use std::rc::Rc; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + finance::{ + Arrangement, Candle, CandlestickChart, ChartKind, ChartSync, ChartTypeSwitcher, DepthChart, + Drawing, DrawingToolbar, IndicatorSelector, IntervalSelector, MultiChartLayout, Overlay, + PointFigureChart, RenkoChart, Study, TimeRangeSelector, Tool, + }, + theme::ActiveTheme, +}; +use gpui::{ + App, AppContext as _, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; +use jiff::Timestamp; + +use crate::{ + probe::probe, + ui::{keep, noise, row, section, set}, +}; + +/// A demo chart's width, inside the page's column. +pub(super) const WIDE: f32 = 840.0; + +/// Today's prices every five minutes from the last close, moving as a quiet stock does. +pub(super) fn day(seed: u64, close: f64) -> Vec { + let mut rng = noise(seed); + let mut price = close; + (0..78) + .map(|_| { + price *= 1.0 + (rng() - 0.5) * 0.004; + price as f32 + }) + .collect() +} + +/// A steady walk of candles `step` seconds apart from `start`, so captures repeat. +pub(super) fn walk(seed: u64, count: usize, start: f64, step: i64) -> Vec { + let mut rng = noise(seed); + let first = 1_767_364_200; + let mut close = start; + (0..count) + .map(|ix| { + let open = close; + let drift = (rng() - 0.47) * 0.034; + close = (open * (1.0 + drift)).max(1.0); + let high = open.max(close) * (1.0 + rng() * 0.011); + let low = open.min(close) * (1.0 - rng() * 0.011); + let volume = 1.0e6 * (0.5 + rng()) * (1.0 + drift.abs() * 24.0); + let time = Timestamp::from_second(first + ix as i64 * step).expect("a time in range"); + Candle::new(time, (open, high, low, close), volume) + }) + .collect() +} + +/// How many candles of an interval a range holds, and each candle's seconds. +fn span(range: &str, interval: &str) -> (usize, i64) { + let seconds = match interval { + "1m" => 60, + "5m" => 300, + "15m" => 900, + "1h" => 3_600, + "4h" => 14_400, + "1W" => 604_800, + "1M" => 2_592_000, + _ => 86_400, + }; + let days = match range { + "1D" => 1.0, + "5D" => 5.0, + "1M" => 22.0, + "3M" => 63.0, + "6M" => 126.0, + "YTD" => 180.0, + "1Y" => 252.0, + "5Y" => 1_260.0, + _ => 2_520.0, + }; + let per_day = (23_400.0 / seconds as f64).max(1.0 / (seconds as f64 / 86_400.0)); + (((days * per_day) as usize).clamp(12, 2_400), seconds) +} + +pub fn market(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kind = keep("finance-kind", || ChartKind::Candles, window, cx); + let range = keep("finance-range", || SharedString::from("6M"), window, cx); + let interval = keep("finance-interval", || SharedString::from("1D"), window, cx); + let (now_kind, now_range, now_interval) = ( + *kind.read(cx), + range.read(cx).clone(), + interval.read(cx).clone(), + ); + let (count, step) = span(&now_range, &now_interval); + let seed = 7 + now_interval.len() as u64 * 13 + step as u64 % 97; + let candles = walk(seed, count, 184.0, step); + let alert = (candles.last().expect("a range holds candles").close * 1.04).round(); + let (pick_kind, pick_range, pick_interval) = (kind.clone(), range.clone(), interval.clone()); + section( + "CandlestickChart / OHLCChart / HeikinAshiChart / LineQuoteChart / VolumeChart / ChartTypeSwitcher / TimeRangeSelector / IntervalSelector", + "Prices as candles, bars, smoothed candles, a line or an area, with volume along the bottom and averages over the top. Drag or scroll sideways to move through time, hold Cmd and scroll to zoom, double-press to return to the newest.", + cx, + ) + .child( + row() + .justify_between() + .w_full() + .child( + row() + .child(ChartTypeSwitcher::new("finance-kind", now_kind).on_change(move |kind, _, cx| set(&pick_kind, kind, cx))) + .child(IntervalSelector::new("finance-interval", now_interval.clone()).on_change(move |key, _, cx| set(&pick_interval, key.clone(), cx))), + ) + .child(TimeRangeSelector::new("finance-range", now_range).on_change(move |key, _, cx| set(&pick_range, key.clone(), cx))), + ) + .child(probe( + "market", + div().w(px(WIDE)).child( + CandlestickChart::new("market", candles) + .kind(now_kind) + .title(format!("ELY · {now_interval}")) + .volume() + .overlay(Overlay::Sma(20)) + .overlay(Overlay::Ema(50)) + .alert(alert, "Alert"), + ), + )) +} + +pub fn indicators(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep( + "finance-indicators", + || { + ( + vec![Overlay::Bollinger(20, 2.0)], + vec![Study::Macd, Study::Rsi], + ) + }, + window, + cx, + ); + let (overlays, studies) = chosen.read(cx).clone(); + let candles = walk(11, 160, 92.0, 86_400); + let peer = walk(19, 160, 61.0, 86_400); + let chart = CandlestickChart::new("studies", candles) + .title("ELY · 1D") + .compare("PEER", peer); + let chart = overlays + .iter() + .fold(chart, |chart, overlay| chart.overlay(*overlay)); + let chart = studies + .iter() + .fold(chart, |chart, study| chart.study(*study)); + let picked = chosen.clone(); + section( + "IndicatorOverlay / IndicatorPane / IndicatorSelector / CompareSymbol / PriceAlertLine", + "Bollinger bands over the prices, MACD and RSI in panes below, and a second symbol drawn from the same start so their moves compare. Choose indicators from the menu.", + cx, + ) + .child(row().child(IndicatorSelector::new("finance-choose", overlays, studies).on_change(move |overlays, studies, _, cx| set(&picked, (overlays, studies), cx)))) + .child(div().w_full().child(chart.alert(98.0, "Take profit"))) +} + +pub fn drawings(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let tool = keep("finance-tool", || Some(Tool::Trend), window, cx); + let marks = keep( + "finance-marks", + || vec![Drawing::Fib((96.0, 118.0), (128.0, 142.0))], + window, + cx, + ); + let (now_tool, now_marks) = (*tool.read(cx), marks.read(cx).clone()); + let (pick, kept, cleared) = (tool.clone(), marks.clone(), marks.clone()); + section( + "DrawingTools / DrawingToolbar", + "Pick a tool and drag over the prices: a trend line, a level, a box, or retracement levels between a swing's two ends. The pointer moves through time again.", + cx, + ) + .child( + row() + .child(DrawingToolbar::new("finance-tools", now_tool).on_change(move |tool, _, cx| set(&pick, tool, cx))) + .child(Button::new("finance-clear", "Clear").variant(ButtonVariant::Ghost).on_click(move |_, _, cx| set(&cleared, Vec::new(), cx))), + ) + .child(probe( + "drawings", + div().w(px(WIDE)).child( + CandlestickChart::new("drawn", walk(23, 140, 120.0, 86_400)) + .title("ELY · 1D") + .drawings(now_marks) + .tool(now_tool) + .on_draw(move |drawing, _, cx| { + kept.update(cx, |marks, cx| { + marks.push(drawing); + cx.notify(); + }) + }), + ), + )) +} + +pub fn depth(cx: &mut App) -> impl IntoElement + use<> { + let mut rng = noise(29); + let bids: Vec<(f64, f64)> = (0..40) + .map(|ix| { + ( + 187.20 - ix as f64 * 0.05, + 200.0 + rng() * 900.0 + ix as f64 * 40.0, + ) + }) + .collect(); + let asks: Vec<(f64, f64)> = (0..40) + .map(|ix| { + ( + 187.26 + ix as f64 * 0.05, + 200.0 + rng() * 900.0 + ix as f64 * 35.0, + ) + }) + .collect(); + section("DepthChart", "Every bid and ask as a running total out from the spread. Hover to read the total to any price.", cx) + .child(probe("depth", div().w(px(WIDE)).child(DepthChart::new("depth", bids, asks)))) +} + +pub fn profile(cx: &mut App) -> impl IntoElement + use<> { + section( + "MarketProfile / VolumeProfile", + "How much traded at each price in view, as bars from the right edge; the busiest price is the darkest.", + cx, + ) + .child(div().w_full().child(CandlestickChart::new("profile", walk(31, 120, 54.0, 86_400)).title("ELY · 1D").profile())) +} + +pub fn bricks(cx: &mut App) -> impl IntoElement + use<> { + let candles = Rc::new(walk(37, 260, 74.0, 86_400)); + section( + "RenkoChart / PointAndFigure", + "Time drops out: a brick for each whole move of two dollars, and columns of Xs and Os that turn after three boxes the other way.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child(div().flex_1().child(RenkoChart::new("renko", candles.clone(), 2.0))) + .child(div().flex_1().child(PointFigureChart::new("figure", candles, 2.0, 3))), + ) +} + +pub fn together(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let arrangement = keep("finance-arrangement", || Arrangement::Four, window, cx); + let sync = keep( + "finance-sync", + || None::>, + window, + cx, + ); + if sync.read(cx).is_none() { + let made = cx.new(|_| ChartSync::default()); + set(&sync, Some(made), cx); + } + let shared = sync.read(cx).clone().expect("made above"); + let now = *arrangement.read(cx); + let pick = arrangement.clone(); + let names = [ + ("ELY", 3, 184.0), + ("NOVA", 5, 62.0), + ("ORBIT", 8, 131.0), + ("PINE", 13, 27.0), + ]; + let layout = names.iter().fold( + MultiChartLayout::new("finance-grid", now), + |layout, (name, seed, start)| { + let chart = CandlestickChart::new( + SharedString::from(format!("sync-{name}")), + walk(*seed, 120, *start, 86_400), + ) + .title(*name) + .sync(&shared); + layout.cell(chart.h(cx.theme().chart().height * 0.8)) + }, + ); + section( + "MultiChartLayout / ChartSync", + "Four symbols moving together: pan, zoom or point at one and the others follow. Switch between one chart, two across, two down or four.", + cx, + ) + .child(layout.on_arrange(move |arrangement, _, cx| set(&pick, arrangement, cx))) +} diff --git a/examples/gallery/pages/finance/mod.rs b/examples/gallery/pages/finance/mod.rs new file mode 100644 index 0000000..97efd66 --- /dev/null +++ b/examples/gallery/pages/finance/mod.rs @@ -0,0 +1,74 @@ +mod assets; +mod book; +mod markets; +mod overview; +mod trading; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 16, + slug: "finance", + title: "Finance", + summary: "Market charts with the studies traders read, books, quotes, order entry and portfolios.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::HoverAt("market", 520.0, 160.0), + Step::Wait(300), + Step::Shot("crosshair"), + Step::DownAt("drawings", 240.0, 210.0), + Step::DragTo("drawings", 620.0, 90.0), + Step::UpAt("drawings", 620.0, 90.0), + Step::Wait(300), + Step::Shot("drawn"), + Step::HoverAt("depth", 300.0, 150.0), + Step::Wait(300), + Step::Shot("depth"), + Step::HoverAt("payoff", 560.0, 120.0), + Step::Wait(300), + Step::Shot("payoff"), + Step::HoverAt("heatmap", 150.0, 150.0), + Step::Wait(300), + Step::Shot("heatmap"), + Step::DownAt("ticket", 170.0, 256.0), + Step::UpAt("ticket", 170.0, 256.0), + Step::Wait(400), + Step::Shot("confirm"), + Step::Key("escape"), + Step::Wait(300), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(markets::market(window, cx)) + .child(markets::indicators(window, cx)) + .child(markets::drawings(window, cx)) + .child(markets::depth(cx)) + .child(markets::profile(cx)) + .child(markets::bricks(cx)) + .child(markets::together(window, cx)) + .child(book::quotes(cx)) + .child(book::book(cx)) + .child(book::tape(window, cx)) + .child(trading::ticket(window, cx)) + .child(trading::blotter(cx)) + .child(trading::risk(window, cx)) + .child(trading::options(window, cx)) + .child(overview::watch(window, cx)) + .child(overview::heatmap(cx)) + .child(overview::screener(window, cx)) + .child(overview::overview(cx)) + .child(overview::calendars(window, cx)) + .child(assets::portfolio(cx)) + .child(assets::statements(cx)) + .child(assets::money(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/finance/overview.rs b/examples/gallery/pages/finance/overview.rs new file mode 100644 index 0000000..5a19999 --- /dev/null +++ b/examples/gallery/pages/finance/overview.rs @@ -0,0 +1,405 @@ +use ely_gpui_component::{ + data_display::Tone, + finance::{ + Earnings, EarningsCalendar, EconomicCalendar, MarketHeatmap, MarketOverview, MarketStatus, + NewsFeed, Release, Screener, Session, Story, SymbolBadge, SymbolSearch, + TradingSessionClock, Watch, Watchlist, + }, + forms::TextInput, + tables::{Cell, Column, FilterRule, Row, Test}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, civil::date, tz::TimeZone}; + +use super::markets::day; +use crate::{ + probe::probe, + ui::{keep, row, section, set}, +}; + +/// The demo's symbols: letters, name, where each trades, last close, and a seed for its day. +const SYMBOLS: [(&str, &str, &str, f64, u64); 8] = [ + ("ELY", "Ely Systems", "NASDAQ", 184.9, 41), + ("NOVA", "Nova Energy", "NYSE", 62.6, 43), + ("ORBIT", "Orbit Freight", "NYSE", 128.9, 47), + ("PINE", "Pine Foods", "NASDAQ", 27.7, 53), + ("QUILL", "Quill Media", "NASDAQ", 87.7, 59), + ("RIVER", "River Health", "NYSE", 45.2, 67), + ("SOLACE", "Solace Labs", "NASDAQ", 310.5, 73), + ("TERRA", "Terra Mining", "LSE", 10.1, 79), +]; + +fn watches() -> Vec { + SYMBOLS + .iter() + .map(|(symbol, name, _, close, seed)| { + let day = day(*seed, *close); + Watch { + symbol: (*symbol).into(), + name: (*name).into(), + last: f64::from(*day.last().expect("a day")), + close: *close, + day, + } + }) + .collect() +} + +pub fn watch(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep("finance-watched", || SharedString::from("ELY"), window, cx); + let field = window.use_keyed_state("finance-symbol", cx, |window, cx| { + TextInput::new(window, cx).placeholder("Search symbols") + }); + let (now, pick, find) = (chosen.read(cx).clone(), chosen.clone(), chosen.clone()); + let symbols = SYMBOLS.iter().map(|(symbol, name, venue, ..)| { + ( + SharedString::from(*symbol), + SharedString::from(*name), + SharedString::from(*venue), + ) + }); + let badges = SYMBOLS.iter().take(4).map(|(symbol, _, venue, ..)| { + SymbolBadge::new(SharedString::from(format!("badge-{symbol}")), *symbol).venue(*venue) + }); + section( + "Watchlist / SymbolBadge / SymbolSearch", + "Symbols to follow, each with its day as a line, its price and its move; a symbol as a tile of its letters; and a search by letters or name.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child( + div().w(px(520.)).child( + Watchlist::new("watchlist", watches()) + .selected(now.clone()) + .on_select(move |symbol, _, cx| set(&pick, symbol.clone(), cx)), + ), + ) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_4() + .child( + SymbolSearch::new("symbol-search", &field, symbols) + .on_pick(move |symbol, _, cx| set(&find, symbol.clone(), cx)), + ) + .child(row().children(badges)) + .child(Caption::new(format!("Watching {now}."))), + ), + ) +} + +pub fn heatmap(cx: &mut App) -> impl IntoElement + use<> { + let tiles = [ + ("SOLACE", 920.0, 0.0212), + ("ELY", 780.0, 0.0124), + ("ORBIT", 410.0, -0.0203), + ("NOVA", 380.0, -0.0081), + ("QUILL", 260.0, 0.0041), + ("RIVER", 240.0, 0.0), + ("PINE", 150.0, -0.0152), + ("TERRA", 120.0, -0.0312), + ("ATLAS", 110.0, 0.0088), + ("BIRCH", 90.0, 0.0263), + ("CEDAR", 70.0, -0.0047), + ("DUNE", 60.0, 0.0151), + ]; + let map = tiles.iter().fold( + MarketHeatmap::new("heatmap"), + |map, (symbol, weight, change)| map.tile(*symbol, *weight, *change), + ); + section( + "MarketHeatmap / SectorTreemap", + "Each symbol a tile sized by its market value and tinted by its move: deeper green as it rises, deeper red as it falls. Hover a tile to read it.", + cx, + ) + .child(probe("heatmap", div().w(px(840.)).child(map.h(px(320.))))) +} + +pub fn screener(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let rules = keep( + "finance-screen", + || { + ( + vec![FilterRule { + column: "change".into(), + test: Test::Above, + value: "0".into(), + }], + false, + ) + }, + window, + cx, + ); + let (now, any) = rules.read(cx).clone(); + let store = rules.clone(); + let columns = [ + Column::new("symbol", "Symbol"), + Column::new("sector", "Sector"), + Column::new("last", "Last").end().decimals(2), + Column::new("change", "Change") + .end() + .decimals(2) + .suffix("%"), + Column::new("value", "Value").end().decimals(1).suffix(" B"), + Column::new("earnings", "P/E").end().decimals(1), + ]; + let sectors = [ + "Software", + "Energy", + "Transport", + "Food", + "Media", + "Health", + "Software", + "Materials", + ]; + let rows = SYMBOLS.iter().zip(watches()).zip(sectors).enumerate().map( + |(ix, (((symbol, ..), watch), sector))| { + let change = (watch.last / watch.close - 1.0) * 100.0; + Row::new( + *symbol, + [ + Cell::Text((*symbol).into()), + Cell::Tag(sector.into(), Tone::Neutral), + Cell::Number(watch.last), + Cell::Number(change), + Cell::Number(12.0 + ix as f64 * 17.5), + Cell::Number(9.0 + (ix * 7 % 30) as f64), + ], + ) + }, + ); + section( + "Screener / FilterPanel", + "Securities narrowed by rules: build the rules above, and the table keeps only what passes them.", + cx, + ) + .child( + Screener::new("screener", columns, rows) + .rules(now, any) + .on_rules(move |rules, any, _, cx| set(&store, (rules.to_vec(), any), cx)), + ) +} + +pub fn overview(cx: &mut App) -> impl IntoElement + use<> { + let indexes = [ + ("EGX 500", "Broad market", 5_812.4, 41), + ("EGX Tech", "Technology", 19_440.1, 43), + ("EGX Small", "Small companies", 2_231.8, 47), + ]; + let movers = [ + ("BIRCH", 18.92, 18.43), + ("SOLACE", 317.08, 310.5), + ("TERRA", 9.78, 10.1), + ("ORBIT", 126.28, 128.9), + ]; + let board = indexes.iter().fold( + MarketOverview::new("overview") + .status(MarketStatus::new("New York", Session::Open).next("Closes in 2 h 14 min")), + |board, (symbol, name, close, seed)| { + let day = day(*seed, *close); + let last = f64::from(*day.last().expect("a day")); + board.index((*symbol, *name), (last, *close), day) + }, + ); + let board = movers.iter().fold(board, |board, (symbol, last, close)| { + board.mover(*symbol, *last, *close) + }); + let clock = [ + ("Sydney", 23.0, 5.0), + ("Tokyo", 0.0, 6.0), + ("Hong Kong", 1.5, 8.0), + ("Frankfurt", 7.0, 15.5), + ("London", 8.0, 16.5), + ("New York", 13.5, 20.0), + ] + .iter() + .fold( + TradingSessionClock::new(15.75), + |clock, (name, open, close)| clock.market(*name, *open, *close), + ); + section( + "MarketOverview / IndexCard / MarketStatus / TradingSessionClock", + "A market at a glance: its session, its indexes as cards, and the day's movers. Below, each market's hours across one day in UTC, those open now lit.", + cx, + ) + .child(board) + .child( + row() + .gap_6() + .child(MarketStatus::new("London", Session::After).next("Opens in 15 h 15 min")) + .child(MarketStatus::new("Tokyo", Session::Closed).next("Opens in 8 h 15 min")) + .child(MarketStatus::new("Frankfurt", Session::Before).next("Opens in 30 min")), + ) + .child(clock) +} + +fn stamp(hour: i64, minute: i64) -> Timestamp { + Timestamp::from_second(1_790_294_400 + hour * 3_600 + minute * 60).expect("a time") +} + +pub fn calendars(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let release = |(hour, minute), + region: &str, + event: &str, + weight, + figures: (Option, Option, Option), + unit: &str| Release { + time: stamp(hour, minute), + region: SharedString::from(region.to_string()), + event: SharedString::from(event.to_string()), + weight, + actual: figures.0, + forecast: figures.1, + previous: figures.2, + unit: SharedString::from(unit.to_string()), + }; + let releases = [ + release( + (1, 30), + "JP", + "Tokyo core consumer prices", + 2, + (Some(2.4), Some(2.3), Some(2.2)), + "%", + ), + release( + (8, 0), + "DE", + "Ifo business climate", + 2, + (Some(87.1), Some(87.6), Some(87.3)), + "", + ), + release( + (12, 30), + "US", + "Durable goods orders", + 2, + (Some(0.8), Some(0.5), Some(-0.3)), + "%", + ), + release( + (12, 30), + "US", + "Initial jobless claims", + 3, + (None, Some(224.0), Some(231.0)), + "K", + ), + release( + (14, 0), + "US", + "New home sales", + 1, + (None, Some(668.0), Some(652.0)), + "K", + ), + ]; + let earnings = [ + ( + date(2026, 9, 28), + "ELY", + "Ely Systems", + false, + 1.84, + Some(1.97), + ), + ( + date(2026, 9, 29), + "NOVA", + "Nova Energy", + true, + 0.62, + Some(0.55), + ), + ( + date(2026, 9, 30), + "SOLACE", + "Solace Labs", + false, + 3.10, + None, + ), + (date(2026, 10, 1), "QUILL", "Quill Media", true, 0.48, None), + ] + .map( + |(date, symbol, name, before_open, estimate, reported)| Earnings { + date, + symbol: symbol.into(), + name: name.into(), + before_open, + estimate, + reported, + }, + ); + let story = |(hour, minute), source: &str, headline: &str, symbols: &[&str], lean| Story { + source: SharedString::from(source.to_string()), + time: stamp(hour, minute), + headline: SharedString::from(headline.to_string()), + symbols: symbols + .iter() + .map(|symbol| SharedString::from(symbol.to_string())) + .collect(), + lean, + }; + let stories = [ + story( + (15, 42), + "Wire", + "Ely Systems lifts its outlook as cloud orders climb", + &["ELY"], + Some(true), + ), + story( + (15, 10), + "Markets Desk", + "Freight rates slide for a third week", + &["ORBIT", "TERRA"], + Some(false), + ), + story( + (14, 36), + "Wire", + "Nova Energy names a new chief financial officer", + &["NOVA"], + None, + ), + story( + (13, 58), + "Daily Brief", + "Solace Labs trial meets its main goal", + &["SOLACE"], + Some(true), + ), + ]; + let opened = keep("finance-story", || None::, window, cx); + let (read, open) = (*opened.read(cx), opened.clone()); + section( + "EconomicCalendar / EarningsCalendar / NewsFeed", + "The day's releases with how much each moves markets and its figure beside the forecast; reports due by date; and stories newest first, each dot leaning green or red. Press a story to open it.", + cx, + ) + .child(EconomicCalendar::new(releases).zone(TimeZone::UTC)) + .child(EarningsCalendar::new("earnings", earnings)) + .child( + div() + .w(px(640.)) + .flex() + .flex_col() + .gap_2() + .child( + NewsFeed::new("news", stories) + .zone(TimeZone::UTC) + .on_open(move |ix, _, cx| set(&open, Some(ix), cx)), + ) + .children(read.map(|ix| Caption::new(format!("Opened story {}.", ix + 1)))), + ) +} diff --git a/examples/gallery/pages/finance/trading.rs b/examples/gallery/pages/finance/trading.rs new file mode 100644 index 0000000..b65583b --- /dev/null +++ b/examples/gallery/pages/finance/trading.rs @@ -0,0 +1,258 @@ +use ely_gpui_component::{ + finance::{ + Fill, GreeksTable, Leg, LeverageSlider, MarginIndicator, OptionChain, OptionQuote, Order, + OrderConfirmDialog, OrderEntry, OrderKind, OrderTable, PayoffDiagram, PnLDisplay, Position, + PositionTable, QuickTradeButtons, RiskMeter, Side, Strike, TradeHistoryTable, Working, + }, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::markets::WIDE; +use crate::{ + probe::probe, + ui::{keep, row, section, set}, +}; + +/// The demo symbol's last trade. +const LAST: f64 = 187.25; + +fn at(minutes: i64) -> Timestamp { + Timestamp::from_second(1_790_343_000 + minutes * 60).expect("a time") +} + +pub fn ticket(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let order = keep( + "finance-order", + || Order { + symbol: "ELY".into(), + side: Side::Buy, + kind: OrderKind::Limit, + quantity: 100.0, + price: 187.20, + }, + window, + cx, + ); + let asking = keep("finance-asking", || false, window, cx); + let told = keep("finance-told", || None::, window, cx); + let now = order.read(cx).clone(); + let dialog = (*asking.read(cx)).then(|| { + let (shut, placed) = (asking.clone(), told.clone()); + OrderConfirmDialog::new("finance-confirm", now.clone(), LAST, move |_, cx| { + set(&shut, false, cx) + }) + .on_confirm(move |order, _, cx| { + set( + &placed, + Some(format!("Placed: {}.", order.words(2)).into()), + cx, + ) + }) + }); + let caption = told + .read(cx) + .clone() + .unwrap_or("Nothing placed yet.".into()); + let (store, ask, quick) = (order.clone(), asking.clone(), told.clone()); + let entry = OrderEntry::new("ticket", now, LAST) + .on_change(move |order, _, cx| set(&store, order, cx)) + .on_submit(move |_, _, cx| set(&ask, true, cx)); + let buttons = QuickTradeButtons::new("quick", 187.24, 187.26).on_trade(move |side, _, cx| { + let words = match side { + Side::Buy => "Bought 100 ELY at 187.26.", + Side::Sell => "Sold 100 ELY at 187.24.", + }; + set(&quick, Some(words.into()), cx) + }); + section( + "OrderEntry / TradePanel / QuickTradeButtons / OrderConfirmDialog", + "An order filled in field by field, with what it comes to; its button asks before the order goes. Quick buttons trade at the bid or the ask in one press.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child(probe("ticket", div().w(px(340.)).child(entry))) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_4() + .child(buttons) + .child(Caption::new(caption)), + ), + ) + .children(dialog) +} + +pub fn blotter(cx: &mut App) -> impl IntoElement + use<> { + let positions = [ + ("ELY", 300.0, 171.40, LAST), + ("NOVA", -150.0, 64.80, 62.08), + ("ORBIT", 80.0, 139.10, 131.52), + ("PINE", 1_200.0, 25.90, 27.33), + ] + .map(|(symbol, quantity, cost, last)| Position { + symbol: symbol.into(), + quantity, + cost, + last, + }); + let order = |symbol: &str, side, kind, quantity, price| Order { + symbol: SharedString::from(symbol.to_string()), + side, + kind, + quantity, + price, + }; + let working = [ + ( + 4, + order("ELY", Side::Buy, OrderKind::Limit, 200.0, 185.50), + 0.0, + ), + ( + 11, + order("NOVA", Side::Sell, OrderKind::Stop, 150.0, 60.00), + 0.0, + ), + ( + 26, + order("QUILL", Side::Buy, OrderKind::Limit, 500.0, 87.80), + 220.0, + ), + ] + .map(|(minutes, order, filled)| Working { + time: at(minutes), + order, + filled, + }); + let fills = [ + (2, "ELY", Side::Buy, 100.0, 186.92, 1.00), + (9, "PINE", Side::Buy, 400.0, 27.05, 1.60), + (17, "ORBIT", Side::Sell, 40.0, 132.40, 1.00), + (31, "ELY", Side::Buy, 200.0, 187.18, 1.20), + (44, "NOVA", Side::Sell, 150.0, 62.31, 1.00), + ] + .map(|(minutes, symbol, side, quantity, price, fee)| Fill { + time: at(minutes), + symbol: symbol.into(), + side, + quantity, + price, + fee, + }); + section( + "PositionTable / OrderTable / TradeHistoryTable / PnLDisplay", + "What is held and what it has made, orders still waiting with how much has filled, and each fill with its fee. Results read green for a gain and red for a loss.", + cx, + ) + .child( + row() + .gap_12() + .child(PnLDisplay::new("pnl-day", "Today", (1_284.50, 0.0092), "USD")) + .child(PnLDisplay::new("pnl-open", "Open positions", (4_910.20, 0.0371), "USD")) + .child(PnLDisplay::new("pnl-month", "This month", (-2_046.75, -0.0148), "USD")), + ) + .child(PositionTable::new("positions", positions)) + .child(OrderTable::new("orders", working).zone(TimeZone::UTC)) + .child(TradeHistoryTable::new("fills", fills).zone(TimeZone::UTC)) +} + +pub fn risk(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let leverage = keep("finance-leverage", || 5.0, window, cx); + let now = *leverage.read(cx); + let store = leverage.clone(); + let used = 21_500.0 * now / 5.0; + section( + "LeverageSlider / MarginIndicator / RiskMeter", + "Leverage marked at the common steps; the margin it takes against the account, and the risk it carries. Each turns amber, then red, as it climbs. Drag the slider.", + cx, + ) + .child( + div() + .flex() + .gap_12() + .items_center() + .child( + div() + .w(px(360.)) + .flex() + .flex_col() + .gap_8() + .child( + LeverageSlider::new("leverage", now, 100.0) + .on_change(move |value, _, cx| set(&store, value, cx)), + ) + .child(MarginIndicator::new("margin", used.min(96_000.0), 96_000.0)), + ) + .child(RiskMeter::new("risk", (now * 0.9 + 8.0).min(100.0))), + ) +} + +pub fn options(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let spot = LAST; + let strikes = (0..9).map(|ix| { + let strike = 170.0 + ix as f64 * 5.0; + let (call, put) = ((spot - strike).max(0.0), (strike - spot).max(0.0)); + let time = 3.2 - (strike - spot).abs() * 0.04; + let quote = |inner: f64, volume: f64, volatility: f64| OptionQuote { + bid: inner + time.max(0.3) - 0.05, + ask: inner + time.max(0.3) + 0.05, + volume, + volatility, + }; + Strike { + strike, + call: quote( + call, + 400.0 + ix as f64 * 120.0, + 0.24 + (ix as f64 - 4.0).abs() * 0.01, + ), + put: quote( + put, + 1_400.0 - ix as f64 * 110.0, + 0.26 + (ix as f64 - 4.0).abs() * 0.012, + ), + } + }); + let picked = keep("finance-picked", || None::, window, cx); + let (told, pick) = (picked.read(cx).clone(), picked.clone()); + let spread = [ + Leg { + call: true, + strike: 185.0, + quantity: 1.0, + premium: 4.10, + }, + Leg { + call: true, + strike: 195.0, + quantity: -1.0, + premium: 1.05, + }, + ]; + section( + "OptionChain / GreeksTable / PayoffDiagram", + "Calls to the left and puts to the right of each strike, the side in the money shaded and a line at the price; a position's greeks; and what a call spread pays at expiry. Hover the diagram to read any price.", + cx, + ) + .child( + OptionChain::new("chain", strikes, spot).on_pick(move |strike, call, _, cx| { + let words = format!("{} {strike:.0}", if call { "Call" } else { "Put" }); + set(&pick, Some(words.into()), cx) + }), + ) + .children(told.map(|words| Caption::new(format!("Picked: {words}.")))) + .child(div().w(px(640.)).child(GreeksTable::new([0.42, 0.031, -0.086, 0.121, 0.047]))) + .child(probe( + "payoff", + div() + .w(px(WIDE)) + .child(PayoffDiagram::new("payoff", spread, (165.0, 215.0), spot)), + )) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 1446b3d..8cd0e38 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -2,6 +2,7 @@ mod buttons; mod charts; mod data; mod feedback; +mod finance; mod forms; mod layout; mod lists; @@ -45,6 +46,7 @@ pub const ALL: &[Page] = &[ lists::PAGE, tables::PAGE, charts::PAGE, + finance::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/tables/core.rs b/examples/gallery/pages/tables/core.rs index ea5980a..b2eb570 100644 --- a/examples/gallery/pages/tables/core.rs +++ b/examples/gallery/pages/tables/core.rs @@ -16,7 +16,7 @@ use gpui::{ use crate::{ probe::probe, - ui::{keep, section, set}, + ui::{keep, noise, section, set}, }; pub fn plain(cx: &mut App) -> impl IntoElement + use<> { @@ -49,17 +49,6 @@ const PEOPLE: [&str; 10] = [ ]; const REGIONS: [&str; 4] = ["Europe", "Americas", "Asia", "Oceania"]; -/// A steady pseudo-random series, so captures repeat. -fn noise(seed: u64) -> impl FnMut() -> f64 { - let mut state = seed; - move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (state >> 33) as f64 / (1u64 << 31) as f64 - } -} - pub(super) fn order_rows() -> Vec { let mut next = noise(7); (0..24) diff --git a/examples/gallery/pages/typography/text.rs b/examples/gallery/pages/typography/text.rs index 251b98c..9373b54 100644 --- a/examples/gallery/pages/typography/text.rs +++ b/examples/gallery/pages/typography/text.rs @@ -1,8 +1,8 @@ use ely_gpui_component::{ theme::{ActiveTheme, Radius}, typography::{ - Blockquote, Caption, Code, EllipsisTooltip, ExternalLink, Heading, Highlight, Kbd, - KbdCombo, Label, Link, MiddleEllipsis, Overline, Paragraph, Subtitle, Title, + Blockquote, Caption, Code, Ellipsis, EllipsisTooltip, ExternalLink, Heading, Highlight, + Kbd, KbdCombo, Label, Link, MiddleEllipsis, Overline, Paragraph, Subtitle, Title, }, }; use gpui::{App, IntoElement, ParentElement, Styled, div, px}; @@ -149,11 +149,18 @@ fn narrow(cx: &App) -> gpui::Div { pub fn truncate(cx: &App) -> impl IntoElement + use<> { section( - "Truncate", - "gpui's .truncate(): one line, an ellipsis at the end.", + "Truncate / Ellipsis", + "One line, an ellipsis at the end, in the text style around it. It holds in flex boxes, where gpui's own .truncate() clips.", cx, ) - .child(narrow(cx).child(div().truncate().child(PROSE))) + .child( + narrow(cx) + .flex() + .items_center() + .gap_2() + .child(div().flex_none().child("Note")) + .child(Ellipsis::new(PROSE)), + ) } pub fn line_clamp(cx: &App) -> impl IntoElement + use<> { diff --git a/examples/gallery/ui.rs b/examples/gallery/ui.rs index effddb1..ede1c2b 100644 --- a/examples/gallery/ui.rs +++ b/examples/gallery/ui.rs @@ -94,3 +94,14 @@ pub fn set(state: &Entity, value: T, cx: &mut App) { cx.notify(); }); } + +/// A steady pseudo-random series, so captures repeat. +pub fn noise(seed: u64) -> impl FnMut() -> f64 { + let mut state = seed; + move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as f64 / (1u64 << 31) as f64 + } +} diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 0baddcd..82a62db 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -20,6 +20,8 @@ pub enum ButtonVariant { Outline, Ghost, Subtle, + /// Confirms a gain, such as a purchase; green. + Success, Danger, Link, } @@ -70,6 +72,13 @@ pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone { fg: colors.fg, border: clear, }, + ButtonVariant::Success => Tone { + bg: colors.success, + hover: colors.success.mix(&colors.fg, 0.14), + pressed: colors.success.mix(&colors.fg, 0.24), + fg: colors.on_accent, + border: clear, + }, ButtonVariant::Danger => Tone { bg: colors.danger, hover: colors.danger.mix(&colors.fg, 0.14), diff --git a/src/buttons/segmented.rs b/src/buttons/segmented.rs index 5f91e0a..5a6ee49 100644 --- a/src/buttons/segmented.rs +++ b/src/buttons/segmented.rs @@ -145,7 +145,7 @@ impl RenderOnce for SegmentedControl { .when_some(icon, |segment, icon| { segment.child(Icon::new(icon).size(icon_size).color(fg)) }) - .child(label) + .when(!label.is_empty(), |segment| segment.child(label)) .child(measure_item(measure, ix, Axis::Horizontal)) }); div() diff --git a/src/charts/axes.rs b/src/charts/axes.rs index 0c61347..8cdc295 100644 --- a/src/charts/axes.rs +++ b/src/charts/axes.rs @@ -1,6 +1,6 @@ use gpui::{ App, Div, IntoElement, ParentElement, Pixels, Rems, SharedString, Styled, Window, div, - prelude::*, + prelude::*, relative, }; use super::{ @@ -9,8 +9,8 @@ use super::{ scale::Linear, }; use crate::{ - theme::{ActiveTheme, TextSize}, - typography::tabular, + theme::{ActiveTheme, Radius, TextSize}, + typography::{LEADING, tabular, text_width}, }; /// A label centered under `x`, below the frame. @@ -99,14 +99,18 @@ pub(crate) fn marks( .collect(); let along = if plot.horizontal { rect.h } else { rect.w }; let room = f32::from(sizes.label.to_pixels(rem)); + let named: Vec<&(f32, SharedString)> = geometry + .labels + .iter() + .filter(|(_, label)| !label.is_empty()) + .collect(); let every = if plot.horizontal { 1 } else { - ((geometry.labels.len() as f32 * room / along.max(1.0)).ceil() as usize).max(1) + ((named.len() as f32 * room / along.max(1.0)).ceil() as usize).max(1) }; labels.extend( - geometry - .labels + named .iter() .enumerate() .filter(|(ix, _)| ix % every == 0) @@ -148,7 +152,7 @@ pub(crate) fn marks( .child(text(label.clone()).pb_0p5()) } })); - let notes: Vec<(f32, SharedString)> = plot + let mut notes: Vec<(f32, SharedString)> = plot .notes .iter() .filter(|(ix, _)| !plot.horizontal && (span.0..=span.1).contains(ix)) @@ -159,14 +163,34 @@ pub(crate) fn marks( .map(|(at, _)| (*at, label.clone())) }) .collect(); - labels.extend(notes.iter().map(|(at, label)| { - div() - .absolute() - .left(Pixels::from(*at)) - .top(Pixels::from(rect.y)) - .pl_1p5() - .child(text(label.clone())) - })); + notes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let size = theme.text_size(TextSize::Xs).to_pixels(rem); + let mut ends: Vec = Vec::new(); + for (at, label) in ¬es { + let row = ends.iter().position(|end| end <= at).unwrap_or(ends.len()); + let end = at + f32::from(text_width(label, size, window) + size); + if row == ends.len() { + ends.push(end); + } else { + ends[row] = end; + } + labels.push( + div() + .absolute() + .left(Pixels::from(*at)) + .top(Pixels::from( + rect.y + f32::from(size) * LEADING * row as f32, + )) + .pl_1p5() + .line_height(relative(LEADING)) + .child( + text(label.clone()) + .px_1() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.bg), + ), + ); + } Marks { labels, rules: rules.iter().map(|(at, _)| *at).collect(), diff --git a/src/charts/mod.rs b/src/charts/mod.rs index 08cb625..736a353 100644 --- a/src/charts/mod.rs +++ b/src/charts/mod.rs @@ -45,3 +45,11 @@ pub use series::{Points, Series}; pub use sunburst::{Slice, Sunburst}; pub use targets::{Bullet, BulletChart, ProgressChart}; pub use tiles::{HeatmapChart, Treemap}; + +pub(crate) use axes::anchored; +pub(crate) use geometry::{Geometry, Ink, Line, Rect}; +pub(crate) use layout::squarify; +pub(crate) use paint::{at, finish, measure, place, ring, tint}; +pub(crate) use plot::{Layout, Pick, Plot, Scene, Tips, plot}; +pub(crate) use scale::{Linear, compact, nice, nice_step}; +pub(crate) use tiles::{Tiles, tracked}; diff --git a/src/charts/paint.rs b/src/charts/paint.rs index fed743b..59b4552 100644 --- a/src/charts/paint.rs +++ b/src/charts/paint.rs @@ -59,8 +59,8 @@ impl Ink { pub(crate) fn color(self, colors: &Palette) -> Hsla { match self { Ink::Series(ix) => tint(colors, ix), - Ink::Rise => tint(colors, 1), - Ink::Fall => tint(colors, 3), + Ink::Rise => colors.success, + Ink::Fall => colors.danger, Ink::Rule => colors.fg_subtle, Ink::Strong => colors.fg, } diff --git a/src/charts/scale.rs b/src/charts/scale.rs index bbc621c..8c70d20 100644 --- a/src/charts/scale.rs +++ b/src/charts/scale.rs @@ -14,6 +14,17 @@ impl Linear { Self { domain, range } } + /// The value at pixel `at`; a flat range reads the middle of the domain. + pub(crate) fn value(&self, at: f32) -> f64 { + let span = self.range.1 - self.range.0; + let share = if span == 0.0 { + 0.5 + } else { + (f64::from(at) - f64::from(self.range.0)) / f64::from(span) + }; + self.domain.0 + (self.domain.1 - self.domain.0) * share + } + /// Where `value` lands; a flat domain lands in the middle. pub(crate) fn at(&self, value: f64) -> f32 { let span = self.domain.1 - self.domain.0; @@ -120,6 +131,7 @@ mod tests { let scale = Linear::new((0.0, 50.0), (100.0, 0.0)); assert_eq!(scale.at(25.0), 50.0); assert_eq!(scale.at(50.0), 0.0); + assert_eq!(scale.value(20.0), 40.0, "and back"); assert_eq!( Linear::new((3.0, 3.0), (0.0, 10.0)).at(3.0), 5.0, diff --git a/src/data_display/stat.rs b/src/data_display/stat.rs index 6819c38..201bc65 100644 --- a/src/data_display/stat.rs +++ b/src/data_display/stat.rs @@ -1,6 +1,6 @@ use gpui::{ - App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, div, prelude::*, + App, Div, ElementId, FontWeight, IntoElement, ParentElement, Refineable, RenderOnce, + SharedString, StyleRefinement, Styled, Window, div, prelude::*, }; use crate::{ @@ -162,6 +162,7 @@ impl RenderOnce for Statistic { /// A statistic on a quiet card, with an icon and a line of context. #[derive(IntoElement)] pub struct KpiCard { + base: Div, statistic: Statistic, icon: Option, caption: Option, @@ -170,6 +171,7 @@ pub struct KpiCard { impl KpiCard { pub fn new(statistic: Statistic) -> Self { Self { + base: div(), statistic, icon: None, caption: None, @@ -187,12 +189,19 @@ impl KpiCard { } } +impl Styled for KpiCard { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + impl RenderOnce for KpiCard { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = &theme.colors; - div() - .relative() + let mut card = div(); + card.style().refine(self.base.style()); + card.relative() .flex() .flex_col() .gap_3() diff --git a/src/finance/blotter.rs b/src/finance/blotter.rs new file mode 100644 index 0000000..aecef28 --- /dev/null +++ b/src/finance/blotter.rs @@ -0,0 +1,368 @@ +use std::cmp::Reverse; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, +}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::{ + book::Side, + quotes::moves, + trade::{Order, OrderKind}, +}; +use crate::{ + data_display::Tone, + motion::Flash, + tables::{Aggregate, Cell, Column, DataTable, Row}, + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// A held position: its symbol, how many, what each cost on average, and the last price. +#[derive(Clone, Debug, PartialEq)] +pub struct Position { + pub symbol: SharedString, + pub quantity: f64, + pub cost: f64, + pub last: f64, +} + +/// A signed share as a tag, green up and red down. +fn signed(share: f64) -> Cell { + let tone = if share > 0.0 { + Tone::Success + } else if share < 0.0 { + Tone::Danger + } else { + Tone::Neutral + }; + Cell::Tag(format::percent(share, 2, true).into(), tone) +} + +/// Positions held, with what each is worth now and what it has made or lost; totals below. +#[derive(IntoElement)] +pub struct PositionTable { + id: ElementId, + positions: Vec, +} + +impl PositionTable { + pub fn new(id: impl Into, positions: impl IntoIterator) -> Self { + Self { + id: id.into(), + positions: positions.into_iter().collect(), + } + } +} + +impl RenderOnce for PositionTable { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let columns = [ + Column::new("symbol", "Symbol"), + Column::new("quantity", "Quantity").end(), + Column::new("cost", "Average cost").end().decimals(2), + Column::new("last", "Last").end().decimals(2), + Column::new("value", "Value") + .end() + .decimals(2) + .aggregate(Aggregate::Sum), + Column::new("gain", "Gain") + .end() + .decimals(2) + .aggregate(Aggregate::Sum), + Column::new("share", "Return").end(), + ]; + let rows: Vec = self + .positions + .iter() + .map(|held| { + let gain = (held.last - held.cost) * held.quantity; + let basis = (held.cost * held.quantity).abs(); + let share = if basis > 0.0 { + signed(gain / basis) + } else { + Cell::Empty + }; + Row::new( + held.symbol.clone(), + [ + Cell::Text(held.symbol.clone()), + held.quantity.into(), + held.cost.into(), + held.last.into(), + (held.last * held.quantity).into(), + gain.into(), + share, + ], + ) + }) + .collect(); + DataTable::new(self.id, columns).rows(rows) + } +} + +/// An order still working: when it was placed, its order, and how many of its quantity have filled. +#[derive(Clone, Debug, PartialEq)] +pub struct Working { + pub time: Timestamp, + pub order: Order, + pub filled: f64, +} + +/// Orders still working, newest first: when, what, which way, how, how much at what price, and how far each has filled. +#[derive(IntoElement)] +pub struct OrderTable { + id: ElementId, + orders: Vec, + zone: TimeZone, +} + +impl OrderTable { + pub fn new(id: impl Into, orders: impl IntoIterator) -> Self { + let mut orders: Vec = orders.into_iter().collect(); + assert!( + orders.iter().all(|working| { + working.order.quantity > 0.0 + && (0.0..=working.order.quantity).contains(&working.filled) + }), + "a working order has a quantity and fills from none up to it" + ); + orders.sort_by_key(|working| Reverse(working.time)); + Self { + id: id.into(), + orders, + zone: TimeZone::system(), + } + } + + /// The time zone its times read in; the system's unless set. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } +} + +/// A side as a tag. +fn side_tag(side: Side) -> Cell { + match side { + Side::Buy => Cell::Tag("Buy".into(), Tone::Success), + Side::Sell => Cell::Tag("Sell".into(), Tone::Danger), + } +} + +impl RenderOnce for OrderTable { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let columns = [ + Column::new("time", "Placed"), + Column::new("symbol", "Symbol"), + Column::new("side", "Side"), + Column::new("kind", "Type"), + Column::new("quantity", "Quantity").end(), + Column::new("price", "Price").end().decimals(2), + Column::new("filled", "Filled"), + ]; + let rows: Vec = self + .orders + .iter() + .enumerate() + .map(|(ix, working)| { + let order = &working.order; + let price = if order.kind == OrderKind::Market { + Cell::Text("Market".into()) + } else { + order.price.into() + }; + let time: SharedString = working + .time + .to_zoned(self.zone.clone()) + .strftime("%H:%M:%S") + .to_string() + .into(); + let filled = working.filled / order.quantity; + Row::new( + format!("order-{ix}"), + [ + Cell::Text(time), + Cell::Text(order.symbol.clone()), + side_tag(order.side), + Cell::Text(order.kind.word().into()), + order.quantity.into(), + price, + Cell::Progress(filled as f32), + ], + ) + }) + .collect(); + DataTable::new(self.id, columns).rows(rows) + } +} + +/// A filled trade: when, what, which way, how much at what price, and its fee. +#[derive(Clone, Debug, PartialEq)] +pub struct Fill { + pub time: Timestamp, + pub symbol: SharedString, + pub side: Side, + pub quantity: f64, + pub price: f64, + pub fee: f64, +} + +/// Trades filled, newest first, with what each came to after its fee; totals below. +#[derive(IntoElement)] +pub struct TradeHistoryTable { + id: ElementId, + fills: Vec, + zone: TimeZone, +} + +impl TradeHistoryTable { + pub fn new(id: impl Into, fills: impl IntoIterator) -> Self { + let mut fills: Vec = fills.into_iter().collect(); + fills.sort_by_key(|fill| Reverse(fill.time)); + Self { + id: id.into(), + fills, + zone: TimeZone::system(), + } + } + + /// The time zone its times read in; the system's unless set. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } +} + +impl RenderOnce for TradeHistoryTable { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let columns = [ + Column::new("time", "Time"), + Column::new("symbol", "Symbol"), + Column::new("side", "Side"), + Column::new("quantity", "Quantity").end(), + Column::new("price", "Price").end().decimals(2), + Column::new("fee", "Fee") + .end() + .decimals(2) + .aggregate(Aggregate::Sum), + Column::new("total", "Total") + .end() + .decimals(2) + .aggregate(Aggregate::Sum), + ]; + let rows: Vec = self + .fills + .iter() + .enumerate() + .map(|(ix, fill)| { + let signed = if fill.side == Side::Buy { -1.0 } else { 1.0 }; + let total = signed * fill.quantity * fill.price - fill.fee; + let time: SharedString = fill + .time + .to_zoned(self.zone.clone()) + .strftime("%b %-d %H:%M") + .to_string() + .into(); + Row::new( + format!("fill-{ix}"), + [ + Cell::Text(time), + Cell::Text(fill.symbol.clone()), + side_tag(fill.side), + fill.quantity.into(), + fill.price.into(), + fill.fee.into(), + total.into(), + ], + ) + }) + .collect(); + DataTable::new(self.id, columns).rows(rows) + } +} + +/// A profit or loss as it reads at a glance: signed and tinted in its currency, its share of cost below, flashing when it moves. +#[derive(IntoElement)] +pub struct PnLDisplay { + id: ElementId, + label: SharedString, + amount: f64, + share: f64, + currency: SharedString, + red_up: bool, +} + +impl PnLDisplay { + /// `share` is the amount as a share of what it cost; `currency` an ISO 4217 code. + pub fn new( + id: impl Into, + label: impl Into, + (amount, share): (f64, f64), + currency: &str, + ) -> Self { + assert!( + amount.is_finite() && share.is_finite(), + "a result needs finite numbers" + ); + Self { + id: id.into(), + label: label.into(), + amount, + share, + currency: currency.to_string().into(), + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for PnLDisplay { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let ink = if self.amount > 0.0 { + rise + } else if self.amount < 0.0 { + fall + } else { + theme.colors.fg + }; + let sign = if self.amount > 0.0 { "+" } else { "" }; + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(self.label), + ) + .child( + Flash::new(self.id, self.amount.to_bits()) + .rounded(theme.radius(crate::theme::Radius::Sm)) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(ink) + .child(format!( + "{sign}{}", + format::currency(self.amount, &self.currency) + )), + ), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(ink) + .child(format::percent(self.share, 2, true)), + ) + } +} diff --git a/src/finance/book.rs b/src/finance/book.rs new file mode 100644 index 0000000..ebe779a --- /dev/null +++ b/src/finance/book.rs @@ -0,0 +1,344 @@ +use gpui::{ + App, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, relative, +}; +use jiff::Timestamp; + +use super::{ + depth::depth, + quotes::{moves, price}, +}; +use crate::{ + charts::compact, + motion::Flash, + theme::{ActiveTheme, Density, TextSize}, + typography::{format, tabular}, +}; + +/// Which side of the book took a trade, or wants to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + Buy, + Sell, +} + +/// One trade as it printed: when, at what price, how much, and which side took it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Trade { + pub time: Timestamp, + pub price: f64, + pub size: f64, + pub side: Side, +} + +/// A row of a book: a bar behind it as wide as its share, and its cells. +pub(super) fn level(share: f64, ink: Hsla, from_right: bool, cx: &App) -> Div { + let theme = cx.theme(); + let bar = div() + .absolute() + .top_0() + .bottom_0() + .w(relative(share.clamp(0.0, 1.0) as f32)) + .bg(ink.opacity(0.1)); + div() + .relative() + .flex() + .items_center() + .gap_3() + .px_2() + .h(theme.table_row(Density::Compact)) + .text_size(theme.text_size(TextSize::Sm)) + .child(if from_right { + bar.right_0() + } else { + bar.left_0() + }) +} + +/// A column of numbers set right. +pub(super) fn cell(words: String, ink: Hsla) -> Div { + tabular(div()) + .flex_1() + .text_right() + .text_color(ink) + .child(words) +} + +/// The heading over a book's columns. +pub(super) fn heading(names: &[&'static str], cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .gap_3() + .px_2() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .children( + names + .iter() + .map(|name| div().flex_1().text_right().child(*name)), + ) +} + +/// Orders waiting to buy and to sell, best in the middle: asks above falling to the spread, bids below; each level's running total shaded as a share of the deepest shown. +#[derive(IntoElement)] +pub struct OrderBook { + id: ElementId, + bids: Vec<(f64, f64)>, + asks: Vec<(f64, f64)>, + levels: usize, + places: usize, + red_up: bool, +} + +impl OrderBook { + /// Bids and asks, each a price and the size waiting there. + pub fn new( + id: impl Into, + bids: impl IntoIterator, + asks: impl IntoIterator, + ) -> Self { + let (bids, asks): (Vec<_>, Vec<_>) = + (bids.into_iter().collect(), asks.into_iter().collect()); + assert!( + !bids.is_empty() && !asks.is_empty(), + "a book needs both sides" + ); + Self { + id: id.into(), + bids, + asks, + levels: 8, + places: 2, + red_up: false, + } + } + + /// How many levels each side shows. + pub fn levels(mut self, levels: usize) -> Self { + assert!(levels > 0, "a book shows a level"); + self.levels = levels; + self + } + + pub fn places(mut self, places: usize) -> Self { + self.places = places; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for OrderBook { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let (bids, asks) = depth(&self.bids, &self.asks); + let (bids, asks) = ( + &bids[..self.levels.min(bids.len())], + &asks[..self.levels.min(asks.len())], + ); + let deepest = bids + .iter() + .chain(asks) + .map(|(_, total)| *total) + .fold(0.0, f64::max); + let sizes = |side: &[(f64, f64)]| { + side.iter() + .scan(0.0, |before, (at, total)| { + let size = total - *before; + *before = *total; + Some((*at, size, *total)) + }) + .collect::>() + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let row = |id: SharedString, (at, size, total): (f64, f64, f64), ink: Hsla| { + level(total / deepest.max(f64::EPSILON), ink, true, cx) + .child(cell(price(at, self.places), ink)) + .child( + Flash::new(id, size.to_bits()) + .flex_1() + .child(cell(compact(size), colors.fg)), + ) + .child(cell(compact(total), colors.fg_muted)) + }; + let (best_bid, best_ask) = (bids[0].0, asks[0].0); + let spread = best_ask - best_bid; + let middle = div() + .flex() + .items_center() + .justify_between() + .px_2() + .py_1p5() + .border_t_1() + .border_b_1() + .border_color(colors.border) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(price((best_bid + best_ask) / 2.0, self.places + 1)), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(format!( + "Spread {} · {}", + price(spread, self.places), + format::percent(spread / best_ask, 3, false) + )), + ); + let id = self.id; + div() + .flex() + .flex_col() + .child(heading(&["Price", "Size", "Total"], cx)) + .children( + sizes(asks) + .into_iter() + .rev() + .map(|level| row(format!("{id:?}-ask-{}", level.0).into(), level, fall)), + ) + .child(middle) + .children( + sizes(bids) + .into_iter() + .map(|level| row(format!("{id:?}-bid-{}", level.0).into(), level, rise)), + ) + } +} + +/// Quotes by venue on both sides: who bids how much at what price and who asks, best first, each price level shaded a step lighter than the better one. +#[derive(IntoElement)] +pub struct Level2Quotes { + bids: Vec<(SharedString, f64, f64)>, + asks: Vec<(SharedString, f64, f64)>, + rows: usize, + places: usize, + red_up: bool, +} + +impl Level2Quotes { + pub fn new() -> Self { + Self { + bids: Vec::new(), + asks: Vec::new(), + rows: 8, + places: 2, + red_up: false, + } + } + + /// A venue's bid: its price and size. + pub fn bid(mut self, venue: impl Into, at: f64, size: f64) -> Self { + self.bids.push((venue.into(), at, size)); + self + } + + pub fn ask(mut self, venue: impl Into, at: f64, size: f64) -> Self { + self.asks.push((venue.into(), at, size)); + self + } + + pub fn rows(mut self, rows: usize) -> Self { + self.rows = rows; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl Default for Level2Quotes { + fn default() -> Self { + Self::new() + } +} + +/// Each quote's price level, zero for the best price, one for the next, and so on. +fn ranks(prices: &[f64]) -> Vec { + let mut rank = 0; + prices + .iter() + .enumerate() + .map(|(ix, at)| { + if ix > 0 && *at != prices[ix - 1] { + rank += 1; + } + rank + }) + .collect() +} + +impl RenderOnce for Level2Quotes { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + self.bids.sort_by(|a, b| b.1.total_cmp(&a.1)); + self.asks.sort_by(|a, b| a.1.total_cmp(&b.1)); + let colors = cx.theme().colors.clone(); + let side = |quotes: &[(SharedString, f64, f64)], ink: Hsla, bids: bool| { + let levels = ranks("es.iter().map(|quote| quote.1).collect::>()); + let names: &[&'static str] = if bids { + &["Venue", "Size", "Bid"] + } else { + &["Ask", "Size", "Venue"] + }; + div() + .flex_1() + .flex() + .flex_col() + .child(heading(names, cx)) + .children(quotes.iter().zip(levels).take(self.rows).map( + |((venue, at, size), rank)| { + let shade = ink.opacity((0.16 - 0.04 * rank as f32).max(0.03)); + let venue = div() + .flex_1() + .text_right() + .text_color(colors.fg_muted) + .child(venue.clone()); + let (at, size) = ( + cell(price(*at, self.places), ink), + cell(compact(*size), colors.fg), + ); + let row = div() + .flex() + .items_center() + .gap_3() + .px_2() + .h(cx.theme().table_row(Density::Compact)) + .text_size(cx.theme().text_size(TextSize::Sm)) + .bg(shade); + if bids { + row.child(venue).child(size).child(at) + } else { + row.child(at).child(size).child(venue) + } + }, + )) + }; + div() + .flex() + .gap_2() + .child(side(&self.bids, rise, true)) + .child(side(&self.asks, fall, false)) + } +} + +#[cfg(test)] +mod tests { + use super::ranks; + + #[test] + fn levels_rank_by_price() { + assert_eq!(ranks(&[10.0, 10.0, 9.9, 9.8, 9.8]), [0, 0, 1, 2, 2]); + } +} diff --git a/src/finance/calendars.rs b/src/finance/calendars.rs new file mode 100644 index 0000000..712c3db --- /dev/null +++ b/src/finance/calendars.rs @@ -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, + pub forecast: Option, + pub previous: Option, + 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, + zone: TimeZone, +} + +impl EconomicCalendar { + pub fn new(releases: impl IntoIterator) -> Self { + let releases: Vec = 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, unit: &SharedString| { + value.map_or("—".to_string(), |value| { + format!("{}{unit}", format::number(value, 1, format::Separators::EN)) + }) + }; + let mut out: Vec = Vec::new(); + let mut day: Option = 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, +} + +/// 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, +} + +impl EarningsCalendar { + pub fn new(id: impl Into, earnings: impl IntoIterator) -> 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 = 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, + pub lean: Option, +} + +type OnStory = Rc; + +/// 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, + zone: TimeZone, + on_open: Option, +} + +impl NewsFeed { + pub fn new(id: impl Into, stories: impl IntoIterator) -> 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 = (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())), + ), + ), + ) + })) + } +} diff --git a/src/finance/candles.rs b/src/finance/candles.rs new file mode 100644 index 0000000..1fd26c0 --- /dev/null +++ b/src/finance/candles.rs @@ -0,0 +1,253 @@ +use jiff::Timestamp; + +/// One period of a market: when it opened, its open, high, low and close, and how much traded. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Candle { + pub time: Timestamp, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +impl Candle { + pub fn new( + time: Timestamp, + (open, high, low, close): (f64, f64, f64, f64), + volume: f64, + ) -> Self { + assert!( + [open, high, low, close, volume] + .iter() + .all(|value| value.is_finite()), + "a candle needs finite prices and volume" + ); + assert!( + high >= open.max(close) && low <= open.min(close), + "a candle's high and low hold its open and close" + ); + assert!(volume >= 0.0, "volume is zero or more"); + Self { + time, + open, + high, + low, + close, + volume, + } + } + + /// Whether it closed at or above where it opened. + pub fn rose(&self) -> bool { + self.close >= self.open + } +} + +/// Heikin-Ashi candles: each close the average of its four prices, each open the midpoint of the last one's body, which smooths the trend. +pub(crate) fn heikin_ashi(candles: &[Candle]) -> Vec { + let mut out: Vec = Vec::with_capacity(candles.len()); + for candle in candles { + let close = (candle.open + candle.high + candle.low + candle.close) / 4.0; + let open = out + .last() + .map_or((candle.open + candle.close) / 2.0, |last| { + (last.open + last.close) / 2.0 + }); + out.push(Candle { + open, + close, + high: candle.high.max(open).max(close), + low: candle.low.min(open).min(close), + ..*candle + }); + } + out +} + +/// A brick of a Renko chart: where it starts and ends, and the candle whose close laid it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Brick { + pub from: f64, + pub to: f64, + pub at: usize, +} + +/// Renko bricks of `size`: one each time the close moves a whole brick past the last brick's far side, so turning takes two. +pub(crate) fn renko(candles: &[Candle], size: f64) -> Vec { + assert!(size > 0.0, "a brick needs a size"); + let Some(first) = candles.first() else { + return Vec::new(); + }; + let mut bricks = Vec::new(); + let mut bottom = (first.close / size).floor() * size; + let mut top = bottom; + for (at, candle) in candles.iter().enumerate() { + while candle.close >= top + size { + bricks.push(Brick { + from: top, + to: top + size, + at, + }); + (bottom, top) = (top, top + size); + } + while candle.close <= bottom - size { + bricks.push(Brick { + from: bottom, + to: bottom - size, + at, + }); + (top, bottom) = (bottom, bottom - size); + } + } + bricks +} + +/// A column of a point-and-figure chart: rising Xs or falling Os, from the box it starts in to the one it ends in. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Column { + pub rising: bool, + pub from: i64, + pub to: i64, +} + +/// Point-and-figure columns on boxes of `size`: a column grows while prices keep its way and turns once they come back `reversal` boxes. +pub(crate) fn point_and_figure(candles: &[Candle], size: f64, reversal: i64) -> Vec { + assert!( + size > 0.0 && reversal > 0, + "boxes need a size and a reversal" + ); + let mut columns: Vec = Vec::new(); + let boxed = |price: f64| (price / size).floor() as i64; + for candle in candles { + let (high, low) = (boxed(candle.high), boxed(candle.low)); + match columns.last_mut() { + None => columns.push(Column { + rising: true, + from: low, + to: high, + }), + Some(column) if column.rising => { + if high > column.to { + column.to = high; + } else if column.to - low >= reversal { + let from = column.to - 1; + columns.push(Column { + rising: false, + from, + to: low, + }); + } + } + Some(column) => { + if low < column.to { + column.to = low; + } else if high - column.to >= reversal { + let from = column.to + 1; + columns.push(Column { + rising: true, + from, + to: high, + }); + } + } + } + } + columns +} + +/// The volume traded at each of `bands` prices across the candles in view, spread over each candle's range. +pub(crate) fn profile( + candles: &[Candle], + (low, high): (f64, f64), + bands: usize, +) -> Vec<(f64, f64, f64)> { + let step = (high - low) / bands as f64; + let mut traded = vec![0.0; bands]; + for candle in candles { + let reach = (candle.high - candle.low).max(step); + for (band, volume) in traded.iter_mut().enumerate() { + let (from, to) = (low + step * band as f64, low + step * (band + 1) as f64); + let overlap = (to.min(candle.high) - from.max(candle.low)).max(0.0); + *volume += candle.volume * overlap / reach; + } + } + traded + .into_iter() + .enumerate() + .map(|(band, volume)| { + ( + low + step * band as f64, + low + step * (band + 1) as f64, + volume, + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candles(closes: &[f64]) -> Vec { + closes + .iter() + .enumerate() + .map(|(ix, close)| { + let open = if ix == 0 { *close } else { closes[ix - 1] }; + let time = Timestamp::from_second(86_400 * ix as i64).expect("a day"); + Candle::new( + time, + (open, open.max(*close) + 0.5, open.min(*close) - 0.5, *close), + 100.0, + ) + }) + .collect() + } + + #[test] + fn heikin_ashi_averages_and_carries_the_body() { + let smooth = heikin_ashi(&candles(&[10.0, 12.0])); + assert_eq!(smooth[0].close, (10.0 + 10.5 + 9.5 + 10.0) / 4.0); + assert_eq!( + smooth[1].open, + (smooth[0].open + smooth[0].close) / 2.0, + "each opens mid-body of the last" + ); + assert!(smooth[1].high >= smooth[1].open.max(smooth[1].close)); + } + + #[test] + fn renko_lays_a_brick_per_whole_move_and_two_to_turn() { + let bricks = renko(&candles(&[10.0, 13.1, 12.0, 10.9]), 1.0); + let steps: Vec<(f64, f64)> = bricks.iter().map(|brick| (brick.from, brick.to)).collect(); + assert_eq!( + steps, + [(10.0, 11.0), (11.0, 12.0), (12.0, 13.0), (12.0, 11.0)], + "a fall of two bricks turns once" + ); + assert_eq!(bricks[1].at, 1); + } + + #[test] + fn point_and_figure_grows_then_turns_on_the_reversal() { + let columns = point_and_figure(&candles(&[10.0, 14.0, 13.0, 10.0]), 1.0, 3); + assert_eq!(columns.len(), 2); + assert!(columns[0].rising && !columns[1].rising); + assert_eq!(columns[0].to, 14, "the rise reached the fourteen box"); + assert_eq!(columns[1].to, 9, "the fall reached down past ten"); + } + + #[test] + fn a_profile_spreads_each_candle_over_its_range() { + let candle = Candle::new(Timestamp::UNIX_EPOCH, (10.0, 12.0, 10.0, 11.0), 100.0); + let bands = profile(&[candle], (10.0, 14.0), 4); + let traded: Vec = bands.iter().map(|band| band.2).collect(); + assert_eq!( + traded, + [50.0, 50.0, 0.0, 0.0], + "half at each price it crossed" + ); + assert_eq!((bands[3].0, bands[3].1), (13.0, 14.0)); + } +} diff --git a/src/finance/controls.rs b/src/finance/controls.rs new file mode 100644 index 0000000..ffc11c4 --- /dev/null +++ b/src/finance/controls.rs @@ -0,0 +1,342 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, +}; + +use super::market::ChartKind; +use crate::{ + buttons::SegmentedControl, + forms::{Choice, Select}, + primitives::IconName, + theme::{ActiveTheme, ControlSize}, +}; + +type OnPick = Rc; + +/// The spans of history a market chart offers, shortest first. +pub const RANGES: [&str; 9] = ["1D", "5D", "1M", "3M", "6M", "YTD", "1Y", "5Y", "All"]; + +/// How long a candle may last, each with its words. +pub const INTERVALS: [(&str, &str); 8] = [ + ("1m", "1 minute"), + ("5m", "5 minutes"), + ("15m", "15 minutes"), + ("1h", "1 hour"), + ("4h", "4 hours"), + ("1D", "1 day"), + ("1W", "1 week"), + ("1M", "1 month"), +]; + +/// A span of history to show, from a day to all of it. +#[derive(IntoElement)] +pub struct TimeRangeSelector { + id: ElementId, + selected: SharedString, + on_change: Option, +} + +impl TimeRangeSelector { + /// `selected` is one of `RANGES`. + pub fn new(id: impl Into, selected: impl Into) -> Self { + let selected = selected.into(); + assert!( + RANGES.contains(&selected.as_ref()), + "{selected} is not a range" + ); + Self { + id: id.into(), + selected, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TimeRangeSelector { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let control = RANGES.iter().fold( + SegmentedControl::new(self.id, self.selected).size(ControlSize::Sm), + |control, range| control.segment(*range, *range, None), + ); + match self.on_change { + Some(on_change) => { + control.on_change(move |range, window, cx| on_change(range, window, cx)) + } + None => control, + } + } +} + +/// How long each candle lasts, from a minute to a month. +#[derive(IntoElement)] +pub struct IntervalSelector { + id: ElementId, + selected: SharedString, + on_change: Option, +} + +impl IntervalSelector { + /// `selected` is one of the keys of `INTERVALS`. + pub fn new(id: impl Into, selected: impl Into) -> Self { + let selected = selected.into(); + assert!( + INTERVALS.iter().any(|(key, _)| *key == selected.as_ref()), + "{selected} is not an interval" + ); + Self { + id: id.into(), + selected, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for IntervalSelector { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let choices = INTERVALS + .iter() + .map(|(key, words)| Choice::new(*key, *words)); + let select = Select::new(self.id, choices) + .selected(self.selected) + .size(ControlSize::Sm); + let select = match self.on_change { + Some(on_change) => select.on_change(move |key, window, cx| on_change(key, window, cx)), + None => select, + }; + div().w(cx.theme().label_width() * 0.75).child(select) + } +} + +/// Each chart kind with its key, words and icon. +const KINDS: [(ChartKind, &str, &str, IconName); 5] = [ + ( + ChartKind::Candles, + "candles", + "Candles", + IconName::ChartCandlestick, + ), + (ChartKind::Bars, "bars", "Bars", IconName::ChartColumn), + ( + ChartKind::HeikinAshi, + "heikin-ashi", + "Heikin-Ashi", + IconName::ChartCandlestick, + ), + (ChartKind::Line, "line", "Line", IconName::ChartLine), + (ChartKind::Area, "area", "Area", IconName::ChartArea), +]; + +type OnKind = Rc; + +/// How a market chart draws its prices, chosen from candles, bars, Heikin-Ashi, a line or an area. +#[derive(IntoElement)] +pub struct ChartTypeSwitcher { + id: ElementId, + kind: ChartKind, + on_change: Option, +} + +impl ChartTypeSwitcher { + pub fn new(id: impl Into, kind: ChartKind) -> Self { + Self { + id: id.into(), + kind, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(ChartKind, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ChartTypeSwitcher { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let choices = KINDS + .iter() + .map(|(_, key, words, icon)| Choice::new(*key, *words).icon(*icon)); + let selected = KINDS + .iter() + .find(|(kind, ..)| *kind == self.kind) + .map(|(_, key, ..)| *key) + .expect("every kind is listed"); + let select = Select::new(self.id, choices) + .selected(selected) + .size(ControlSize::Sm); + let select = match self.on_change { + Some(on_change) => select.on_change(move |key, window, cx| { + let kind = KINDS + .iter() + .find(|(_, listed, ..)| *listed == key.as_ref()) + .map(|(kind, ..)| *kind) + .expect("a listed kind"); + on_change(kind, window, cx) + }), + None => select, + }; + div().w(cx.theme().label_width() * 0.85).child(select) + } +} + +/// How charts share a space: one, two across, two down, or four. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Arrangement { + #[default] + One, + Across, + Down, + Four, +} + +impl Arrangement { + const ALL: [(Arrangement, &'static str, IconName); 4] = [ + (Arrangement::One, "one", IconName::Square), + (Arrangement::Across, "across", IconName::Columns2), + (Arrangement::Down, "down", IconName::Rows2), + (Arrangement::Four, "four", IconName::LayoutGrid), + ]; + + /// How many charts it holds. + pub fn cells(self) -> usize { + match self { + Arrangement::One => 1, + Arrangement::Across | Arrangement::Down => 2, + Arrangement::Four => 4, + } + } +} + +type OnArrange = Rc; + +/// Charts arranged one, two across, two down or four to a grid, split by hairlines, with the four arrangements to switch between above them. +#[derive(IntoElement)] +pub struct MultiChartLayout { + id: ElementId, + arrangement: Arrangement, + cells: Vec, + on_arrange: Option, +} + +impl MultiChartLayout { + pub fn new(id: impl Into, arrangement: Arrangement) -> Self { + Self { + id: id.into(), + arrangement, + cells: Vec::new(), + on_arrange: None, + } + } + + /// A chart for the next cell; the arrangement shows as many as it holds. + pub fn cell(mut self, cell: impl IntoElement) -> Self { + self.cells.push(cell.into_any_element()); + self + } + + pub fn on_arrange( + mut self, + handler: impl Fn(Arrangement, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_arrange = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MultiChartLayout { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let wanted = self.arrangement.cells(); + assert!( + self.cells.len() >= wanted, + "{:?} holds {wanted} charts; {} were given", + self.arrangement, + self.cells.len() + ); + let theme = cx.theme(); + let key = Arrangement::ALL + .iter() + .find(|(arrangement, ..)| *arrangement == self.arrangement) + .map(|(_, key, _)| *key) + .expect("listed"); + let switcher = Arrangement::ALL.iter().fold( + SegmentedControl::new((self.id.clone(), "arrange"), key).size(ControlSize::Sm), + |control, (_, key, icon)| control.segment(*key, "", Some(*icon)), + ); + let switcher = match self.on_arrange { + Some(on_arrange) => switcher.on_change(move |key, window, cx| { + let arrangement = Arrangement::ALL + .iter() + .find(|(_, listed, _)| *listed == key.as_ref()) + .map(|(arrangement, ..)| *arrangement) + .expect("listed"); + log::info!("multi-chart layout: {arrangement:?}"); + on_arrange(arrangement, window, cx) + }), + None => switcher, + }; + let border = theme.colors.border; + let cell = |child: AnyElement| div().flex_1().min_w_0().min_h_0().p_2().child(child); + let mut cells = self.cells.into_iter().take(wanted).map(cell); + let mut next = || cells.next().expect("counted above"); + let grid = match self.arrangement { + Arrangement::One => div().flex().child(next()), + Arrangement::Across => div() + .flex() + .child(next().border_r_1().border_color(border)) + .child(next()), + Arrangement::Down => div() + .flex() + .flex_col() + .child(next().border_b_1().border_color(border)) + .child(next()), + Arrangement::Four => div() + .flex() + .flex_col() + .child( + div() + .flex() + .border_b_1() + .border_color(border) + .child(next().border_r_1().border_color(border)) + .child(next()), + ) + .child( + div() + .flex() + .child(next().border_r_1().border_color(border)) + .child(next()), + ), + }; + div() + .flex() + .flex_col() + .gap_2() + .child(div().flex().justify_end().child(switcher)) + .child( + grid.rounded(theme.radius(crate::theme::Radius::Md)) + .border_1() + .border_color(border), + ) + } +} diff --git a/src/finance/depth.rs b/src/finance/depth.rs new file mode 100644 index 0000000..de24d39 --- /dev/null +++ b/src/finance/depth.rs @@ -0,0 +1,349 @@ +use gpui::{ + App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Point, + Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, +}; + +use super::quotes::moves; +use crate::{ + charts::{ + ChartTooltip, Linear, Rect, Tiles, anchored, at, compact, finish, measure, nice, place, + ring, tracked, + }, + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// Prices, each with a running total of size. +pub(crate) type Totals = Vec<(f64, f64)>; + +/// A book's running totals, best price first: each bid's price with all size bid at it or higher, and each ask's with all size asked at it or lower. +pub(crate) fn depth(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> (Totals, Totals) { + let run = |mut side: Vec<(f64, f64)>, best_high: bool| { + side.sort_by(|a, b| { + if best_high { + b.0.total_cmp(&a.0) + } else { + a.0.total_cmp(&b.0) + } + }); + let mut total = 0.0; + side.into_iter() + .map(|(price, size)| { + total += size; + (price, total) + }) + .collect::>() + }; + (run(bids.to_vec(), true), run(asks.to_vec(), false)) +} + +/// The total on one side at a price: everything bid at it or higher, or asked at it or lower. +fn total_at(side: &[(f64, f64)], price: f64, bids: bool) -> Option { + side.iter() + .take_while(|(level, _)| { + if bids { + *level >= price + } else { + *level <= price + } + }) + .last() + .map(|(_, total)| *total) +} + +/// Orders waiting to buy and to sell as the running total at each price: bids step up to the left of the spread, asks to the right. Hover reads the total to any price. +#[derive(IntoElement)] +pub struct DepthChart { + base: Div, + id: ElementId, + bids: Vec<(f64, f64)>, + asks: Vec<(f64, f64)>, + red_up: bool, +} + +impl DepthChart { + /// Bids and asks, each a price and the size waiting there; the best bid sits below the best ask. + pub fn new( + id: impl Into, + bids: impl IntoIterator, + asks: impl IntoIterator, + ) -> Self { + let (bids, asks): (Vec<_>, Vec<_>) = + (bids.into_iter().collect(), asks.into_iter().collect()); + let fine = |side: &[(f64, f64)]| { + side.iter() + .all(|(price, size)| price.is_finite() && size.is_finite() && *size > 0.0) + }; + assert!( + fine(&bids) && fine(&asks) && !bids.is_empty() && !asks.is_empty(), + "a book needs levels with positive sizes on both sides" + ); + let best_bid = bids + .iter() + .map(|(price, _)| *price) + .fold(f64::MIN, f64::max); + let best_ask = asks + .iter() + .map(|(price, _)| *price) + .fold(f64::MAX, f64::min); + assert!(best_bid < best_ask, "the best bid sits below the best ask"); + Self { + base: div(), + id: id.into(), + bids, + asks, + red_up: false, + } + } + + /// Bids in red and asks in green, as markets in China and Japan read rises. + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl Styled for DepthChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for DepthChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "depth"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let (bids, asks) = depth(&self.bids, &self.asks); + let (best_bid, best_ask) = (bids[0].0, asks[0].0); + let mid = (best_bid + best_ask) / 2.0; + let reach = (mid - bids.last().expect("bids").0).max(asks.last().expect("asks").0 - mid); + let most = bids + .last() + .expect("bids") + .1 + .max(asks.last().expect("asks").1); + let ((_, top), ticks) = nice(0.0, most, 4); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (inset, gutter, foot) = ( + pixels(sizes.inset), + pixels(sizes.gutter), + pixels(sizes.foot), + ); + let frame = Rect { + x: inset, + y: inset * 3.0, + w: (f32::from(bounds.size.width) - inset - gutter).max(0.0), + h: (f32::from(bounds.size.height) - inset * 3.0 - foot).max(0.0), + }; + let xs = Linear::new((mid - reach, mid + reach), (frame.x, frame.x + frame.w)); + let ys = Linear::new((0.0, top), (frame.y + frame.h, frame.y)); + let (rise, fall) = moves(self.red_up, cx); + let text = |words: String, color| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(color) + .child(words) + }; + let edge = frame.x + frame.w; + let mut words: Vec
= ticks + .iter() + .map(|tick| { + div() + .absolute() + .left(Pixels::from(edge)) + .top(Pixels::from(ys.at(*tick))) + .h_0() + .flex() + .items_center() + .pl_2() + .child(tabular(text(compact(*tick), colors.fg_subtle))) + }) + .collect(); + let (_, price_ticks) = nice(mid - reach, mid + reach, 5); + let digits = super::stage::decimals( + price_ticks + .get(1) + .zip(price_ticks.first()) + .map_or(1.0, |(b, a)| b - a), + ) + .max(2); + words.extend( + price_ticks + .iter() + .filter(|tick| (mid - reach..=mid + reach).contains(*tick)) + .map(|tick| { + let label = tabular(text( + format::number(*tick, digits, format::Separators::EN), + colors.fg_subtle, + )); + div() + .absolute() + .left(Pixels::from(xs.at(*tick))) + .top(Pixels::from(frame.y + frame.h)) + .w_0() + .flex() + .justify_center() + .pt_1p5() + .child(label) + }), + ); + let spread = best_ask - best_bid; + let heading = format!( + "Spread {} · {}", + format::number(spread, digits, format::Separators::EN), + format::percent(spread / mid, 3, false) + ); + words.push( + div() + .absolute() + .left(Pixels::from(xs.at(mid))) + .top_0() + .w_0() + .flex() + .justify_center() + .child(text(heading, colors.fg_muted)), + ); + let steps = |side: &[(f64, f64)]| -> Vec<(f32, f32)> { + let mut points = vec![(xs.at(side[0].0), ys.at(0.0))]; + let mut last = 0.0; + for (price, total) in side { + points.push((xs.at(*price), ys.at(last))); + points.push((xs.at(*price), ys.at(*total))); + last = *total; + } + points + }; + let (bid_steps, ask_steps) = (steps(&bids), steps(&asks)); + let reading = hover.map(|column| { + let price = xs.value(frame.x + column as f32); + let is_bid = price < mid; + let total = if is_bid { + total_at(&bids, price, true) + } else { + total_at(&asks, price, false) + }; + (price, is_bid, total.unwrap_or(0.0)) + }); + if let Some((price, is_bid, total)) = reading { + let card = ChartTooltip::new(format::number(price, digits, format::Separators::EN)) + .row( + Some(if is_bid { rise } else { fall }), + if is_bid { + "Bids at or above" + } else { + "Asks at or below" + }, + compact(total), + ); + let x = xs.at(price); + words.push(anchored( + (x, ys.at(total)), + x > f32::from(bounds.size.width) * 0.6, + card, + )); + } + let (stroke, bottom) = (sizes.stroke.to_pixels(rem), frame.y + frame.h); + let mark = reading.map(|(price, _, total)| (xs.at(price), ys.at(total))); + let palette = colors.clone(); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "book").into(), + &tiles, + move |(x, y)| { + frame + .contains((x, y)) + .then(|| (x - frame.x).round() as usize) + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin: Point = bounds.origin; + for tick in &ticks { + let line = Rect { + x: frame.x, + y: ys.at(*tick), + w: frame.w, + h: f32::from(sizes.hairline), + }; + window.paint_quad(fill(place(origin, line), palette.border.opacity(0.5))); + } + for (points, ink) in [(&bid_steps, rise), (&ask_steps, fall)] { + let (Some(first), Some(last)) = (points.first(), points.last()) else { + continue; + }; + let mut area = PathBuilder::fill(); + let mut edge = PathBuilder::stroke(stroke); + area.move_to(at(origin, *first)); + edge.move_to(at(origin, *first)); + points[1..].iter().for_each(|point| { + area.line_to(at(origin, *point)); + edge.line_to(at(origin, *point)); + }); + area.line_to(at(origin, (last.0, bottom))); + area.close(); + finish(area, ink.opacity(0.14), window); + finish(edge, ink, window); + } + let middle = Rect { + x: xs.at(mid), + y: frame.y, + w: f32::from(sizes.hairline), + h: frame.h, + }; + window.paint_quad(fill(place(origin, middle), palette.border_strong)); + if let Some(point) = mark { + let upright = Rect { + x: point.0, + y: frame.y, + w: f32::from(sizes.hairline), + h: frame.h, + }; + window.paint_quad(fill(place(origin, upright), palette.fg_subtle)); + ring( + at(origin, point), + stroke * 2.0, + (palette.bg, palette.fg), + stroke, + window, + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(words) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::{depth, total_at}; + + #[test] + fn a_book_runs_totals_outward_from_the_spread() { + let (bids, asks) = depth( + &[(99.0, 2.0), (100.0, 1.0), (98.0, 3.0)], + &[(102.0, 4.0), (101.0, 1.0)], + ); + assert_eq!(bids, [(100.0, 1.0), (99.0, 3.0), (98.0, 6.0)]); + assert_eq!(asks, [(101.0, 1.0), (102.0, 5.0)]); + assert_eq!( + total_at(&bids, 98.5, true), + Some(3.0), + "bids at 99 and above" + ); + assert_eq!(total_at(&asks, 101.5, false), Some(1.0)); + assert_eq!(total_at(&bids, 100.5, true), None, "nothing bid that high"); + } +} diff --git a/src/finance/draw.rs b/src/finance/draw.rs new file mode 100644 index 0000000..343f0cd --- /dev/null +++ b/src/finance/draw.rs @@ -0,0 +1,440 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{Hsla, IntoElement, PathBuilder, Pixels, Point, Styled, Window, canvas, fill}; + +use super::{ + candles::Candle, + market::ChartKind, + series::Drawn, + stage::Visible, + tools::{self, Drawing}, +}; +use crate::{ + charts::{Linear, Rect, at, finish, place, tint}, + theme::Palette, +}; + +/// How a market chart paints: its colors and measures in pixels. +#[derive(Clone)] +pub(crate) struct Pen { + pub palette: Palette, + pub rise: Hsla, + pub fall: Hsla, + pub stroke: Pixels, + pub hairline: Pixels, +} + +/// What a market chart paints, in its box's own pixels: panes with their price scales, main first; the candles in view and how they draw; volume, profile, overlays, studies, a compared symbol, the last price, alerts, and the crosshair. +pub(crate) struct Scene { + pub panes: Vec<(Rect, Linear, Vec)>, + pub visible: Visible, + pub range: Range, + pub candles: Rc>, + pub kind: ChartKind, + pub volume: Option, + pub profile: Vec<(f64, f64, f64)>, + pub overlays: Rc>, + pub studies: Rc>, + pub compare: Option>>, + pub last: Option<(f64, bool)>, + pub alerts: Vec, + pub cross: Option<(i64, Option<(usize, f32)>)>, + pub drawings: Vec, + pub pen: Pen, +} + +impl Scene { + fn x(&self, ix: usize) -> f32 { + let main = self.panes[0].0; + self.visible.x(ix as f64, (main.x, main.w)) + } + + fn slot(&self) -> f32 { + self.visible.slot(self.panes[0].0.w) + } +} + +/// A straight line in dashes. +fn dashed( + origin: Point, + (from, to): ((f32, f32), (f32, f32)), + color: Hsla, + pen: &Pen, + window: &mut Window, +) { + let dash = [pen.stroke * 2.0, pen.stroke * 2.0]; + let mut path = PathBuilder::stroke(pen.hairline).dash_array(&dash); + path.move_to(at(origin, from)); + path.line_to(at(origin, to)); + finish(path, color, window); +} + +/// A reading's line through the candles in view, broken where it has no value. +fn reading( + scene: &Scene, + values: &[Option], + scale: &Linear, + width: Pixels, + color: Hsla, + origin: Point, + window: &mut Window, +) { + let mut path = PathBuilder::stroke(width); + let mut open = false; + for ix in scene.range.clone() { + match values.get(ix).copied().flatten() { + Some(value) => { + let place = at(origin, (scene.x(ix), scale.at(value))); + if open { + path.line_to(place) + } else { + path.move_to(place) + } + open = true; + } + None => open = false, + } + } + finish(path, color, window); +} + +/// The price's own marks: candles, bars, or a line with or without its area. +fn prices(scene: &Scene, origin: Point, window: &mut Window) { + let (main, scale) = (scene.panes[0].0, &scene.panes[0].1); + let (pen, slot) = (&scene.pen, scene.slot()); + let body = (slot * 0.68).max(f32::from(pen.hairline)); + if matches!(scene.kind, ChartKind::Line | ChartKind::Area) { + let closes: Vec> = scene + .candles + .iter() + .map(|candle| Some(candle.close)) + .collect(); + let ink = tint(&pen.palette, 0); + if scene.kind == ChartKind::Area && scene.range.len() > 1 { + let mut area = PathBuilder::fill(); + let (first, last) = (scene.range.start, scene.range.end - 1); + area.move_to(at(origin, (scene.x(first), main.y + main.h))); + scene.range.clone().for_each(|ix| { + area.line_to(at(origin, (scene.x(ix), scale.at(scene.candles[ix].close)))) + }); + area.line_to(at(origin, (scene.x(last), main.y + main.h))); + area.close(); + finish(area, ink.opacity(0.12), window); + } + reading(scene, &closes, scale, pen.stroke, ink, origin, window); + return; + } + for ix in scene.range.clone() { + let (candle, x) = (&scene.candles[ix], scene.x(ix)); + let ink = if candle.rose() { pen.rise } else { pen.fall }; + let (high, low, open, close) = ( + scale.at(candle.high), + scale.at(candle.low), + scale.at(candle.open), + scale.at(candle.close), + ); + let thin = f32::from(pen.hairline); + window.paint_quad(fill( + place( + origin, + Rect { + x: x - thin / 2.0, + y: high, + w: thin, + h: low - high, + }, + ), + ink, + )); + if scene.kind == ChartKind::Bars { + let tick = body / 2.0; + window.paint_quad(fill( + place( + origin, + Rect { + x: x - tick, + y: open - thin / 2.0, + w: tick, + h: thin, + }, + ), + ink, + )); + window.paint_quad(fill( + place( + origin, + Rect { + x, + y: close - thin / 2.0, + w: tick, + h: thin, + }, + ), + ink, + )); + continue; + } + let (top, bottom) = (open.min(close), open.max(close)); + let rect = Rect { + x: x - body / 2.0, + y: top, + w: body, + h: (bottom - top).max(thin), + }; + window.paint_quad(fill(place(origin, rect), ink)); + } +} + +/// Volume as faint bars along the bottom of the price pane, and the volume at each price as bars from its right edge. +fn volume(scene: &Scene, origin: Point, window: &mut Window) { + let (main, pen) = (scene.panes[0].0, &scene.pen); + if let Some(most) = scene.volume.filter(|most| *most > 0.0) { + let (body, room) = ( + (scene.slot() * 0.68).max(f32::from(pen.hairline)), + main.h * 0.2, + ); + for ix in scene.range.clone() { + let candle = &scene.candles[ix]; + let tall = room * (candle.volume / most) as f32; + let ink = if candle.rose() { pen.rise } else { pen.fall }; + let rect = Rect { + x: scene.x(ix) - body / 2.0, + y: main.y + main.h - tall, + w: body, + h: tall, + }; + window.paint_quad(fill(place(origin, rect), ink.opacity(0.22))); + } + } + let most = scene + .profile + .iter() + .map(|(_, _, volume)| *volume) + .fold(0.0, f64::max); + for (low, high, traded) in scene.profile.iter().filter(|_| most > 0.0) { + let scale = &scene.panes[0].1; + let (top, bottom) = (scale.at(*high), scale.at(*low)); + let wide = main.w * 0.24 * (*traded / most) as f32; + let strength = if *traded == most { 0.16 } else { 0.08 }; + let seam = f32::from(pen.hairline); + let rect = Rect { + x: main.x + main.w - wide, + y: top + seam, + w: wide, + h: (bottom - top - seam).max(0.0), + }; + window.paint_quad(fill(place(origin, rect), pen.palette.fg.opacity(strength))); + } +} + +/// Overlays over the prices: a shaded band, then their lines. +fn overlays(scene: &Scene, origin: Point, window: &mut Window) { + let (scale, pen) = (&scene.panes[0].1, &scene.pen); + for drawn in scene.overlays.iter() { + let color = tint( + &pen.palette, + drawn.lines.first().map_or(0, |(color, _)| *color), + ); + if let Some((low, high)) = &drawn.band { + let shown: Vec = scene + .range + .clone() + .filter(|ix| low[*ix].is_some() && high[*ix].is_some()) + .collect(); + if shown.len() > 1 { + let mut band = PathBuilder::fill(); + band.move_to(at( + origin, + (scene.x(shown[0]), scale.at(high[shown[0]].expect("shown"))), + )); + shown.iter().for_each(|ix| { + band.line_to(at( + origin, + (scene.x(*ix), scale.at(high[*ix].expect("shown"))), + )) + }); + shown.iter().rev().for_each(|ix| { + band.line_to(at( + origin, + (scene.x(*ix), scale.at(low[*ix].expect("shown"))), + )) + }); + band.close(); + finish(band, color.opacity(0.08), window); + reading( + scene, + high, + scale, + pen.hairline, + color.opacity(0.6), + origin, + window, + ); + reading( + scene, + low, + scale, + pen.hairline, + color.opacity(0.6), + origin, + window, + ); + } + } + for (ink, values) in &drawn.lines { + reading( + scene, + values, + scale, + pen.stroke * 0.75, + tint(&pen.palette, *ink), + origin, + window, + ); + } + } + if let Some(compare) = &scene.compare { + reading( + scene, + compare, + scale, + pen.stroke * 0.75, + pen.palette.fg_muted, + origin, + window, + ); + } +} + +/// Each study in its pane: guides, bars about zero, then lines. +fn studies(scene: &Scene, origin: Point, window: &mut Window) { + let pen = &scene.pen; + for ((pane, scale, _), drawn) in scene.panes.iter().skip(1).zip(scene.studies.iter()) { + for guide in &drawn.guides { + let y = scale.at(*guide); + dashed( + origin, + ((pane.x, y), (pane.x + pane.w, y)), + pen.palette.border_strong, + pen, + window, + ); + } + if let Some(bars) = &drawn.bars { + let (body, zero) = ( + (scene.slot() * 0.6).max(f32::from(pen.hairline)), + scale.at(0.0), + ); + for ix in scene.range.clone() { + let Some(value) = bars[ix] else { + continue; + }; + let y = scale.at(value); + let ink = if value >= 0.0 { pen.rise } else { pen.fall }; + let rect = Rect { + x: scene.x(ix) - body / 2.0, + y: y.min(zero), + w: body, + h: (y - zero).abs(), + }; + window.paint_quad(fill(place(origin, rect), ink.opacity(0.45))); + } + } + for (ink, values) in &drawn.lines { + reading( + scene, + values, + scale, + pen.stroke * 0.75, + tint(&pen.palette, *ink), + origin, + window, + ); + } + } +} + +/// The market chart's canvas: gridlines, volume and profile, prices, overlays, studies, the last price, alerts, and the crosshair. +pub(crate) fn drawing(scene: Scene) -> impl IntoElement { + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + let pen = &scene.pen; + for (pane, scale, ticks) in &scene.panes { + for tick in ticks { + let y = scale.at(*tick); + let line = Rect { + x: pane.x, + y, + w: pane.w, + h: f32::from(pen.hairline), + }; + window.paint_quad(fill(place(origin, line), pen.palette.border.opacity(0.5))); + } + } + volume(&scene, origin, window); + let main = scene.panes[0].0; + window.with_content_mask( + Some(gpui::ContentMask { + bounds: place(origin, main), + }), + |window| { + prices(&scene, origin, window); + overlays(&scene, origin, window); + tools::drawings(&scene, origin, window); + }, + ); + studies(&scene, origin, window); + let edge = main.x + main.w; + let domain = scene.panes[0].1.domain; + let shown = |price: &f64| (domain.0..=domain.1).contains(price); + for price in scene.alerts.iter().filter(|price| shown(price)) { + let y = scene.panes[0].1.at(*price); + dashed( + origin, + ((main.x, y), (edge, y)), + pen.palette.warning, + pen, + window, + ); + } + if let Some((price, rose)) = scene.last.filter(|(price, _)| shown(price)) { + let y = scene.panes[0].1.at(price); + dashed( + origin, + ((main.x, y), (edge, y)), + if rose { pen.rise } else { pen.fall }, + pen, + window, + ); + } + if let Some((ix, row)) = scene.cross { + let x = scene.visible.x(ix as f64, (main.x, main.w)); + let bottom = scene + .panes + .last() + .map_or(main.y + main.h, |(pane, _, _)| pane.y + pane.h); + let upright = Rect { + x, + y: main.y, + w: f32::from(pen.hairline), + h: bottom - main.y, + }; + window.paint_quad(fill(place(origin, upright), pen.palette.fg_subtle)); + if let Some((pane, y)) = row { + let across = scene.panes[pane].0; + dashed( + origin, + ((across.x, y), (across.x + across.w, y)), + pen.palette.fg_subtle, + pen, + window, + ); + } + } + }, + ) + .absolute() + .inset_0() +} diff --git a/src/finance/heatmap.rs b/src/finance/heatmap.rs new file mode 100644 index 0000000..d2514fc --- /dev/null +++ b/src/finance/heatmap.rs @@ -0,0 +1,213 @@ +use gpui::{ + App, Div, ElementId, Entity, FontWeight, Hsla, IntoElement, ParentElement, Pixels, RenderOnce, + SharedString, StyleRefinement, Styled, Window, canvas, div, fill, prelude::*, +}; + +use super::quotes::moves; +use crate::{ + charts::{ChartTooltip, Rect, Tiles, anchored, measure, place, squarify, tracked}, + theme::{ActiveTheme, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// Symbols as tiles sized by their weight and tinted by their move: deeper green as they rise, deeper red as they fall, quiet near flat. Hover reads one. +#[derive(IntoElement)] +pub struct MarketHeatmap { + base: Div, + id: ElementId, + tiles: Vec<(SharedString, f64, f64)>, + full: f64, + red_up: bool, +} + +impl MarketHeatmap { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + tiles: Vec::new(), + full: 0.03, + red_up: false, + } + } + + /// A symbol, its weight, such as its market value, and its move as a share. + pub fn tile(mut self, symbol: impl Into, weight: f64, change: f64) -> Self { + assert!( + weight.is_finite() && weight >= 0.0 && change.is_finite(), + "a tile needs a weight of zero or more and a finite move" + ); + self.tiles.push((symbol.into(), weight, change)); + self + } + + /// The move, as a share, that takes the deepest tint. + pub fn full(mut self, share: f64) -> Self { + assert!(share > 0.0, "the deepest tint needs a move"); + self.full = share; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl Styled for MarketHeatmap { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +/// How strongly a move tints its tile: faint near flat, deepest at `full`. +pub(crate) fn strength(change: f64, full: f64) -> f32 { + 0.12 + 0.78 * (change.abs() / full).min(1.0) as f32 +} + +impl RenderOnce for MarketHeatmap { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "heat"), cx, |_, _| Tiles::default()); + let (bounds, hover) = ( + tiles.read(cx).bounds, + tiles.read(cx).pointed(self.tiles.len()), + ); + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let mut order: Vec = (0..self.tiles.len()).collect(); + order.sort_by(|a, b| self.tiles[*b].1.total_cmp(&self.tiles[*a].1)); + let whole = Rect { + x: 0.0, + y: 0.0, + w: f32::from(bounds.size.width), + h: f32::from(bounds.size.height), + }; + let laid = squarify( + &order.iter().map(|ix| self.tiles[*ix].1).collect::>(), + whole, + ); + let seam = f32::from(sizes.hairline); + let mut rects = vec![Rect::default(); self.tiles.len()]; + for (slot, ix) in order.iter().enumerate() { + let rect = laid[slot]; + rects[*ix] = Rect { + x: rect.x + seam, + y: rect.y + seam, + w: (rect.w - seam * 2.0).max(0.0), + h: (rect.h - seam * 2.0).max(0.0), + }; + } + let fills: Vec<(Hsla, f32)> = self + .tiles + .iter() + .map(|(_, _, change)| { + ( + if *change >= 0.0 { rise } else { fall }, + strength(*change, self.full), + ) + }) + .collect(); + let room = f32::from(sizes.label.to_pixels(rem)) * 0.75; + let labels: Vec
= rects + .iter() + .zip(&self.tiles) + .zip(&fills) + .filter(|((rect, _), _)| rect.w >= room && rect.h >= room * 0.6) + .map(|((rect, (symbol, _, change)), (_, strong))| { + let ink = if *strong > 0.55 { + colors.on_accent + } else { + colors.fg + }; + div() + .absolute() + .left(Pixels::from(rect.x)) + .top(Pixels::from(rect.y)) + .w(Pixels::from(rect.w)) + .h(Pixels::from(rect.h)) + .flex() + .flex_col() + .items_center() + .justify_center() + .overflow_hidden() + .text_color(ink) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .child(symbol.clone()), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xs)) + .child(format::percent(*change, 2, true)), + ) + }) + .collect(); + let tooltip = hover.map(|ix| { + let (symbol, weight, change) = &self.tiles[ix]; + let rect = rects[ix]; + let card = ChartTooltip::new(symbol.clone()) + .row(Some(fills[ix].0), "Move", format::percent(*change, 2, true)) + .row(None, "Weight", crate::charts::compact(*weight)); + let x = rect.x + rect.w; + anchored( + (x.min(whole.w), rect.y + rect.h / 2.0), + x > whole.w * 0.6, + card, + ) + }); + let (painted, hit, corner, edge) = ( + rects.clone(), + rects, + theme.radius(Radius::Sm).to_pixels(rem), + colors.fg, + ); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "tiles").into(), + &tiles, + move |place| hit.iter().position(|rect| rect.contains(place)), + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for (ix, (rect, (ink, strong))) in painted.iter().zip(&fills).enumerate() { + let quad = fill(place(bounds.origin, *rect), ink.opacity(*strong)) + .corner_radii(corner); + window.paint_quad(if hover == Some(ix) { + quad.border_widths(sizes.hairline).border_color(edge) + } else { + quad + }); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::strength; + + #[test] + fn moves_tint_up_to_full() { + assert_eq!(strength(0.0, 0.03), 0.12, "flat stays faint"); + assert_eq!( + strength(-0.06, 0.03), + 0.9, + "past full holds the deepest tint" + ); + } +} diff --git a/src/finance/indicators.rs b/src/finance/indicators.rs new file mode 100644 index 0000000..b2eab9e --- /dev/null +++ b/src/finance/indicators.rs @@ -0,0 +1,253 @@ +use super::candles::Candle; + +/// A reading per candle; none until an indicator has seen enough. +pub(crate) type Reading = Vec>; + +/// A simple moving average of `period` values. +pub(crate) fn sma(values: &[f64], period: usize) -> Reading { + assert!(period > 0, "an average needs a period"); + (0..values.len()) + .map(|ix| { + (ix + 1 >= period) + .then(|| values[ix + 1 - period..=ix].iter().sum::() / period as f64) + }) + .collect() +} + +/// An exponential moving average, seeded with the first period's simple average. +pub(crate) fn ema(values: &[f64], period: usize) -> Reading { + assert!(period > 0, "an average needs a period"); + let weight = 2.0 / (period as f64 + 1.0); + let mut out = vec![None; values.len()]; + if values.len() < period { + return out; + } + let mut last = values[..period].iter().sum::() / period as f64; + out[period - 1] = Some(last); + for ix in period..values.len() { + last = values[ix] * weight + last * (1.0 - weight); + out[ix] = Some(last); + } + out +} + +/// An average over only the readings there are, in place: leading gaps stay gaps. +fn ema_of(reading: &Reading, period: usize) -> Reading { + let start = reading + .iter() + .position(Option::is_some) + .unwrap_or(reading.len()); + let values: Vec = reading[start..] + .iter() + .map(|value| value.expect("readings run on once they start")) + .collect(); + let mut out = vec![None; start]; + out.extend(ema(&values, period)); + out +} + +/// Bollinger bands: the moving average, and `width` standard deviations below and above it. +pub(crate) fn bollinger(values: &[f64], period: usize, width: f64) -> Vec> { + sma(values, period) + .into_iter() + .enumerate() + .map(|(ix, mean)| { + let mean = mean?; + let window = &values[ix + 1 - period..=ix]; + let spread = (window + .iter() + .map(|value| (value - mean).powi(2)) + .sum::() + / period as f64) + .sqrt(); + Some((mean - width * spread, mean, mean + width * spread)) + }) + .collect() +} + +/// The volume-weighted average of each candle's typical price, from the first candle on. +pub(crate) fn vwap(candles: &[Candle]) -> Reading { + let (mut weighted, mut volume) = (0.0, 0.0); + candles + .iter() + .map(|candle| { + weighted += (candle.high + candle.low + candle.close) / 3.0 * candle.volume; + volume += candle.volume; + (volume > 0.0).then(|| weighted / volume) + }) + .collect() +} + +/// MACD: a fast average less a slow one, its own average as the signal, and the gap between them. +pub(crate) struct Macd { + pub line: Reading, + pub signal: Reading, + pub histogram: Reading, +} + +pub(crate) fn macd(values: &[f64], (fast, slow, signal): (usize, usize, usize)) -> Macd { + let (quick, steady) = (ema(values, fast), ema(values, slow)); + let line: Reading = quick + .iter() + .zip(&steady) + .map(|(a, b)| Some((*a)? - (*b)?)) + .collect(); + let signal = ema_of(&line, signal); + let histogram = line + .iter() + .zip(&signal) + .map(|(a, b)| Some((*a)? - (*b)?)) + .collect(); + Macd { + line, + signal, + histogram, + } +} + +/// The relative strength index with Wilder's smoothing: 100 when every change rose, 0 when every one fell. +pub(crate) fn rsi(values: &[f64], period: usize) -> Reading { + assert!(period > 0, "an index needs a period"); + let mut out = vec![None; values.len()]; + if values.len() <= period { + return out; + } + let change = |ix: usize| values[ix] - values[ix - 1]; + let (mut gain, mut loss) = (1..=period).fold((0.0, 0.0), |(gain, loss), ix| { + (gain + change(ix).max(0.0), loss + (-change(ix)).max(0.0)) + }); + (gain, loss) = (gain / period as f64, loss / period as f64); + let index = |gain: f64, loss: f64| { + if loss == 0.0 { + 100.0 + } else { + 100.0 - 100.0 / (1.0 + gain / loss) + } + }; + out[period] = Some(index(gain, loss)); + for (ix, slot) in out.iter_mut().enumerate().skip(period + 1) { + let smoothing = (period - 1) as f64; + gain = (gain * smoothing + change(ix).max(0.0)) / period as f64; + loss = (loss * smoothing + (-change(ix)).max(0.0)) / period as f64; + *slot = Some(index(gain, loss)); + } + out +} + +/// The KDJ stochastic: where each close sits in the last `period` candles' range as K, K smoothed as D, and J pulling ahead of both. +pub(crate) fn kdj(candles: &[Candle], period: usize) -> (Reading, Reading, Reading) { + assert!(period > 0, "a stochastic needs a period"); + let (mut k, mut d) = (50.0, 50.0); + let (mut ks, mut ds, mut js) = ( + vec![None; candles.len()], + vec![None; candles.len()], + vec![None; candles.len()], + ); + for ix in period.saturating_sub(1)..candles.len() { + let window = &candles[ix + 1 - period..=ix]; + let (low, high) = window + .iter() + .fold((f64::MAX, f64::MIN), |(low, high), candle| { + (low.min(candle.low), high.max(candle.high)) + }); + let raw = if high > low { + (candles[ix].close - low) / (high - low) * 100.0 + } else { + 50.0 + }; + k = k * 2.0 / 3.0 + raw / 3.0; + d = d * 2.0 / 3.0 + k / 3.0; + (ks[ix], ds[ix], js[ix]) = (Some(k), Some(d), Some(3.0 * k - 2.0 * d)); + } + (ks, ds, js) +} + +/// On-balance volume: the running total of volume, added on a rising close and taken on a falling one. +pub(crate) fn obv(candles: &[Candle]) -> Vec { + let mut total = 0.0; + candles + .iter() + .enumerate() + .map(|(ix, candle)| { + if let Some(last) = ix.checked_sub(1).map(|before| candles[before].close) { + total += if candle.close > last { + candle.volume + } else if candle.close < last { + -candle.volume + } else { + 0.0 + }; + } + total + }) + .collect() +} + +#[cfg(test)] +mod tests { + use jiff::Timestamp; + + use super::*; + + fn candle(close: f64, volume: f64) -> Candle { + Candle::new( + Timestamp::UNIX_EPOCH, + (close, close + 1.0, close - 1.0, close), + volume, + ) + } + + #[test] + fn averages_start_once_they_have_a_period() { + assert_eq!( + sma(&[1.0, 2.0, 3.0, 4.0], 2), + [None, Some(1.5), Some(2.5), Some(3.5)] + ); + assert_eq!( + ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), + [None, None, Some(2.0), Some(3.0), Some(4.0)] + ); + } + + #[test] + fn bands_sit_two_deviations_either_side() { + let bands = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0); + assert_eq!(bands[7], Some((1.0, 5.0, 9.0))); + assert_eq!(bands[6], None); + } + + #[test] + fn momentum_reads_rises_and_falls() { + let rising: Vec = (0..20).map(f64::from).collect(); + assert_eq!(rsi(&rising, 14)[19], Some(100.0), "nothing fell"); + let falling: Vec = rising.iter().rev().copied().collect(); + assert_eq!(rsi(&falling, 14)[19], Some(0.0), "nothing rose"); + let trend = macd(&rising, (3, 6, 2)); + assert_eq!(trend.line[4], None); + assert!(trend.line[5].is_some() && trend.signal[5].is_none() && trend.signal[6].is_some()); + assert!( + trend.line[10].expect("a line") > 0.0, + "a rise holds the fast average above the slow" + ); + } + + #[test] + fn volume_follows_the_close() { + let candles = [ + candle(10.0, 100.0), + candle(11.0, 200.0), + candle(10.5, 50.0), + candle(10.5, 70.0), + ]; + assert_eq!(obv(&candles), [0.0, 200.0, 150.0, 150.0]); + let mean = vwap(&candles); + assert_eq!(mean[0], Some(10.0)); + assert!((mean[1].expect("a price") - (10.0 * 100.0 + 11.0 * 200.0) / 300.0).abs() < 1e-9); + let (k, d, j) = kdj(&candles, 2); + assert_eq!(k[0], None); + assert!(k[1].is_some() && d[1].is_some()); + assert!( + (j[1].expect("j") - (3.0 * k[1].expect("k") - 2.0 * d[1].expect("d"))).abs() < 1e-9 + ); + } +} diff --git a/src/finance/labels.rs b/src/finance/labels.rs new file mode 100644 index 0000000..81a8885 --- /dev/null +++ b/src/finance/labels.rs @@ -0,0 +1,342 @@ +use std::ops::Range; + +use gpui::{App, Div, FontWeight, Hsla, ParentElement, Pixels, SharedString, Styled, Window, div}; +use jiff::tz::TimeZone; + +use super::{ + candles::Candle, + series::Drawn, + stage::{Visible, decimals}, + tools::Drawing, +}; +use crate::{ + charts::{Linear, Rect, tint}, + theme::{ActiveTheme, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// Where a market chart's words go: its panes and their scales, what is in view, the candle and row under the pointer, the zone its times read in, its title, and a compared symbol's name. +pub(crate) struct Axes { + pub panes: Vec<(Rect, Linear, Vec)>, + pub visible: Visible, + pub range: Range, + pub hover: Option, + pub row: Option<(usize, f32)>, + pub zone: TimeZone, + pub title: Option, + pub compare: Option, + pub drawings: Vec, +} + +/// Places a price shows: its ticks' own, and at least two. +fn places(ticks: &[f64]) -> usize { + let step = ticks + .windows(2) + .map(|pair| pair[1] - pair[0]) + .next() + .unwrap_or(1.0); + decimals(step).max(2) +} + +/// How a candle's time reads: the month and year for monthly candles, the day for daily, the hour within a day. +fn stamp(candle: &Candle, interval: i64, zone: &TimeZone, full: bool) -> String { + let zoned = candle.time.to_zoned(zone.clone()); + let pattern = match (interval, full) { + (seconds, _) if seconds >= 86_400 * 28 => "%b %Y", + (seconds, true) if seconds >= 86_400 => "%a %b %-d, %Y", + (seconds, false) if seconds >= 86_400 => "%b %-d", + (_, true) => "%b %-d %H:%M", + (_, false) => "%H:%M", + }; + zoned.strftime(pattern).to_string() +} + +/// Price alerts with their words, and the last price with whether it rose. +pub(crate) type Marks<'a> = (&'a [(f64, SharedString)], Option<(f64, bool)>); + +/// Every word a market chart shows: price and time ticks, the readout of the candle under the pointer or the last, each study's readout, and the pills at the price scale for the last price, alerts and the crosshair. +pub(crate) fn labels( + axes: &Axes, + (shown, candles): (&[Candle], &[Candle]), + (overlays, studies): (&[Drawn], &[Drawn]), + (alerts, last): Marks, + (rise, fall): (Hsla, Hsla), + window: &Window, + cx: &App, +) -> Vec
{ + let theme = cx.theme(); + let (colors, sizes, rem) = (&theme.colors, theme.chart(), window.rem_size()); + let small = theme.text_size(TextSize::Xs); + let text = |words: String, color: Hsla| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(small) + .text_color(color) + .child(words) + }; + let (main, price, price_ticks) = (&axes.panes[0].0, &axes.panes[0].1, &axes.panes[0].2); + let digits = places(price_ticks); + let edge = main.x + main.w; + let beside = |y: f32, label: Div| { + div() + .absolute() + .left(Pixels::from(edge)) + .top(Pixels::from(y)) + .h_0() + .flex() + .items_center() + .pl_2() + .child(label) + }; + let mut out: Vec
= Vec::new(); + for (ix, (_, scale, ticks)) in axes.panes.iter().enumerate() { + let digits = if ix == 0 { + digits + } else { + places(ticks).min(2) + }; + out.extend(ticks.iter().map(|tick| { + beside( + scale.at(*tick), + tabular(text( + format::number(*tick, digits, format::Separators::EN), + colors.fg_subtle, + )), + ) + })); + } + let interval = candles.windows(2).next().map_or(86_400, |pair| { + pair[1].time.as_second() - pair[0].time.as_second() + }); + let room = f32::from(sizes.label.to_pixels(rem)) * 1.25; + let every = ((room / axes.visible.slot(main.w)).ceil() as usize).max(1); + let bottom = axes + .panes + .last() + .map_or(main.y + main.h, |(pane, _, _)| pane.y + pane.h); + for ix in axes.range.clone().filter(|ix| ix % every == 0) { + let x = axes.visible.x(ix as f64, (main.x, main.w)); + if x < main.x || x > edge { + continue; + } + let label = text( + stamp(&candles[ix], interval, &axes.zone, false), + colors.fg_subtle, + ); + out.push( + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(bottom)) + .w_0() + .flex() + .justify_center() + .pt_1p5() + .child(label), + ); + } + let at = axes + .hover + .map(|ix| ix as usize) + .or(candles.len().checked_sub(1)); + if let Some(ix) = at { + let candle = &shown[ix]; + let change = candle.close - candle.open; + let ink = if change >= 0.0 { rise } else { fall }; + let number = |value: f64| format::number(value, digits, format::Separators::EN); + let mut readout = div() + .absolute() + .left(Pixels::from(main.x)) + .top(Pixels::from(main.y)) + .flex() + .flex_wrap() + .items_center() + .gap_x_3() + .gap_y_0p5() + .px_1(); + if let Some(title) = &axes.title { + readout = readout.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(title.clone()), + ); + } + for (name, value) in [ + ("O", candle.open), + ("H", candle.high), + ("L", candle.low), + ("C", candle.close), + ] { + readout = readout.child( + div() + .flex() + .gap_1() + .child(text(name.into(), colors.fg_subtle)) + .child(tabular(text(number(value), ink))), + ); + } + let share = if candle.open != 0.0 { + change / candle.open + } else { + 0.0 + }; + readout = readout.child(tabular(text(format::percent(share, 2, true), ink))); + for drawn in overlays { + let reading = drawn.lines.first().and_then(|(_, values)| values[ix]); + let color = tint(colors, drawn.lines.first().map_or(0, |(color, _)| *color)); + readout = readout.child( + div() + .flex() + .gap_1() + .child(text(drawn.name.to_string(), color)) + .children(reading.map(|value| tabular(text(number(value), color)))), + ); + } + if let Some(name) = &axes.compare { + readout = readout.child(text(format!("vs {name}"), colors.fg_muted)); + } + out.push(readout); + for ((pane, _, _), drawn) in axes.panes.iter().skip(1).zip(studies) { + let values = drawn + .lines + .iter() + .filter_map(|(color, values)| values[ix].map(|value| (*color, value))); + let line = div() + .absolute() + .left(Pixels::from(pane.x)) + .top(Pixels::from(pane.y)) + .flex() + .gap_2() + .px_1() + .child(text(drawn.name.to_string(), colors.fg_muted)); + out.push(line.children(values.map(|(color, value)| { + tabular(text( + format::number(value, 2, format::Separators::EN), + tint(colors, color), + )) + }))); + } + } + for drawing in &axes.drawings { + let Drawing::Fib(from, to) = *drawing else { + continue; + }; + let x = axes.visible.x(from.0.min(to.0), (main.x, main.w)); + for (share, value) in Drawing::levels(from, to) { + let words = format!( + "{} {}", + format::percent(share, 1, false), + format::number(value, digits, format::Separators::EN) + ); + out.push( + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(price.at(value))) + .h_0() + .flex() + .items_end() + .pl_1() + .child(tabular(text(words, colors.fg_muted)).pb_0p5()), + ); + } + } + let pill = |y: f32, words: String, (fill, ink): (Hsla, Hsla)| { + let body = tabular(div()) + .px_1p5() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .bg(fill) + .text_size(small) + .text_color(ink) + .whitespace_nowrap() + .child(words); + div() + .absolute() + .left(Pixels::from(edge)) + .top(Pixels::from(y)) + .h_0() + .flex() + .items_center() + .pl_1() + .child(body) + }; + let number = |value: f64| format::number(value, digits, format::Separators::EN); + let shown = |value: f64| (price.domain.0..=price.domain.1).contains(&value); + for (value, label) in alerts.iter().filter(|(value, _)| shown(*value)) { + out.push(pill( + price.at(*value), + format!("{label} {}", number(*value)), + (colors.warning_subtle, colors.fg), + )); + } + if let Some((value, rose)) = last.filter(|(value, _)| shown(*value)) { + out.push(pill( + price.at(value), + number(value), + (if rose { rise } else { fall }, colors.bg), + )); + } + if let Some((pane, y)) = axes.row { + let (_, scale, ticks) = &axes.panes[pane]; + let digits = if pane == 0 { + digits + } else { + places(ticks).min(2) + }; + out.push(pill( + y, + format::number(scale.value(y), digits, format::Separators::EN), + (colors.fg, colors.bg), + )); + } + if let Some(ix) = axes.hover.filter(|ix| (*ix as usize) < candles.len()) { + let x = axes.visible.x(ix as f64, (main.x, main.w)); + let body = div() + .px_1p5() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.fg) + .text_size(small) + .text_color(colors.bg) + .whitespace_nowrap(); + let label = body.child(stamp(&candles[ix as usize], interval, &axes.zone, true)); + out.push( + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(bottom)) + .w_0() + .flex() + .justify_center() + .pt_1() + .child(label), + ); + } + out +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, tz::TimeZone}; + + use super::*; + + #[test] + fn times_read_by_the_candles_interval() { + let candle = Candle::new( + Timestamp::from_second(1_780_000_000).expect("a time"), + (1.0, 1.0, 1.0, 1.0), + 0.0, + ); + let utc = TimeZone::UTC; + assert_eq!(stamp(&candle, 86_400, &utc, false), "May 28"); + assert_eq!(stamp(&candle, 3_600, &utc, false), "20:26"); + assert_eq!(stamp(&candle, 86_400 * 30, &utc, false), "May 2026"); + assert_eq!(places(&[100.0, 105.0]), 2, "prices keep cents"); + assert_eq!(places(&[0.001, 0.002]), 3); + } +} diff --git a/src/finance/market.rs b/src/finance/market.rs new file mode 100644 index 0000000..54a69f3 --- /dev/null +++ b/src/finance/market.rs @@ -0,0 +1,422 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Refineable, + RenderOnce, SharedString, StyleRefinement, Styled, Window, div, +}; +use jiff::tz::TimeZone; + +use super::{ + candles::{Candle, heikin_ashi, profile}, + draw::{Pen, Scene, drawing}, + labels::{Axes, labels}, + quotes::moves, + series::{Drawn, Overlay, Study, overlay, study}, + stage::{Visible, fit}, + steer::{OnDraw, Stage, Steering}, + tools::{Drawing, Tool}, +}; +use crate::{ + charts::{Linear, Rect, measure}, + theme::ActiveTheme, +}; + +/// How a market chart draws its prices. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ChartKind { + #[default] + Candles, + /// Open, high, low and close as bars with ticks. + Bars, + /// Candles smoothed by averaging, which hold a trend's color longer. + HeikinAshi, + Line, + Area, +} + +/// What market charts that move together share: the candles in view and the one under the pointer. Create one entity and hand it to each chart. +#[derive(Default)] +pub struct ChartSync { + pub(crate) visible: Option, + pub(crate) hover: Option, +} + +/// A market's prices over time as candles, bars or a line, on a price scale at the right. Drag or scroll sideways to move through time, hold Cmd and scroll to zoom, and double-press to return to the newest; the crosshair reads each candle. +#[derive(IntoElement)] +pub struct CandlestickChart { + base: Div, + id: ElementId, + candles: Rc>, + kind: ChartKind, + title: Option, + volume: bool, + profile: bool, + overlays: Vec, + studies: Vec, + alerts: Vec<(f64, SharedString)>, + compare: Option<(SharedString, Rc>)>, + sync: Option>, + red_up: bool, + zone: TimeZone, + drawings: Vec, + tool: Option, + on_draw: Option, +} + +impl CandlestickChart { + /// Candles oldest first, one per period. + pub fn new(id: impl Into, candles: impl Into>>) -> Self { + let candles = candles.into(); + assert!( + candles.windows(2).all(|pair| pair[0].time < pair[1].time), + "candles run oldest first" + ); + Self { + base: div(), + id: id.into(), + candles, + kind: ChartKind::Candles, + title: None, + volume: false, + profile: false, + overlays: Vec::new(), + studies: Vec::new(), + alerts: Vec::new(), + compare: None, + sync: None, + red_up: false, + zone: TimeZone::system(), + drawings: Vec::new(), + tool: None, + on_draw: None, + } + } + + /// Marks the owner keeps, drawn over the prices. + pub fn drawings(mut self, drawings: impl IntoIterator) -> Self { + self.drawings = drawings.into_iter().collect(); + self + } + + /// A tool to draw with: a drag over the prices draws with it instead of moving through time. + pub fn tool(mut self, tool: Option) -> Self { + self.tool = tool; + self + } + + /// Gets each drawing a tool finishes, for the owner to keep. + pub fn on_draw(mut self, handler: impl Fn(Drawing, &mut Window, &mut App) + 'static) -> Self { + self.on_draw = Some(Rc::new(handler)); + self + } + + pub fn kind(mut self, kind: ChartKind) -> Self { + self.kind = kind; + self + } + + /// A name read at the top left, such as a symbol and its interval. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Volume as faint bars along the bottom. + pub fn volume(mut self) -> Self { + self.volume = true; + self + } + + /// The volume traded at each price in view, as bars from the right edge. + pub fn profile(mut self) -> Self { + self.profile = true; + self + } + + pub fn overlay(mut self, overlay: Overlay) -> Self { + self.overlays.push(overlay); + self + } + + /// A study in a pane of its own under the prices. + pub fn study(mut self, study: Study) -> Self { + self.studies.push(study); + self + } + + /// A dashed level with its label at the price scale, as a price alert. + pub fn alert(mut self, price: f64, label: impl Into) -> Self { + assert!(price.is_finite(), "an alert needs a finite price"); + self.alerts.push((price, label.into())); + self + } + + /// Another symbol's closes over the same periods, drawn from where this one starts in view so their moves compare. + pub fn compare( + mut self, + name: impl Into, + candles: impl Into>>, + ) -> Self { + let candles = candles.into(); + assert_eq!( + candles.len(), + self.candles.len(), + "a compared symbol needs a candle for each period" + ); + self.compare = Some((name.into(), candles)); + self + } + + /// Moves, zooms and points together with every chart given the same sync. + pub fn sync(mut self, sync: &Entity) -> Self { + self.sync = Some(sync.clone()); + self + } + + /// Rising prices in red and falling in green, as markets in China and Japan read them. + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } + + /// The time zone its times read in; the system's unless set. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } +} + +impl Styled for CandlestickChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for CandlestickChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let sync = self.sync.clone(); + let stage: Entity = + window.use_keyed_state((self.id.clone(), "stage"), cx, |_, cx| Stage { + _synced: sync + .as_ref() + .map(|sync| cx.observe(sync, |_, _, cx| cx.notify())), + ..Stage::default() + }); + let theme = cx.theme(); + let (sizes, rem) = (theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let bounds = stage.read(cx).bounds; + let (gutter, foot, inset) = ( + pixels(sizes.gutter) * 1.25, + pixels(sizes.foot), + pixels(sizes.inset), + ); + let frame = Rect { + x: inset, + y: inset, + w: (f32::from(bounds.size.width) - inset - gutter).max(0.0), + h: (f32::from(bounds.size.height) - inset - foot).max(0.0), + }; + let study_tall = pixels(sizes.height) * 0.3; + let main = Rect { + h: (frame.h - study_tall * self.studies.len() as f32).max(0.0), + ..frame + }; + let total = self.candles.len(); + let held = match &self.sync { + Some(sync) => (sync.read(cx).visible, sync.read(cx).hover), + None => (stage.read(cx).visible, stage.read(cx).hover), + }; + let seen = stage.read(cx).seen; + let visible = match held.0 { + Some(visible) + if total > seen + && seen > 0 + && visible.start + visible.count >= seen as f64 - 0.5 => + { + visible.pan((total - seen) as f64, total) + } + Some(visible) => visible, + None => Visible::latest(total, f64::from(main.w / pixels(sizes.candle)).max(1.0)), + }; + if seen != total { + stage.update(cx, |stage, _| stage.seen = total); + } + let range = visible.range(total); + let shown = Rc::new(if self.kind == ChartKind::HeikinAshi { + heikin_ashi(&self.candles) + } else { + (*self.candles).clone() + }); + let drawn: Rc> = Rc::new( + self.overlays + .iter() + .enumerate() + .map(|(ix, kind)| overlay(*kind, &self.candles, ix + 1)) + .collect(), + ); + let studied: Rc> = Rc::new( + self.studies + .iter() + .map(|kind| study(*kind, &self.candles, 1)) + .collect(), + ); + let compare = self.compare.as_ref().and_then(|(_, other)| { + let first = range.clone().next()?; + let ratio = self.candles[first].close / other[first].close; + Some( + other + .iter() + .map(|candle| Some(candle.close * ratio)) + .collect::>(), + ) + }); + let in_view = &shown[range.clone()]; + let mut reach = in_view.iter().fold(None::<(f64, f64)>, |reach, candle| { + let (low, high) = reach.unwrap_or((candle.low, candle.high)); + Some((low.min(candle.low), high.max(candle.high))) + }); + for extra in drawn + .iter() + .filter_map(|drawn| drawn.reach(range.clone())) + .chain(compare.iter().filter_map(|compare| { + compare[range.clone()] + .iter() + .flatten() + .fold(None::<(f64, f64)>, |reach, value| { + let (low, high) = reach.unwrap_or((*value, *value)); + Some((low.min(*value), high.max(*value))) + }) + })) + { + let (low, high) = reach.unwrap_or(extra); + reach = Some((low.min(extra.0), high.max(extra.1))); + } + let ((low, high), ticks) = fit( + reach.map_or(0.0, |reach| reach.0), + reach.map_or(1.0, |reach| reach.1), + ); + let mut panes = vec![( + main, + Linear::new((low, high), (main.y + main.h, main.y)), + ticks, + )]; + for (ix, drawn) in studied.iter().enumerate() { + let pane = Rect { + x: frame.x, + y: main.y + main.h + study_tall * ix as f32, + w: frame.w, + h: study_tall, + }; + let inner = Rect { + y: pane.y + inset, + h: (pane.h - inset * 2.0).max(0.0), + ..pane + }; + let (domain, ticks) = match drawn.reach(range.clone()) { + Some(range) if drawn.range.is_some() => (range, drawn.guides.clone()), + Some((low, high)) => fit(low, high), + None => ((0.0, 1.0), Vec::new()), + }; + panes.push(( + pane, + Linear::new(domain, (inner.y + inner.h, inner.y)), + ticks, + )); + } + let (rise, fall) = moves(self.red_up, cx); + let pointer = stage.read(cx).pointer; + let hover = held.1.filter(|ix| *ix >= 0 && (*ix as usize) < total); + let row = pointer.and_then(|(_, y)| { + panes + .iter() + .position(|(pane, _, _)| y >= pane.y && y <= pane.y + pane.h) + .map(|pane| (pane, y)) + }); + let last = self + .candles + .last() + .map(|candle| (candle.close, candle.rose())); + let volume = self.volume.then(|| { + in_view + .iter() + .map(|candle| candle.volume) + .fold(0.0, f64::max) + }); + let profiled = if self.profile { + profile(in_view, (low, high), 24) + } else { + Vec::new() + }; + let sketch = stage.read(cx).sketch; + let marks: Vec = self + .drawings + .iter() + .copied() + .chain(sketch.map(|(tool, from, to)| Drawing::of(tool, from, to))) + .collect(); + let price = panes[0].1; + let axes = Axes { + panes: panes.clone(), + visible, + range: range.clone(), + hover, + row, + zone: self.zone.clone(), + title: self.title.clone(), + compare: self.compare.as_ref().map(|(name, _)| name.clone()), + drawings: marks.clone(), + }; + let words = labels( + &axes, + (&shown, &self.candles), + (&drawn, &studied), + (&self.alerts, last), + (rise, fall), + window, + cx, + ); + let theme = cx.theme(); + let scene = Scene { + panes, + visible, + range, + candles: shown, + kind: self.kind, + volume, + profile: profiled, + overlays: drawn, + studies: studied, + compare, + last, + alerts: self.alerts.iter().map(|(price, _)| *price).collect(), + cross: hover.map(|ix| (ix, row)), + drawings: marks, + pen: Pen { + palette: theme.colors.clone(), + rise, + fall, + stroke: sizes.stroke.to_pixels(rem), + hairline: sizes.hairline, + }, + }; + let height = sizes.height + sizes.height * 0.3 * self.studies.len() as f32; + let mut root = div().relative().w_full().h(height); + root.style().refine(self.base.style()); + let steering = Steering { + stage: stage.clone(), + sync: self.sync.clone(), + visible, + total, + main, + price, + tool: self.tool, + on_draw: self.on_draw.clone(), + }; + steering + .attach(root.id((self.id.clone(), "market"))) + .child(drawing(scene)) + .children(words) + .child(measure(stage, |stage| &mut stage.bounds)) + } +} diff --git a/src/finance/mod.rs b/src/finance/mod.rs new file mode 100644 index 0000000..05c82ed --- /dev/null +++ b/src/finance/mod.rs @@ -0,0 +1,57 @@ +mod blotter; +mod book; +mod calendars; +mod candles; +mod controls; +mod depth; +mod draw; +mod heatmap; +mod indicators; +mod labels; +mod market; +mod options; +mod overview; +mod payoff; +mod portfolio; +mod quotes; +mod renko; +mod risk; +mod series; +mod spread; +mod stage; +mod steer; +mod symbols; +mod tape; +mod tools; +mod trade; +mod wallet; + +pub use blotter::{ + Fill, OrderTable, PnLDisplay, Position, PositionTable, TradeHistoryTable, Working, +}; +pub use book::{Level2Quotes, OrderBook, Side, Trade}; +pub use calendars::{Earnings, EarningsCalendar, EconomicCalendar, NewsFeed, Release, Story}; +pub use candles::Candle; +pub use controls::{ + Arrangement, ChartTypeSwitcher, INTERVALS, IntervalSelector, MultiChartLayout, RANGES, + TimeRangeSelector, +}; +pub use depth::DepthChart; +pub use heatmap::MarketHeatmap; +pub use market::{CandlestickChart, ChartKind, ChartSync}; +pub use options::{GreeksTable, OptionChain, OptionQuote, Strike}; +pub use overview::{MarketOverview, MarketStatus, Session, TradingSessionClock}; +pub use payoff::{Leg, PayoffDiagram}; +pub use portfolio::{ + Dividend, DividendTable, FinancialStatementTable, PerformanceChart, PortfolioSummary, Statement, +}; +pub use quotes::{PriceChangeBadge, PriceText, QuoteCard, TickerTape}; +pub use renko::{PointFigureChart, RenkoChart}; +pub use risk::{LeverageSlider, MarginIndicator, RiskMeter}; +pub use series::{Overlay, Study}; +pub use spread::{BidAskBar, SpreadIndicator}; +pub use symbols::{Screener, SymbolBadge, SymbolSearch, Watch, Watchlist}; +pub use tape::{DomLadder, TimeAndSales}; +pub use tools::{Drawing, DrawingToolbar, FIB, IndicatorSelector, Tool}; +pub use trade::{Order, OrderConfirmDialog, OrderEntry, OrderKind, QuickTradeButtons}; +pub use wallet::{CryptoWalletCard, CurrencyConverter, Settled, TransactionList, Transfer}; diff --git a/src/finance/options.rs b/src/finance/options.rs new file mode 100644 index 0000000..cbbf1cf --- /dev/null +++ b/src/finance/options.rs @@ -0,0 +1,289 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, +}; + +use super::quotes::price; +use crate::{ + charts::compact, + theme::{ActiveTheme, Density, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// One side of a strike: its bid, ask, volume, and implied volatility as a share. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OptionQuote { + pub bid: f64, + pub ask: f64, + pub volume: f64, + pub volatility: f64, +} + +/// A strike and its call and put. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Strike { + pub strike: f64, + pub call: OptionQuote, + pub put: OptionQuote, +} + +type OnPick = Rc; + +/// Calls and puts across strikes: calls to the left, puts to the right, strikes between; the side in the money is shaded and a line marks where the price stands. A press on a quote picks its option. +#[derive(IntoElement)] +pub struct OptionChain { + id: ElementId, + strikes: Vec, + spot: f64, + on_pick: Option, +} + +impl OptionChain { + pub fn new( + id: impl Into, + strikes: impl IntoIterator, + spot: f64, + ) -> Self { + let mut strikes: Vec = strikes.into_iter().collect(); + strikes.sort_by(|a, b| a.strike.total_cmp(&b.strike)); + Self { + id: id.into(), + strikes, + spot, + on_pick: None, + } + } + + /// Gets a strike and whether the call, rather than the put, was pressed. + pub fn on_pick(mut self, handler: impl Fn(f64, bool, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +/// A side's four numbers as cells. +fn quote_cells(quote: &OptionQuote, ink: Hsla) -> [Div; 4] { + let number = |words: String, color: Hsla| { + tabular(div()) + .flex_1() + .text_right() + .text_color(color) + .child(words) + }; + [ + number(price(quote.bid, 2), ink), + number(price(quote.ask, 2), ink), + number(compact(quote.volume), ink), + number(format::percent(quote.volatility, 1, false), ink), + ] +} + +impl RenderOnce for OptionChain { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let tall = theme.table_row(Density::Compact); + let small = theme.text_size(TextSize::Xs); + let names = + |names: [&'static str; 4]| names.map(|name| div().flex_1().text_right().child(name)); + let header = div() + .flex() + .gap_2() + .px_2() + .pb_1() + .text_size(small) + .text_color(colors.fg_subtle) + .child( + div() + .flex_1() + .flex() + .gap_2() + .children(names(["Bid", "Ask", "Volume", "IV"])), + ) + .child( + div() + .w(theme.label_width() * 0.5) + .text_center() + .child("Strike"), + ) + .child( + div() + .flex_1() + .flex() + .gap_2() + .children(names(["Bid", "Ask", "Volume", "IV"])), + ); + let above = self + .strikes + .iter() + .position(|strike| strike.strike > self.spot) + .unwrap_or(self.strikes.len()); + let mut rows: Vec = Vec::new(); + for (ix, strike) in self.strikes.iter().enumerate() { + if ix == above { + let pill = tabular(div()) + .px_1p5() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.fg) + .text_color(colors.bg) + .text_size(small) + .child(price(self.spot, 2)); + let rule = || div().flex_1().h(theme.chart().hairline).bg(colors.fg); + rows.push( + div() + .flex() + .items_center() + .gap_2() + .child(rule()) + .child(pill) + .child(rule()) + .into_any_element(), + ); + } + let half = |call: bool| { + let quote = if call { &strike.call } else { &strike.put }; + let money = if call { + strike.strike < self.spot + } else { + strike.strike > self.spot + }; + let pick = self.on_pick.clone(); + let at = strike.strike; + div() + .id(( + self.id.clone(), + SharedString::from(format!("{at}-{}", if call { "call" } else { "put" })), + )) + .flex_1() + .flex() + .gap_2() + .px_2() + .h_full() + .items_center() + .when(money, |half| half.bg(colors.hover)) + .cursor_pointer() + .hover(|style| style.bg(colors.active)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(pick, |half, pick| { + half.on_click(move |_, window, cx| { + log::info!("option chain: {} {at}", if call { "call" } else { "put" }); + pick(at, call, window, cx) + }) + }) + .children(quote_cells(quote, colors.fg)) + }; + rows.push( + div() + .flex() + .h(tall) + .text_size(theme.text_size(TextSize::Sm)) + .border_b_1() + .border_color(colors.border.opacity(0.5)) + .child(half(true)) + .child( + tabular(div()) + .w(theme.label_width() * 0.5) + .h_full() + .flex() + .items_center() + .justify_center() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(price(strike.strike, 2)), + ) + .child(half(false)) + .into_any_element(), + ); + } + div().flex().flex_col().child(header).children(rows) + } +} + +/// An option's or a book's greeks: how its price answers to the underlying, time, volatility and rates. +#[derive(IntoElement)] +pub struct GreeksTable { + greeks: [f64; 5], +} + +impl GreeksTable { + /// Delta, gamma, theta, vega and rho. + pub fn new(greeks: [f64; 5]) -> Self { + assert!( + greeks.iter().all(|greek| greek.is_finite()), + "greeks are finite" + ); + Self { greeks } + } +} + +const GREEKS: [(&str, &str, &str); 5] = [ + ( + "Δ", + "Delta", + "Price change for each dollar the underlying moves", + ), + ( + "Γ", + "Gamma", + "Delta change for each dollar the underlying moves", + ), + ("Θ", "Theta", "Value lost each day as expiry nears"), + ("ν", "Vega", "Price change for each point of volatility"), + ("ρ", "Rho", "Price change for each point of interest rates"), +]; + +impl RenderOnce for GreeksTable { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .flex_col() + .children( + GREEKS + .iter() + .zip(self.greeks) + .map(|((symbol, name, meaning), value)| { + 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( + div() + .w(theme.icon_size(crate::theme::IconSize::Md)) + .text_color(colors.fg_subtle) + .child(*symbol), + ) + .child( + div() + .w(theme.label_width() * 0.5) + .text_color(colors.fg) + .child(*name), + ) + .child( + tabular(div()) + .w(theme.label_width() * 0.5) + .text_right() + .text_color(colors.fg) + .child(format::number(value, 4, format::Separators::EN)), + ) + .child( + div() + .flex_1() + .min_w_0() + .pl_3() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(*meaning), + ) + }), + ) + } +} diff --git a/src/finance/overview.rs b/src/finance/overview.rs new file mode 100644 index 0000000..8963def --- /dev/null +++ b/src/finance/overview.rs @@ -0,0 +1,369 @@ +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, relative, +}; + +use super::quotes::{PriceChangeBadge, QuoteCard, price}; +use crate::{ + charts::tint, + theme::{ActiveTheme, TextSize}, + typography::tabular, +}; + +/// Where a market's day stands: before hours, open, after hours, or closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Session { + Before, + Open, + After, + Closed, +} + +/// Whether a market trades now, with a dot, and when that next changes. +#[derive(IntoElement)] +pub struct MarketStatus { + market: SharedString, + session: Session, + next: Option, +} + +impl MarketStatus { + pub fn new(market: impl Into, session: Session) -> Self { + Self { + market: market.into(), + session, + next: None, + } + } + + /// When the session changes, such as "Closes in 2 h 14 min". + pub fn next(mut self, next: impl Into) -> Self { + self.next = Some(next.into()); + self + } +} + +impl RenderOnce for MarketStatus { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (words, dot) = match self.session { + Session::Before => ("Before hours", colors.warning), + Session::Open => ("Open", colors.success), + Session::After => ("After hours", colors.warning), + Session::Closed => ("Closed", colors.fg_subtle), + }; + div() + .flex() + .items_center() + .gap_2() + .px_2p5() + .py_1() + .rounded_full() + .border_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .child(div().size(theme.status_dot()).rounded_full().bg(dot)) + .child( + div() + .text_color(colors.fg) + .child(format!("{} · {words}", self.market)), + ) + .children( + self.next + .map(|next| div().text_color(colors.fg_muted).child(next)), + ) + } +} + +/// The world's trading day on one scale: each market's hours as a band across a day in UTC, those open now lit, and a line at now. +#[derive(IntoElement)] +pub struct TradingSessionClock { + markets: Vec<(SharedString, f32, f32)>, + now: f32, +} + +impl TradingSessionClock { + /// `now` in hours since midnight UTC. + pub fn new(now: f32) -> Self { + assert!((0.0..24.0).contains(&now), "now is an hour of the day"); + Self { + markets: Vec::new(), + now, + } + } + + /// A market and its hours in UTC; a session past midnight runs from `open` to `close` the next day. + pub fn market(mut self, name: impl Into, open: f32, close: f32) -> Self { + assert!( + (0.0..24.0).contains(&open) && (0.0..=24.0).contains(&close), + "hours run from 0 to 24" + ); + self.markets.push((name.into(), open, close)); + self + } +} + +/// A session's bands across the day, split at midnight when it runs past it, as shares of the day. +pub(crate) fn bands(open: f32, close: f32) -> Vec<(f32, f32)> { + if open <= close { + vec![(open / 24.0, close / 24.0)] + } else { + vec![(open / 24.0, 1.0), (0.0, close / 24.0)] + } +} + +impl RenderOnce for TradingSessionClock { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let now = self.now / 24.0; + let rows = self + .markets + .iter() + .enumerate() + .map(|(ix, (name, open, close))| { + let spans = bands(*open, *close); + let lit = spans.iter().any(|(from, to)| (*from..*to).contains(&now)); + let ink = tint(&colors, ix); + let track = div() + .relative() + .flex_1() + .h(theme.meter_track() * 2.0) + .rounded_full() + .bg(colors.hover) + .children(spans.into_iter().map(|(from, to)| { + div() + .absolute() + .top_0() + .bottom_0() + .left(relative(from)) + .w(relative(to - from)) + .rounded_full() + .bg(if lit { ink } else { ink.opacity(0.35) }) + })); + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .w(theme.label_width() * 0.6) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if lit { colors.fg } else { colors.fg_muted }) + .child(name.clone()), + ) + .child(track) + }); + let hours = [0, 6, 12, 18, 24].map(|hour| { + let at = hour as f32 / 24.0; + div() + .absolute() + .top_0() + .left(relative(at)) + .w_0() + .flex() + .justify_center() + .child(tabular(div()).child(format!("{hour:02}:00"))) + }); + let line = div() + .absolute() + .top_0() + .bottom_0() + .left(relative(now)) + .w(theme.chart().hairline) + .bg(colors.fg); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .relative() + .flex() + .flex_col() + .gap_2() + .children(rows) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(theme.label_width() * 0.6) + .right_0() + .ml_3() + .child(line), + ), + ) + .child( + div() + .flex() + .gap_3() + .child(div().w(theme.label_width() * 0.6)) + .child( + div() + .relative() + .flex_1() + .h(theme.text_size(TextSize::Xs) * 1.5) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .children(hours), + ), + ) + } +} + +/// A market at a glance: its session, its indexes as cards in a row, and the day's movers up and down. +#[derive(IntoElement)] +pub struct MarketOverview { + id: ElementId, + status: Option, + indexes: Vec<(SharedString, SharedString, f64, f64, Vec)>, + movers: Vec<(SharedString, f64, f64)>, + red_up: bool, +} + +impl MarketOverview { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + status: None, + indexes: Vec::new(), + movers: Vec::new(), + red_up: false, + } + } + + pub fn status(mut self, status: MarketStatus) -> Self { + self.status = Some(status); + self + } + + /// An index: its symbol, name, last value, last close, and today's values. + pub fn index( + mut self, + (symbol, name): (impl Into, impl Into), + (last, close): (f64, f64), + day: impl IntoIterator, + ) -> Self { + self.indexes.push(( + symbol.into(), + name.into(), + last, + close, + day.into_iter().collect(), + )); + self + } + + /// A symbol that moved today: its last price and the last close. + pub fn mover(mut self, symbol: impl Into, last: f64, close: f64) -> Self { + self.movers.push((symbol.into(), last, close)); + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for MarketOverview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let red_up = self.red_up; + let cards = + self.indexes + .into_iter() + .enumerate() + .map(|(ix, (symbol, name, last, close, day))| { + let card = QuoteCard::new( + (self.id.clone(), format!("index-{ix}")), + (symbol, name), + (last, close), + day, + ); + div() + .flex_1() + .min_w_0() + .child(if red_up { card.red_up() } else { card }) + }); + let mut movers = self.movers; + movers.sort_by(|a, b| (b.1 / b.2).total_cmp(&(a.1 / a.2))); + let column = |title: &'static str, list: Vec<(SharedString, f64, f64)>| { + div() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(title), + ) + .children(list.into_iter().map(|(symbol, last, close)| { + let badge = if red_up { + PriceChangeBadge::new(close, last).red_up() + } else { + PriceChangeBadge::new(close, last) + }; + div() + .flex() + .items_center() + .justify_between() + .py_1() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(symbol), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + tabular(div()) + .text_color(colors.fg_muted) + .child(price(last, 2)), + ) + .child(badge), + ) + })) + }; + let half = movers.len().div_ceil(2); + let losers: Vec<_> = movers.split_off(half).into_iter().rev().collect(); + div() + .flex() + .flex_col() + .gap_4() + .children(self.status) + .child(div().flex().gap_4().children(cards)) + .child( + div() + .flex() + .gap_8() + .child(column("Up the most", movers)) + .child(column("Down the most", losers)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::bands; + + #[test] + fn sessions_split_at_midnight() { + assert_eq!(bands(8.0, 16.5), [(8.0 / 24.0, 16.5 / 24.0)]); + assert_eq!( + bands(22.0, 6.0), + [(22.0 / 24.0, 1.0), (0.0, 0.25)], + "a session past midnight takes two bands" + ); + } +} diff --git a/src/finance/payoff.rs b/src/finance/payoff.rs new file mode 100644 index 0000000..af37260 --- /dev/null +++ b/src/finance/payoff.rs @@ -0,0 +1,240 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, prelude::*, +}; + +use super::quotes::price; +use crate::{ + charts::{Geometry, Ink, Layout, Line, Linear, Pick, Plot, Scene, Tips, nice, nice_step, plot}, + theme::ActiveTheme, + typography::format, +}; + +/// A leg of an options position: a call or a put at a strike, how many (negative when sold), and the premium paid or taken for each. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Leg { + pub call: bool, + pub strike: f64, + pub quantity: f64, + pub premium: f64, +} + +/// What a position pays at expiry if the underlying ends at `at`. +pub(crate) fn payoff(legs: &[Leg], at: f64) -> f64 { + legs.iter() + .map(|leg| { + let worth = if leg.call { + (at - leg.strike).max(0.0) + } else { + (leg.strike - at).max(0.0) + }; + leg.quantity * (worth - leg.premium) + }) + .sum() +} + +/// Where a payoff sampled at `prices` crosses zero, read between samples. +pub(crate) fn breakevens(prices: &[f64], values: &[f64]) -> Vec { + prices + .windows(2) + .zip(values.windows(2)) + .filter(|(_, pair)| pair[0].signum() != pair[1].signum() && pair[0] != pair[1]) + .map(|(at, pair)| at[0] + (at[1] - at[0]) * pair[0] / (pair[0] - pair[1])) + .collect() +} + +/// What an options position pays at expiry across prices of the underlying: profit shaded above zero, loss below, the break-evens and the price marked. Hover reads the result at any price. +#[derive(IntoElement)] +pub struct PayoffDiagram { + base: Div, + id: ElementId, + legs: Vec, + range: (f64, f64), + spot: f64, + red_up: bool, +} + +impl PayoffDiagram { + /// Prices from `range.0` to `range.1`, widened to round prices, and the underlying's price now. + pub fn new( + id: impl Into, + legs: impl IntoIterator, + range: (f64, f64), + spot: f64, + ) -> Self { + assert!( + range.0 < range.1 && range.0.is_finite() && range.1.is_finite(), + "a range runs low to high" + ); + Self { + base: div(), + id: id.into(), + legs: legs.into_iter().collect(), + range, + spot, + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +/// Samples between two round prices on the axis. +const PER_STEP: usize = 20; + +impl Styled for PayoffDiagram { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for PayoffDiagram { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let step = nice_step(self.range.1 - self.range.0, 6); + let ((start, end), _) = nice(self.range.0, self.range.1, 6); + let samples = ((end - start) / step).round() as usize * PER_STEP + 1; + let prices: Vec = (0..samples) + .map(|ix| start + (end - start) * ix as f64 / (samples - 1) as f64) + .collect(); + let values: Rc> = + Rc::new(prices.iter().map(|at| payoff(&self.legs, *at)).collect()); + let labels: Vec = prices.iter().map(|at| price(*at, 2).into()).collect(); + let places = (-step.log10().floor()).max(0.0) as usize; + let words: Vec = prices + .iter() + .enumerate() + .map(|(ix, at)| { + if ix % PER_STEP == 0 { + price(*at, places).into() + } else { + SharedString::default() + } + }) + .collect(); + let (gain, loss) = if self.red_up { + (Ink::Fall, Ink::Rise) + } else { + (Ink::Rise, Ink::Fall) + }; + let drawn = values.clone(); + let layout: Layout = Rc::new(move |scene: &Scene| { + let frame = scene.frame; + let (low, high) = drawn.iter().fold((0.0f64, 0.0f64), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let ((low, high), ticks) = nice(low, high, 5); + let ys = Linear::new((low, high), (frame.y + frame.h, frame.y)); + let x = |ix: usize| frame.x + frame.w * ix as f32 / (drawn.len() - 1) as f32; + let zero = ys.at(0.0); + let side = |pick: fn(f64) -> f64| { + let mut outline: Vec<(f32, f32)> = drawn + .iter() + .enumerate() + .map(|(ix, value)| (x(ix), ys.at(pick(*value)))) + .collect(); + outline.extend([(x(drawn.len() - 1), zero), (x(0), zero)]); + outline + }; + Geometry { + ticks: ticks.iter().map(|tick| (ys.at(*tick), *tick)).collect(), + labels: words + .iter() + .enumerate() + .map(|(ix, word)| (x(ix), word.clone())) + .collect(), + lines: vec![Line { + ink: Ink::Strong, + points: drawn + .iter() + .enumerate() + .map(|(ix, value)| (x(ix), ys.at(*value))) + .collect(), + floor: None, + }], + shapes: vec![ + (gain, side(|value| value.max(0.0))), + (loss, side(|value| value.min(0.0))), + ], + ..Geometry::default() + } + }); + let (named, read) = (labels.clone(), values.clone()); + let tips: Tips = Rc::new(move |ix, _| { + let value = read[ix]; + let sign = if value > 0.0 { "+" } else { "" }; + let ink = if value >= 0.0 { gain } else { loss }; + ( + named[ix].clone(), + vec![( + Some(ink), + "At expiry".into(), + format!("{sign}{}", format::currency(value, "USD")), + )], + ) + }); + let mut chart = Plot::new(self.id.clone(), Pick::Point, layout, tips); + let evens = breakevens(&prices, &values); + let nearest = |at: f64| { + (((at - start) / (end - start)).clamp(0.0, 1.0) * (samples - 1) as f64).round() as usize + }; + chart.notes = evens + .iter() + .map(|at| { + ( + nearest(*at), + SharedString::from(format!("Break-even {}", price(*at, 2))), + ) + }) + .collect(); + if (start..=end).contains(&self.spot) { + chart.notes.push((nearest(self.spot), "Now".into())); + } + chart.labels = labels; + chart.format = Rc::new(|value| format::currency(value, "USD")); + let mut base = div().w_full().h(cx.theme().chart().height); + base.style().refine(self.base.style()); + plot(chart, base, window, cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_spread_pays_between_its_strikes_and_breaks_even_where_it_crosses() { + let spread = [ + Leg { + call: true, + strike: 100.0, + quantity: 1.0, + premium: 5.0, + }, + Leg { + call: true, + strike: 110.0, + quantity: -1.0, + premium: 2.0, + }, + ]; + assert_eq!( + payoff(&spread, 90.0), + -3.0, + "both expire worthless: the net premium is lost" + ); + assert_eq!( + payoff(&spread, 120.0), + 7.0, + "the most it pays: the width less the premium" + ); + let prices: Vec = (90..=120).map(f64::from).collect(); + let values: Vec = prices.iter().map(|at| payoff(&spread, *at)).collect(); + let evens = breakevens(&prices, &values); + assert_eq!(evens, [103.0]); + } +} diff --git a/src/finance/portfolio.rs b/src/finance/portfolio.rs new file mode 100644 index 0000000..20a469b --- /dev/null +++ b/src/finance/portfolio.rs @@ -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, + 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:?}" + ); + } +} diff --git a/src/finance/quotes.rs b/src/finance/quotes.rs new file mode 100644 index 0000000..c01f3af --- /dev/null +++ b/src/finance/quotes.rs @@ -0,0 +1,405 @@ +use gpui::{ + App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, relative, +}; + +use crate::{ + data_display::{Badge, Sparkline, Tone}, + motion::{Flash, Marquee}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// The rise and fall colors, green up unless red leads. +pub(crate) fn moves(red_up: bool, cx: &App) -> (Hsla, Hsla) { + let colors = &cx.theme().colors; + if red_up { + (colors.danger, colors.success) + } else { + (colors.success, colors.danger) + } +} + +/// A price as it reads: grouped thousands and its places. +pub(crate) fn price(value: f64, places: usize) -> String { + format::number(value, places, format::Separators::EN) +} + +/// A change and its share as a signed pair, such as "+1.24 (+0.66%)". +pub(crate) fn change(amount: f64, share: f64, places: usize) -> String { + let sign = if amount > 0.0 { "+" } else { "" }; + format!( + "{sign}{} ({})", + price(amount, places), + format::percent(share, 2, true) + ) +} + +/// A price read to its places that tints green or red for a moment each time it moves, with an arrow the way it last went. +#[derive(IntoElement)] +pub struct PriceText { + id: ElementId, + value: f64, + before: Option, + places: usize, + size: TextSize, + red_up: bool, +} + +impl PriceText { + pub fn new(id: impl Into, value: f64) -> Self { + assert!(value.is_finite(), "a price needs a finite value"); + Self { + id: id.into(), + value, + before: None, + places: 2, + size: TextSize::Base, + red_up: false, + } + } + + /// The price it moved from, which sets its arrow and tint. + pub fn from(mut self, before: f64) -> Self { + self.before = Some(before); + self + } + + pub fn places(mut self, places: usize) -> Self { + self.places = places; + self + } + + pub fn size(mut self, size: TextSize) -> Self { + self.size = size; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for PriceText { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let way = self.before.map(|before| self.value.total_cmp(&before)); + let ink = match way { + Some(std::cmp::Ordering::Greater) => rise, + Some(std::cmp::Ordering::Less) => fall, + _ => theme.colors.fg, + }; + let arrow = way.filter(|way| way.is_ne()).map(|way| { + let icon = if way.is_gt() { + IconName::ArrowUp + } else { + IconName::ArrowDown + }; + Icon::new(icon).size(IconSize::Xs).color(ink) + }); + Flash::new(self.id, self.value.to_bits()) + .tint(ink.opacity(0.18)) + .flex() + .items_center() + .gap_1() + .px_1() + .rounded(theme.radius(Radius::Sm)) + .children(arrow) + .child( + tabular(div()) + .text_size(theme.text_size(self.size)) + .text_color(ink) + .child(price(self.value, self.places)), + ) + } +} + +/// A move since a reference, its amount and share, tinted by which way it went. +#[derive(IntoElement)] +pub struct PriceChangeBadge { + amount: f64, + share: f64, + places: usize, + red_up: bool, +} + +impl PriceChangeBadge { + /// The move from `before` to `now`. + pub fn new(before: f64, now: f64) -> Self { + assert!( + before.is_finite() && now.is_finite() && before != 0.0, + "a change needs finite prices and a start other than zero" + ); + Self { + amount: now - before, + share: (now - before) / before, + places: 2, + red_up: false, + } + } + + pub fn places(mut self, places: usize) -> Self { + self.places = places; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for PriceChangeBadge { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let tone = match (self.amount.total_cmp(&0.0), self.red_up) { + (std::cmp::Ordering::Equal, _) => Tone::Neutral, + (std::cmp::Ordering::Greater, false) | (std::cmp::Ordering::Less, true) => { + Tone::Success + } + _ => Tone::Danger, + }; + Badge::new(change(self.amount, self.share, self.places)).tone(tone) + } +} + +/// A symbol at a glance: its name, price and move since the last close, the day's line, and where the price sits in the day's range. +#[derive(IntoElement)] +pub struct QuoteCard { + id: ElementId, + symbol: SharedString, + name: SharedString, + now: f64, + close: f64, + day: Vec, + red_up: bool, +} + +impl QuoteCard { + /// `close` is the last session's; `day` the prices since, oldest first. + pub fn new( + id: impl Into, + (symbol, name): (impl Into, impl Into), + (now, close): (f64, f64), + day: impl IntoIterator, + ) -> Self { + let day: Vec = day.into_iter().collect(); + assert!(day.len() >= 2, "a day's line needs two prices"); + Self { + id: id.into(), + symbol: symbol.into(), + name: name.into(), + now, + close, + day, + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for QuoteCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let colors = &theme.colors; + let (low, high) = self + .day + .iter() + .fold((f32::MAX, f32::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let at = if high > low { + (self.now as f32 - low) / (high - low) + } else { + 0.5 + }; + let ink = if self.now >= self.close { rise } else { fall }; + let badge = if self.red_up { + PriceChangeBadge::new(self.close, self.now).red_up() + } else { + PriceChangeBadge::new(self.close, self.now) + }; + let quote = if self.red_up { + PriceText::new(self.id.clone(), self.now).red_up() + } else { + PriceText::new(self.id.clone(), self.now) + }; + let small = |words: String| { + tabular(div()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(words) + }; + let track = div() + .relative() + .flex_1() + .h(theme.meter_track()) + .rounded_full() + .bg(colors.border) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(relative(at.clamp(0.0, 1.0))) + .w(theme.meter_track()) + .rounded_full() + .bg(colors.fg), + ); + div() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .flex() + .items_start() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.symbol), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(self.name), + ), + ) + .child(badge), + ) + .child(quote.size(TextSize::Xl).from(self.close)) + .child( + Sparkline::new(self.day) + .area() + .color(ink) + .w_full() + .h(theme.chart().height * 0.2), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(small(price(f64::from(low), 2))) + .child(track) + .child(small(price(f64::from(high), 2))), + ) + } +} + +/// Quotes running past in a band, each symbol with its price and move. +#[derive(IntoElement)] +pub struct TickerTape { + id: ElementId, + quotes: Vec<(SharedString, f64, f64)>, + red_up: bool, +} + +impl TickerTape { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + quotes: Vec::new(), + red_up: false, + } + } + + /// A symbol, its price, and its move as a share since the last close. + pub fn quote(mut self, symbol: impl Into, now: f64, share: f64) -> Self { + self.quotes.push((symbol.into(), now, share)); + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for TickerTape { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let (colors, size) = (theme.colors.clone(), theme.text_size(TextSize::Sm)); + let quotes = self.quotes; + let tape = Marquee::new(self.id, move || { + div() + .flex() + .items_center() + .children(quotes.iter().map(|(symbol, now, share)| { + let ink = if *share >= 0.0 { rise } else { fall }; + let icon = if *share >= 0.0 { + IconName::TrendingUp + } else { + IconName::TrendingDown + }; + div() + .flex() + .items_center() + .gap_2() + .px_4() + .text_size(size) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(symbol.clone()), + ) + .child( + tabular(div()) + .text_color(colors.fg_muted) + .child(price(*now, 2)), + ) + .child(Icon::new(icon).size(IconSize::Xs).color(ink)) + .child( + tabular(div()) + .text_color(ink) + .child(format::percent(*share, 2, true)), + ) + })) + }); + div() + .py_2() + .border_t_1() + .border_b_1() + .border_color(theme.colors.border) + .child(tape) + } +} + +#[cfg(test)] +mod tests { + use super::{change, price}; + + #[test] + fn prices_and_changes_read_signed() { + assert_eq!(price(1234.5, 2), "1,234.50"); + assert_eq!(change(1.24, 0.0066, 2), "+1.24 (+0.66%)"); + assert_eq!( + change(-0.5, -0.01, 2), + "\u{2212}0.50 (\u{2212}1.00%)", + "a true minus" + ); + } +} diff --git a/src/finance/renko.rs b/src/finance/renko.rs new file mode 100644 index 0000000..a4a2c69 --- /dev/null +++ b/src/finance/renko.rs @@ -0,0 +1,379 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, + RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, +}; + +use jiff::tz::TimeZone; + +use super::{ + candles::{Candle, point_and_figure, renko}, + quotes::moves, + stage::{decimals, fit}, +}; +use crate::{ + charts::{ + ChartTooltip, Linear, Rect, Tiles, anchored, at, finish, measure, place, ring, tracked, + }, + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// A mark on a price-only chart: its slot, the prices it spans, whether it rose, and its words. +struct Mark { + slot: usize, + span: (f64, f64), + rose: bool, + title: String, + reading: String, +} + +/// What both price-only charts share: a slot per mark along the bottom, prices at the right, and a tooltip for the mark under the pointer. +fn board( + id: ElementId, + marks: Vec, + boxes: Option, + (base, red_up): (&mut Div, bool), + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { + let tiles: Entity = + window.use_keyed_state((id.clone(), "board"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (inset, gutter, foot) = ( + pixels(sizes.inset), + pixels(sizes.gutter) * 1.25, + pixels(sizes.foot), + ); + let frame = Rect { + x: inset, + y: inset, + w: (f32::from(bounds.size.width) - inset - gutter).max(0.0), + h: (f32::from(bounds.size.height) - inset - foot).max(0.0), + }; + let (low, high) = marks + .iter() + .fold((f64::MAX, f64::MIN), |(low, high), mark| { + ( + low.min(mark.span.0.min(mark.span.1)), + high.max(mark.span.0.max(mark.span.1)), + ) + }); + let ((low, high), ticks) = if marks.is_empty() { + fit(0.0, 1.0) + } else { + fit(low, high) + }; + let ys = Linear::new((low, high), (frame.y + frame.h, frame.y)); + let slots = marks.iter().map(|mark| mark.slot + 1).max().unwrap_or(1); + let wide = frame.w / slots as f32; + let (rise, fall) = moves(red_up, cx); + let digits = decimals( + ticks + .windows(2) + .next() + .map_or(1.0, |pair| pair[1] - pair[0]), + ) + .max(2); + let edge = frame.x + frame.w; + let text = |words: String| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(words) + }; + let mut words: Vec
= ticks + .iter() + .map(|tick| { + div() + .absolute() + .left(Pixels::from(edge)) + .top(Pixels::from(ys.at(*tick))) + .h_0() + .flex() + .items_center() + .pl_2() + .child(tabular(text(format::number( + *tick, + digits, + format::Separators::EN, + )))) + }) + .collect(); + let place_of = |mark: &Mark| { + let (top, bottom) = ( + ys.at(mark.span.0.max(mark.span.1)), + ys.at(mark.span.0.min(mark.span.1)), + ); + Rect { + x: frame.x + wide * mark.slot as f32, + y: top, + w: wide, + h: bottom - top, + } + }; + if let Some(mark) = hover.and_then(|ix| marks.get(ix)) { + let rect = place_of(mark); + let card = ChartTooltip::new(mark.title.clone()).row( + Some(if mark.rose { rise } else { fall }), + "Prices", + mark.reading.clone(), + ); + words.push(anchored( + (rect.x + rect.w, rect.y + rect.h / 2.0), + rect.x > f32::from(bounds.size.width) * 0.6, + card, + )); + } + let rects: Vec<(Rect, bool)> = marks + .iter() + .map(|mark| (place_of(mark), mark.rose)) + .collect(); + let hit = rects.clone(); + let (stroke, hairline, palette, corner) = ( + sizes.stroke.to_pixels(rem), + sizes.hairline, + colors.clone(), + theme.radius(crate::theme::Radius::Sm).to_pixels(rem), + ); + let box_px = boxes.map(|size| (ys.at(0.0) - ys.at(size)).abs()); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(base.style()); + tracked(root, (id, "marks").into(), &tiles, move |place| { + hit.iter().position(|(rect, _)| rect.contains(place)) + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for tick in &ticks { + let line = Rect { + x: frame.x, + y: ys.at(*tick), + w: frame.w, + h: f32::from(hairline), + }; + window.paint_quad(fill(place(origin, line), palette.border.opacity(0.5))); + } + for (ix, (rect, rose)) in rects.iter().enumerate() { + let ink = if *rose { rise } else { fall }; + let ink = if hover.is_some_and(|on| on != ix) { + ink.opacity(0.5) + } else { + ink + }; + let gap = wide * 0.12; + let Some(side) = box_px else { + let brick = Rect { + x: rect.x + gap, + y: rect.y, + w: rect.w - gap * 2.0, + h: rect.h, + }; + window.paint_quad( + fill(place(origin, brick), ink.opacity(0.85)) + .corner_radii(Pixels::from(f32::from(corner).min(brick.w / 4.0))), + ); + continue; + }; + let count = (rect.h / side).round().max(1.0) as usize; + let half = (side.min(wide) / 2.0 - gap).max(f32::from(hairline)); + for step in 0..count { + let center = (rect.x + wide / 2.0, rect.y + side * (step as f32 + 0.5)); + if *rose { + let mut cross = PathBuilder::stroke(stroke * 0.75); + cross.move_to(at(origin, (center.0 - half, center.1 - half))); + cross.line_to(at(origin, (center.0 + half, center.1 + half))); + cross.move_to(at(origin, (center.0 + half, center.1 - half))); + cross.line_to(at(origin, (center.0 - half, center.1 + half))); + finish(cross, ink, window); + } else { + ring( + at(origin, center), + Pixels::from(half), + (palette.bg.opacity(0.0), ink), + stroke * 0.75, + window, + ); + } + } + } + }, + ) + .absolute() + .inset_0(), + ) + .children(words) + .child(measure(tiles, |tiles| &mut tiles.bounds)) +} + +/// Prices as bricks of one size, laid only when the close moves a whole brick past the last, so time drops out and trends stand clear. Hover reads a brick. +#[derive(IntoElement)] +pub struct RenkoChart { + base: Div, + id: ElementId, + candles: Rc>, + size: f64, + zone: TimeZone, + red_up: bool, +} + +impl RenkoChart { + pub fn new(id: impl Into, candles: impl Into>>, size: f64) -> Self { + assert!( + size.is_finite() && size > 0.0, + "a brick needs a positive size" + ); + Self { + base: div(), + id: id.into(), + candles: candles.into(), + size, + zone: TimeZone::system(), + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } + + /// The time zone its dates read in; the system's unless set. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } +} + +impl Styled for RenkoChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for RenkoChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let marks = renko(&self.candles, self.size) + .into_iter() + .enumerate() + .map(|(slot, brick)| { + let day = self.candles[brick.at] + .time + .to_zoned(self.zone.clone()) + .strftime("%b %-d, %Y") + .to_string(); + let reading = format!( + "{} → {}", + format::number(brick.from, 2, format::Separators::EN), + format::number(brick.to, 2, format::Separators::EN) + ); + Mark { + slot, + span: (brick.from, brick.to), + rose: brick.to > brick.from, + title: day, + reading, + } + }) + .collect(); + board( + self.id.clone(), + marks, + None, + (&mut self.base, self.red_up), + window, + cx, + ) + } +} + +/// Prices as columns of Xs where they rose and Os where they fell, a new column each time they turn by the reversal. Hover reads a column. +#[derive(IntoElement)] +pub struct PointFigureChart { + base: Div, + id: ElementId, + candles: Rc>, + size: f64, + reversal: i64, + red_up: bool, +} + +impl PointFigureChart { + /// Boxes of `size`, and a turn after `reversal` boxes the other way. + pub fn new( + id: impl Into, + candles: impl Into>>, + size: f64, + reversal: i64, + ) -> Self { + assert!( + size.is_finite() && size > 0.0 && reversal > 0, + "boxes need a positive size and reversal" + ); + Self { + base: div(), + id: id.into(), + candles: candles.into(), + size, + reversal, + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl Styled for PointFigureChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for PointFigureChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let size = self.size; + let marks = point_and_figure(&self.candles, size, self.reversal) + .into_iter() + .enumerate() + .map(|(slot, column)| { + let (low, high) = ( + column.from.min(column.to) as f64 * size, + (column.from.max(column.to) + 1) as f64 * size, + ); + let boxes = column.from.abs_diff(column.to) + 1; + let title = format!("{} {}", boxes, if column.rising { "X" } else { "O" }); + let reading = format!( + "{} – {}", + format::number(low, 2, format::Separators::EN), + format::number(high, 2, format::Separators::EN) + ); + Mark { + slot, + span: (low, high), + rose: column.rising, + title, + reading, + } + }) + .collect(); + board( + self.id.clone(), + marks, + Some(size), + (&mut self.base, self.red_up), + window, + cx, + ) + } +} diff --git a/src/finance/risk.rs b/src/finance/risk.rs new file mode 100644 index 0000000..ca4f81a --- /dev/null +++ b/src/finance/risk.rs @@ -0,0 +1,198 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, Styled, Window, div, + relative, +}; + +use super::quotes::price; +use crate::{ + charts::nice_step, + data_display::{Gauge, Meter}, + forms::Slider, + theme::{ActiveTheme, TextSize}, + typography::tabular, +}; + +/// Where leverage up to `max` is marked: one times, round steps, and the most, none crowding it. +fn marks(max: f64) -> Vec { + let step = nice_step(max, 5).max(1.0); + let between = (1..) + .map(|ix| step * f64::from(ix)) + .take_while(|mark| *mark <= max - step / 2.0) + .filter(|mark| *mark > 1.0); + std::iter::once(1.0) + .chain(between) + .chain(std::iter::once(max)) + .collect() +} + +type OnValue = Rc; + +/// Leverage from one times up to `max`, marked at the common steps, its number turning amber, then red, as the risk climbs. +#[derive(IntoElement)] +pub struct LeverageSlider { + id: ElementId, + value: f64, + max: f64, + on_change: Option, +} + +impl LeverageSlider { + pub fn new(id: impl Into, value: f64, max: f64) -> Self { + assert!( + max > 1.0 && max.fract() == 0.0 && (1.0..=max).contains(&value), + "leverage runs in whole steps from one up to its most" + ); + Self { + id: id.into(), + value, + max, + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LeverageSlider { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let share = (self.value - 1.0) / (self.max - 1.0); + let ink = if share >= 0.5 { + colors.danger + } else if share >= 0.2 { + colors.warning + } else { + colors.fg + }; + let slider = Slider::new(self.id.clone(), self.value) + .range(1.0, self.max) + .step(1.0); + let slider = match self.on_change { + Some(on_change) => { + slider.on_change(move |value, window, cx| on_change(value, window, cx)) + } + None => slider, + }; + let marks = marks(self.max).into_iter().map(|step| { + let at = ((step - 1.0) / (self.max - 1.0)) as f32; + div() + .absolute() + .top_0() + .left(relative(at)) + .w_0() + .flex() + .justify_center() + .child(tabular(div()).child(format!("{step:.0}×"))) + }); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .flex() + .justify_between() + .text_size(theme.text_size(TextSize::Sm)) + .child(div().text_color(colors.fg_muted).child("Leverage")) + .child( + tabular(div()) + .font_weight(FontWeight::SEMIBOLD) + .text_color(ink) + .child(format!("{:.0}×", self.value)), + ), + ) + .child(slider) + .child( + div() + .relative() + .h(theme.text_size(TextSize::Xs) * 1.5) + .mx(theme.slider_thumb() * 0.5) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .children(marks), + ) + } +} + +/// Margin in use against what the account holds, as a bar that turns amber, then red, and what is left. +#[derive(IntoElement)] +pub struct MarginIndicator { + id: ElementId, + used: f64, + equity: f64, +} + +impl MarginIndicator { + pub fn new(id: impl Into, used: f64, equity: f64) -> Self { + assert!( + equity > 0.0 && used >= 0.0, + "margin needs equity and a use of zero or more" + ); + Self { + id: id.into(), + used, + equity, + } + } +} + +impl RenderOnce for MarginIndicator { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let share = (self.used / self.equity) as f32; + let detail = format!( + "${} of ${} · ${} free", + price(self.used, 0), + price(self.equity, 0), + price((self.equity - self.used).max(0.0), 0) + ); + Meter::new(self.id, "Margin used", share.min(1.0)) + .detail(detail) + .thresholds(0.6, 0.85) + } +} + +/// How much risk a position carries, from none to all, on a dial that turns amber, then red. +#[derive(IntoElement)] +pub struct RiskMeter { + id: ElementId, + risk: f64, +} + +impl RiskMeter { + /// `risk` from 0 to 100. + pub fn new(id: impl Into, risk: f64) -> Self { + assert!((0.0..=100.0).contains(&risk), "risk reads from 0 to 100"); + Self { + id: id.into(), + risk, + } + } +} + +impl RenderOnce for RiskMeter { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + Gauge::new(self.id, "Risk", self.risk, 0.0, 100.0) + .decimals(0) + .thresholds(0.5, 0.8) + } +} + +#[cfg(test)] +mod tests { + use super::marks; + + #[test] + fn leverage_marks_step_evenly_to_the_most() { + assert_eq!(marks(100.0), [1.0, 20.0, 40.0, 60.0, 80.0, 100.0]); + assert_eq!(marks(125.0), [1.0, 50.0, 100.0, 125.0]); + assert_eq!(marks(20.0), [1.0, 5.0, 10.0, 15.0, 20.0]); + assert_eq!(marks(3.0), [1.0, 2.0, 3.0]); + assert_eq!(marks(2.0), [1.0, 2.0]); + } +} diff --git a/src/finance/series.rs b/src/finance/series.rs new file mode 100644 index 0000000..c5f065c --- /dev/null +++ b/src/finance/series.rs @@ -0,0 +1,187 @@ +use gpui::SharedString; + +use super::{ + candles::Candle, + indicators::{Reading, bollinger, ema, kdj, macd, obv, rsi, sma, vwap}, +}; + +/// A line drawn over the prices. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Overlay { + /// A simple moving average of this many closes. + Sma(usize), + /// An exponential moving average of this many closes. + Ema(usize), + /// Bollinger bands over this many closes, this many deviations wide. + Bollinger(usize, f64), + /// The volume-weighted average price. + Vwap, +} + +/// A pane of its own under the prices. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Study { + Macd, + Rsi, + Kdj, + Obv, +} + +/// What an overlay or a study draws: its name, its lines in palette colors, a band to shade, bars about zero, guide levels, and a fixed range when it has one. +#[derive(Clone, Debug, Default)] +pub(crate) struct Drawn { + pub name: SharedString, + pub lines: Vec<(usize, Reading)>, + pub band: Option<(Reading, Reading)>, + pub bars: Option, + pub guides: Vec, + pub range: Option<(f64, f64)>, +} + +fn closes(candles: &[Candle]) -> Vec { + candles.iter().map(|candle| candle.close).collect() +} + +/// What an overlay draws for these candles; `color` is its place in the palette. +pub(crate) fn overlay(overlay: Overlay, candles: &[Candle], color: usize) -> Drawn { + let closes = closes(candles); + match overlay { + Overlay::Sma(period) => Drawn { + name: format!("SMA {period}").into(), + lines: vec![(color, sma(&closes, period))], + ..Drawn::default() + }, + Overlay::Ema(period) => Drawn { + name: format!("EMA {period}").into(), + lines: vec![(color, ema(&closes, period))], + ..Drawn::default() + }, + Overlay::Vwap => Drawn { + name: "VWAP".into(), + lines: vec![(color, vwap(candles))], + ..Drawn::default() + }, + Overlay::Bollinger(period, width) => { + let bands = bollinger(&closes, period, width); + let part = |pick: fn((f64, f64, f64)) -> f64| { + bands.iter().map(|band| band.map(pick)).collect::() + }; + Drawn { + name: format!("BOLL {period} {width}").into(), + lines: vec![(color, part(|band| band.1))], + band: Some((part(|band| band.0), part(|band| band.2))), + ..Drawn::default() + } + } + } +} + +/// What a study draws for these candles, in the palette from `color` on. +pub(crate) fn study(study: Study, candles: &[Candle], color: usize) -> Drawn { + let closes = closes(candles); + match study { + Study::Macd => { + let trend = macd(&closes, (12, 26, 9)); + Drawn { + name: "MACD 12 26 9".into(), + lines: vec![(color, trend.line), (color + 1, trend.signal)], + bars: Some(trend.histogram), + guides: vec![0.0], + ..Drawn::default() + } + } + Study::Rsi => Drawn { + name: "RSI 14".into(), + lines: vec![(color, rsi(&closes, 14))], + guides: vec![30.0, 70.0], + range: Some((0.0, 100.0)), + ..Drawn::default() + }, + Study::Kdj => { + let (k, d, j) = kdj(candles, 9); + Drawn { + name: "KDJ 9".into(), + lines: vec![(color, k), (color + 1, d), (color + 2, j)], + guides: vec![20.0, 80.0], + ..Drawn::default() + } + } + Study::Obv => Drawn { + name: "OBV".into(), + lines: vec![(color, obv(candles).into_iter().map(Some).collect())], + ..Drawn::default() + }, + } +} + +impl Drawn { + /// The lowest and highest it reaches over `range` of candles, bars and bands included; its fixed range when it has one. + pub(crate) fn reach(&self, range: std::ops::Range) -> Option<(f64, f64)> { + if self.range.is_some() { + return self.range; + } + let band = self.band.iter().flat_map(|(low, high)| [low, high]); + let readings = self + .lines + .iter() + .map(|(_, reading)| reading) + .chain(self.bars.iter()) + .chain(band); + let values = readings.flat_map(|reading| reading[range.clone()].iter().flatten().copied()); + let guides = self.guides.iter().copied().filter(|_| self.bars.is_some()); + values.chain(guides).fold(None, |reach, value| { + let (low, high) = reach.unwrap_or((value, value)); + Some((low.min(value), high.max(value))) + }) + } +} + +#[cfg(test)] +mod tests { + use jiff::Timestamp; + + use super::*; + + fn rising(count: usize) -> Vec { + (0..count) + .map(|ix| { + let close = 100.0 + ix as f64; + Candle::new( + Timestamp::UNIX_EPOCH, + (close - 0.5, close + 1.0, close - 1.0, close), + 10.0, + ) + }) + .collect() + } + + #[test] + fn overlays_name_themselves_and_reach_over_what_shows() { + let candles = rising(30); + let average = overlay(Overlay::Sma(5), &candles, 2); + assert_eq!(average.name.as_ref(), "SMA 5"); + assert_eq!(average.reach(0..4), None, "nothing to average yet"); + assert_eq!(average.reach(4..6), Some((102.0, 103.0))); + let bands = overlay(Overlay::Bollinger(20, 2.0), &candles, 0); + let (low, high) = bands.reach(19..30).expect("bands"); + assert!( + low < 110.0 && high > 128.0, + "the bands spread past the average" + ); + } + + #[test] + fn studies_keep_their_ranges_and_guides() { + let candles = rising(40); + let strength = study(Study::Rsi, &candles, 0); + assert_eq!( + strength.reach(0..40), + Some((0.0, 100.0)), + "RSI always reads out of a hundred" + ); + let trend = study(Study::Macd, &candles, 0); + assert_eq!(trend.lines.len(), 2); + let (low, _) = trend.reach(30..40).expect("macd"); + assert!(low <= 0.0, "the zero guide stays in view with the bars"); + } +} diff --git a/src/finance/spread.rs b/src/finance/spread.rs new file mode 100644 index 0000000..de20801 --- /dev/null +++ b/src/finance/spread.rs @@ -0,0 +1,147 @@ +use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div, relative}; + +use super::quotes::{moves, price}; +use crate::{ + theme::{ActiveTheme, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// The best bid and ask, and the spread between them in price and in basis points. +#[derive(IntoElement)] +pub struct SpreadIndicator { + bid: f64, + ask: f64, + places: usize, + red_up: bool, +} + +impl SpreadIndicator { + pub fn new(bid: f64, ask: f64) -> Self { + assert!( + bid.is_finite() && ask.is_finite() && bid < ask, + "a spread needs a bid below its ask" + ); + Self { + bid, + ask, + places: 2, + red_up: false, + } + } + + pub fn places(mut self, places: usize) -> Self { + self.places = places; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for SpreadIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let colors = &theme.colors; + let side = |name: &'static str, value: f64, ink| { + div() + .flex() + .items_baseline() + .gap_1p5() + .child(div().text_color(colors.fg_subtle).child(name)) + .child( + tabular(div()) + .text_color(ink) + .child(price(value, self.places)), + ) + }; + let spread = self.ask - self.bid; + let points = spread / ((self.ask + self.bid) / 2.0) * 10_000.0; + div() + .flex() + .items_center() + .gap_4() + .text_size(theme.text_size(TextSize::Sm)) + .child(side("Bid", self.bid, rise)) + .child( + tabular(div()) + .px_2() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.hover) + .text_color(colors.fg_muted) + .child(format!("{} · {points:.1} bps", price(spread, self.places))), + ) + .child(side("Ask", self.ask, fall)) + } +} + +/// The weight of waiting orders on each side as one bar split where they balance. +#[derive(IntoElement)] +pub struct BidAskBar { + bids: f64, + asks: f64, + red_up: bool, +} + +impl BidAskBar { + /// The size waiting on each side. + pub fn new(bids: f64, asks: f64) -> Self { + assert!( + bids >= 0.0 && asks >= 0.0 && bids + asks > 0.0, + "a balance needs some size" + ); + Self { + bids, + asks, + red_up: false, + } + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for BidAskBar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let share = self.bids / (self.bids + self.asks); + let words = |name: &'static str, value: f64| { + tabular(div()) + .text_color(theme.colors.fg_muted) + .child(format!("{name} {}", format::percent(value, 0, false))) + }; + let part = |grow: f64, ink| { + let mut part = div().h_full().flex_basis(relative(0.0)).bg(ink); + part.style().flex_grow = Some(grow as f32); + part + }; + div() + .flex() + .flex_col() + .gap_1() + .text_size(theme.text_size(TextSize::Xs)) + .child( + div() + .flex() + .justify_between() + .child(words("Bids", share)) + .child(words("Asks", 1.0 - share)), + ) + .child( + div() + .flex() + .gap_0p5() + .h(theme.meter_track()) + .rounded_full() + .overflow_hidden() + .child(part(share, rise).rounded_l_full()) + .child(part(1.0 - share, fall).rounded_r_full()), + ) + } +} diff --git a/src/finance/stage.rs b/src/finance/stage.rs new file mode 100644 index 0000000..6e81241 --- /dev/null +++ b/src/finance/stage.rs @@ -0,0 +1,178 @@ +use std::ops::Range; + +use crate::charts::nice; + +/// The fewest candles a chart zooms in to. +const CLOSEST: f64 = 10.0; + +/// The candles a chart shows: from a first index, which may sit between candles while panning, across `count` of them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Visible { + pub(crate) start: f64, + pub(crate) count: f64, +} + +impl Visible { + /// The newest `count` of `total` candles, a little room left past the last. + pub(crate) fn latest(total: usize, count: f64) -> Self { + let count = count.clamp( + CLOSEST.min(total as f64).max(1.0), + (total as f64).max(1.0) * 1.1, + ); + Self { + start: total as f64 - count * 0.95, + count, + } + .kept(total) + } + + /// Kept to what `total` candles allow: never before the first, at most a quarter window past the last. + fn kept(self, total: usize) -> Self { + let last = (total as f64 - self.count * 0.75).max(0.0); + Self { + start: self.start.clamp(0.0, last), + ..self + } + } + + /// Where candle `ix`'s middle sits across a frame starting at `left` and `width` wide. + pub(crate) fn x(&self, ix: f64, (left, width): (f32, f32)) -> f32 { + left + ((ix - self.start + 0.5) / self.count) as f32 * width + } + + /// The candle nearest `x`, which may lie outside the data. + pub(crate) fn at(&self, x: f32, (left, width): (f32, f32)) -> i64 { + (self.start + f64::from((x - left) / width) * self.count - 0.5).round() as i64 + } + + /// How wide one candle's slot is. + pub(crate) fn slot(&self, width: f32) -> f32 { + width / self.count as f32 + } + + /// The candles in view, whole ones and those cut by an edge. + pub(crate) fn range(&self, total: usize) -> Range { + let first = self.start.floor().max(0.0) as usize; + let last = ((self.start + self.count).ceil().max(0.0) as usize).min(total); + first.min(last)..last + } + + /// Closer in (`factor` under one) or farther out, holding still the candle at `share` of the way across. + pub(crate) fn zoom(&self, factor: f64, share: f64, total: usize) -> Self { + let count = (self.count * factor).clamp( + CLOSEST.min(total as f64).max(1.0), + (total as f64).max(1.0) * 1.1, + ); + let held = self.start + self.count * share; + Self { + start: held - count * share, + count, + } + .kept(total) + } + + /// Moved by `candles`: later when positive. + pub(crate) fn pan(&self, candles: f64, total: usize) -> Self { + Self { + start: self.start + candles, + ..*self + } + .kept(total) + } +} + +/// The prices a pane shows: its low and high a little widened, and round ticks inside. +pub(crate) fn fit(low: f64, high: f64) -> ((f64, f64), Vec) { + let room = ((high - low) * 0.08) + .max(high.abs() * 1e-4) + .max(f64::EPSILON); + let (low, high) = (low - room, high + room); + let (_, ticks) = nice(low, high, 5); + let inside = ticks + .into_iter() + .filter(|tick| *tick >= low && *tick <= high) + .collect(); + ((low, high), inside) +} + +/// Places after the point that ticks `step` apart need: none for whole steps, two for cents. +pub(crate) fn decimals(step: f64) -> usize { + if step >= 1.0 || step <= 0.0 { + return 0; + } + (-step.log10() - 1e-9).ceil().max(0.0) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_window_maps_candles_across_its_frame() { + let window = Visible { + start: 10.0, + count: 20.0, + }; + assert_eq!( + window.x(10.0, (0.0, 200.0)), + 5.0, + "the first candle's middle" + ); + assert_eq!(window.at(5.0, (0.0, 200.0)), 10); + assert_eq!(window.at(199.0, (0.0, 200.0)), 29); + assert_eq!(window.slot(200.0), 10.0); + assert_eq!(window.range(100), 10..30); + assert_eq!( + Visible { + start: 95.5, + count: 20.0 + } + .range(100), + 95..100, + "the edge of the data" + ); + } + + #[test] + fn zoom_holds_the_candle_under_the_pointer_and_pan_stops_at_the_ends() { + let window = Visible { + start: 0.0, + count: 40.0, + }; + let closer = window.zoom(0.5, 0.5, 100); + assert_eq!( + (closer.start, closer.count), + (10.0, 20.0), + "the middle candle stays in the middle" + ); + assert_eq!( + window.zoom(0.01, 0.5, 100).count, + 10.0, + "no closer than ten candles" + ); + assert_eq!(window.pan(-5.0, 100).start, 0.0, "not before the first"); + assert_eq!( + window.pan(500.0, 100).start, + 70.0, + "a quarter window past the last at most" + ); + let latest = Visible::latest(100, 40.0); + assert_eq!(latest.range(100).end, 100, "the newest candle shows"); + } + + #[test] + fn prices_fit_with_room_and_read_to_their_step() { + let ((low, high), ticks) = fit(100.0, 110.0); + assert!(low < 100.0 && high > 110.0); + assert!(ticks.iter().all(|tick| (low..=high).contains(tick)) && ticks.len() >= 3); + assert_eq!( + ( + decimals(10.0), + decimals(0.5), + decimals(0.05), + decimals(0.001) + ), + (0, 1, 2, 3) + ); + } +} diff --git a/src/finance/steer.rs b/src/finance/steer.rs new file mode 100644 index 0000000..8bef4b3 --- /dev/null +++ b/src/finance/steer.rs @@ -0,0 +1,179 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Div, Entity, InteractiveElement, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Point, ScrollWheelEvent, Stateful, StatefulInteractiveElement, Styled, + Subscription, Window, +}; + +use super::{ + market::ChartSync, + stage::Visible, + tools::{Drawing, Tool}, +}; +use crate::charts::{Linear, Rect}; + +/// A drawing in progress: its tool, and where the drag began and is now, each a candle and a price. +pub(crate) type Sketch = (Tool, (f64, f64), (f64, f64)); + +/// A market chart's own state: its box, what it shows and where the pointer is when no sync holds them, how many candles it last saw, and a drag in progress. +#[derive(Default)] +pub(crate) struct Stage { + pub bounds: Bounds, + pub visible: Option, + pub hover: Option, + pub pointer: Option<(f32, f32)>, + pub seen: usize, + pub drag: Option<(f32, Visible)>, + pub sketch: Option, + pub _synced: Option, +} + +pub(crate) type OnDraw = Rc; + +/// What the pointer moves on a market chart: where it keeps what shows, the candles in view out of `total`, the price pane and its scale, and the drawing tool. +pub(crate) struct Steering { + pub stage: Entity, + pub sync: Option>, + pub visible: Visible, + pub total: usize, + pub main: Rect, + pub price: Linear, + pub tool: Option, + pub on_draw: Option, +} + +/// Keeps what shows and the candle under the pointer in the sync when one is shared, or in the chart's own stage. +fn hold( + stage: &Entity, + sync: &Option>, + cx: &mut App, + visible: Option, + hover: Option, +) { + match sync { + Some(sync) => sync.update(cx, |sync, cx| { + (sync.visible, sync.hover) = (visible, hover); + cx.notify(); + }), + None => stage.update(cx, |stage, cx| { + (stage.visible, stage.hover) = (visible, hover); + cx.notify(); + }), + } +} + +/// A window position in the chart box's own pixels. +fn local(position: Point, bounds: Bounds) -> (f32, f32) { + let offset = position - bounds.origin; + (f32::from(offset.x), f32::from(offset.y)) +} + +impl Steering { + /// Drag or scroll sideways to pan, hold Cmd and scroll to zoom, double-press for the newest, and drag with a tool to draw. + pub(crate) fn attach(self, root: Stateful
) -> Stateful
{ + let Steering { + stage, + sync, + visible, + total, + main, + price, + tool, + on_draw, + } = self; + let (moved, pressed, released, wheeled, left) = ( + stage.clone(), + stage.clone(), + stage.clone(), + stage.clone(), + stage, + ); + let (sync_move, sync_press, sync_wheel, sync_leave) = + (sync.clone(), sync.clone(), sync.clone(), sync); + let across = (main.x, main.w); + let pinned = move |(x, y): (f32, f32)| { + ( + visible.start + f64::from((x - across.0) / across.1) * visible.count - 0.5, + price.value(y), + ) + }; + root.cursor_crosshair() + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let place = local(event.position, moved.read(cx).bounds); + let pressed_left = event.pressed_button == Some(MouseButton::Left); + let drag = moved.read(cx).drag.filter(|_| pressed_left); + moved.update(cx, |stage, _| { + stage.pointer = Some(place); + if let Some(sketch) = stage.sketch.as_mut().filter(|_| pressed_left) { + sketch.2 = pinned(place); + } + }); + let (visible, hover) = match drag { + Some((from, start)) => ( + start.pan(f64::from((from - place.0) / start.slot(across.1)), total), + None, + ), + None => ( + visible, + (place.0 >= across.0 && place.0 <= across.0 + across.1) + .then(|| visible.at(place.0, across)), + ), + }; + hold(&moved, &sync_move, cx, Some(visible), hover); + }) + .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { + let place = local(event.position, pressed.read(cx).bounds); + if event.click_count == 2 { + log::info!("market chart: back to the newest"); + hold(&pressed, &sync_press, cx, None, None); + return; + } + pressed.update(cx, |stage, cx| match tool { + Some(tool) if main.contains(place) => { + stage.sketch = Some((tool, pinned(place), pinned(place))); + cx.notify(); + } + Some(_) => {} + None => stage.drag = Some((place.0, visible)), + }); + }) + .on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, window, cx| { + let sketch = released.update(cx, |stage, cx| { + stage.drag = None; + cx.notify(); + stage.sketch.take() + }); + if let (Some((tool, from, to)), Some(on_draw)) = (sketch, &on_draw) { + let drawn = Drawing::of(tool, from, to); + log::info!("market chart: drew {drawn:?}"); + on_draw(drawn, window, cx); + } + }) + .on_scroll_wheel(move |event: &ScrollWheelEvent, _, cx| { + let delta = event + .delta + .pixel_delta(Pixels::from(across.1 / visible.count as f32)); + let (dx, dy) = (f32::from(delta.x), f32::from(delta.y)); + let place = local(event.position, wheeled.read(cx).bounds); + let next = if event.modifiers.platform && dy.abs() > dx.abs() { + let share = f64::from(((place.0 - across.0) / across.1).clamp(0.0, 1.0)); + visible.zoom((-f64::from(dy) * 0.004).exp(), share, total) + } else if dx.abs() > dy.abs() { + visible.pan(-f64::from(dx / visible.slot(across.1)), total) + } else { + return; + }; + cx.stop_propagation(); + hold(&wheeled, &sync_wheel, cx, Some(next), None); + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |stage, _| { + (stage.pointer, stage.drag, stage.sketch) = (None, None, None) + }); + hold(&left, &sync_leave, cx, Some(visible), None); + } + }) + } +} diff --git a/src/finance/symbols.rs b/src/finance/symbols.rs new file mode 100644 index 0000000..2811966 --- /dev/null +++ b/src/finance/symbols.rs @@ -0,0 +1,312 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use super::quotes::{PriceChangeBadge, moves, price}; +use crate::{ + data_display::{Avatar, Sparkline}, + forms::{Choice, Combobox, TextInput}, + lists::{ListItem, SelectableList}, + tables::{Column, DataTable, FilterBuilder, FilterRule, Row}, + theme::{ActiveTheme, AvatarSize, TextSize}, + typography::tabular, +}; + +/// A symbol as a small tile of its letters, toned steadily from its name, beside the symbol and what it trades on. +#[derive(IntoElement)] +pub struct SymbolBadge { + id: ElementId, + symbol: SharedString, + venue: Option, +} + +impl SymbolBadge { + pub fn new(id: impl Into, symbol: impl Into) -> Self { + Self { + id: id.into(), + symbol: symbol.into(), + venue: None, + } + } + + /// Where it trades, or its name, read under the symbol. + pub fn venue(mut self, venue: impl Into) -> Self { + self.venue = Some(venue.into()); + self + } +} + +impl RenderOnce for SymbolBadge { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .items_center() + .gap_2() + .child( + Avatar::new(self.id, self.symbol.clone()) + .square() + .size(AvatarSize::Sm), + ) + .child( + div() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.colors.fg) + .child(self.symbol), + ) + .children(self.venue.map(|venue| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(venue) + })), + ) + } +} + +/// A search for a symbol by its letters or its name, each result with where it trades. +#[derive(IntoElement)] +pub struct SymbolSearch { + id: ElementId, + field: Entity, + symbols: Vec<(SharedString, SharedString, SharedString)>, + on_pick: Option, +} + +impl SymbolSearch { + /// Symbols to find, each with its name and where it trades; `field` holds the query. + pub fn new( + id: impl Into, + field: &Entity, + symbols: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + field: field.clone(), + symbols: symbols.into_iter().collect(), + on_pick: None, + } + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SymbolSearch { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let choices = self.symbols.iter().map(|(symbol, name, venue)| { + Choice::new(symbol.clone(), format!("{symbol} · {name}")).note(venue.clone()) + }); + let search = Combobox::new(self.id, &self.field, choices); + match self.on_pick { + Some(on_pick) => search.on_change(move |symbol, window, cx| { + log::info!("symbol search: {symbol}"); + on_pick(symbol, window, cx) + }), + None => search, + } + } +} + +/// A symbol on a watchlist: its name, last price, the last session's close, and today's prices so far. +#[derive(Clone, Debug, PartialEq)] +pub struct Watch { + pub symbol: SharedString, + pub name: SharedString, + pub last: f64, + pub close: f64, + pub day: Vec, +} + +type OnSymbol = Rc; + +/// Symbols to keep an eye on, each with its tile, today's line, its price and move; a press picks one, and the keys walk the list. +#[derive(IntoElement)] +pub struct Watchlist { + id: ElementId, + watches: Vec, + selected: Option, + on_select: Option, + red_up: bool, +} + +impl Watchlist { + pub fn new(id: impl Into, watches: impl IntoIterator) -> Self { + Self { + id: id.into(), + watches: watches.into_iter().collect(), + selected: None, + on_select: None, + red_up: false, + } + } + + pub fn selected(mut self, symbol: impl Into) -> Self { + self.selected = Some(symbol.into()); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for Watchlist { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let spark = theme.spark_size(); + let list = self + .watches + .iter() + .fold(SelectableList::new(self.id.clone()), |list, watch| { + let ink = if watch.last >= watch.close { + rise + } else { + fall + }; + let change = if self.red_up { + PriceChangeBadge::new(watch.close, watch.last).red_up() + } else { + PriceChangeBadge::new(watch.close, watch.last) + }; + let figures = div() + .flex() + .items_center() + .gap_3() + .child( + Sparkline::new(watch.day.clone()) + .color(ink) + .w(spark.width) + .h(spark.height), + ) + .child( + tabular(div()) + .w(theme.label_width() * 0.45) + .text_right() + .text_color(theme.colors.fg) + .child(price(watch.last, 2)), + ) + .child( + div() + .w(theme.label_width() * 0.8) + .flex() + .justify_end() + .child(change), + ); + let item = ListItem::new( + (self.id.clone(), watch.symbol.clone()), + watch.symbol.clone(), + ) + .description(watch.name.clone()) + .leading( + Avatar::new( + (self.id.clone(), format!("tile-{}", watch.symbol)), + watch.symbol.clone(), + ) + .square() + .size(AvatarSize::Sm), + ) + .trailing(figures); + list.row(watch.symbol.clone(), item) + }); + let list = list.selected(self.selected.clone()); + match self.on_select { + Some(on_select) => list.on_change(move |keys, window, cx| { + if let Some(symbol) = keys.first() { + log::info!("watchlist: {symbol}"); + on_select(symbol, window, cx); + } + }), + None => list, + } + } +} + +type OnRules = Rc; + +/// Securities narrowed by rules: the rules to build above, and the table of what passes them below. +#[derive(IntoElement)] +pub struct Screener { + id: ElementId, + columns: Vec, + rows: Vec, + rules: Vec, + any: bool, + on_rules: Option, +} + +impl Screener { + pub fn new( + id: impl Into, + columns: impl IntoIterator, + rows: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + columns: columns.into_iter().collect(), + rows: rows.into_iter().collect(), + rules: Vec::new(), + any: false, + on_rules: None, + } + } + + /// The rules the owner keeps, and whether a row passes any rather than all. + pub fn rules(mut self, rules: impl IntoIterator, any: bool) -> Self { + self.rules = rules.into_iter().collect(); + self.any = any; + self + } + + pub fn on_rules( + mut self, + handler: impl Fn(&[FilterRule], bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rules = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Screener { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let keys = self + .columns + .iter() + .map(|column| (column.key().clone(), column.title().clone())); + let builder = FilterBuilder::new((self.id.clone(), "rules"), keys) + .rules(self.rules.clone(), self.any); + let builder = match self.on_rules { + Some(on_rules) => { + builder.on_change(move |rules, any, window, cx| on_rules(rules, any, window, cx)) + } + None => builder, + }; + let table = DataTable::new((self.id.clone(), "table"), self.columns) + .rows(self.rows) + .filters(self.rules, self.any); + div().flex().flex_col().gap_3().child(builder).child(table) + } +} diff --git a/src/finance/tape.rs b/src/finance/tape.rs new file mode 100644 index 0000000..47b8b72 --- /dev/null +++ b/src/finance/tape.rs @@ -0,0 +1,287 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; +use jiff::tz::TimeZone; + +use super::{ + book::{Side, Trade, cell, heading, level}, + quotes::{moves, price}, +}; +use crate::{ + charts::compact, + motion::Flash, + theme::{ActiveTheme, Density, TextSize}, + typography::tabular, +}; + +/// Trades as they print, newest on top: the time, price and size tinted by the side that took them; trades of at least `large` stand bold, and the newest flashes in. +#[derive(IntoElement)] +pub struct TimeAndSales { + id: ElementId, + trades: Vec, + large: f64, + rows: usize, + places: usize, + zone: TimeZone, + red_up: bool, +} + +impl TimeAndSales { + /// Trades oldest first. + pub fn new(id: impl Into, trades: impl IntoIterator) -> Self { + Self { + id: id.into(), + trades: trades.into_iter().collect(), + large: f64::MAX, + rows: 12, + places: 2, + zone: TimeZone::system(), + red_up: false, + } + } + + /// The size from which a trade reads bold. + pub fn large(mut self, size: f64) -> Self { + self.large = size; + self + } + + pub fn rows(mut self, rows: usize) -> Self { + self.rows = rows; + self + } + + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +impl RenderOnce for TimeAndSales { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let newest = self.trades.len(); + let rows = self + .trades + .iter() + .rev() + .take(self.rows) + .enumerate() + .map(|(ix, trade)| { + let ink = if trade.side == Side::Buy { rise } else { fall }; + let time = trade + .time + .to_zoned(self.zone.clone()) + .strftime("%H:%M:%S") + .to_string(); + let row = div() + .flex() + .items_center() + .gap_3() + .px_2() + .h(theme.table_row(Density::Compact)) + .text_size(theme.text_size(TextSize::Sm)) + .when(trade.size >= self.large, |row| { + row.font_weight(FontWeight::SEMIBOLD) + }) + .child( + tabular(div()) + .flex_1() + .text_right() + .text_color(colors.fg_muted) + .child(time), + ) + .child(cell(price(trade.price, self.places), ink)) + .child(cell(compact(trade.size), colors.fg)); + if ix == 0 { + Flash::new((self.id.clone(), "newest"), newest) + .child(row) + .into_any_element() + } else { + row.into_any_element() + } + }); + div() + .flex() + .flex_col() + .child(heading(&["Time", "Price", "Size"], cx)) + .children(rows) + } +} + +type OnTrade = Rc; + +/// The book as a ladder of prices around the last trade: size bid at each price on the left, asked on the right, the last price marked in the middle. A press on a size side trades at its price. +#[derive(IntoElement)] +pub struct DomLadder { + id: ElementId, + bids: Vec<(f64, f64)>, + asks: Vec<(f64, f64)>, + last: f64, + tick: f64, + rows: usize, + places: usize, + on_trade: Option, + red_up: bool, +} + +impl DomLadder { + /// Bids and asks, each a price and the size waiting there; the last trade's price, and the price step between rows. + pub fn new( + id: impl Into, + bids: impl IntoIterator, + asks: impl IntoIterator, + last: f64, + tick: f64, + ) -> Self { + assert!( + tick > 0.0 && last.is_finite(), + "a ladder needs a step and a last price" + ); + Self { + id: id.into(), + bids: bids.into_iter().collect(), + asks: asks.into_iter().collect(), + last, + tick, + rows: 15, + places: 2, + on_trade: None, + red_up: false, + } + } + + pub fn rows(mut self, rows: usize) -> Self { + self.rows = rows; + self + } + + /// Gets a buy at a bid's price or a sell at an ask's, from a press on its size. + pub fn on_trade( + mut self, + handler: impl Fn(Side, f64, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_trade = Some(Rc::new(handler)); + self + } + + pub fn red_up(mut self) -> Self { + self.red_up = true; + self + } +} + +/// The prices a ladder shows around `last`, highest first, each on a whole step. +fn rungs(last: f64, tick: f64, rows: usize) -> Vec { + let middle = (last / tick).round() as i64; + let above = rows as i64 / 2; + (0..rows as i64) + .map(|ix| (middle + above - ix) as f64 * tick) + .collect() +} + +impl RenderOnce for DomLadder { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (rise, fall) = moves(self.red_up, cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let near = |side: &[(f64, f64)], at: f64| { + side.iter() + .find(|(level, _)| (level - at).abs() < self.tick / 2.0) + .map(|(_, size)| *size) + }; + let deepest = self + .bids + .iter() + .chain(&self.asks) + .map(|(_, size)| *size) + .fold(0.0, f64::max); + let last = (self.last / self.tick).round() * self.tick; + let rows = rungs(self.last, self.tick, self.rows) + .into_iter() + .map(|at| { + let (bid, ask) = (near(&self.bids, at), near(&self.asks, at)); + let side_cell = |size: Option, side: Side, ink: Hsla| { + let key = format!("{}-{:?}", at, side); + let body = level( + size.unwrap_or(0.0) / deepest.max(f64::EPSILON), + ink, + side == Side::Buy, + cx, + ) + .flex_1() + .child(cell(size.map(compact).unwrap_or_default(), colors.fg)); + let trade = self.on_trade.clone(); + div() + .id((self.id.clone(), SharedString::from(key))) + .flex_1() + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(trade, |cell, trade| { + cell.on_click(move |_, window, cx| { + log::info!("dom ladder: {side:?} at {at}"); + trade(side, at, window, cx) + }) + }) + .child(body) + }; + let marked = (at - last).abs() < self.tick / 2.0; + div() + .flex() + .items_center() + .border_b_1() + .border_color(colors.border.opacity(0.5)) + .child(side_cell(bid, Side::Buy, rise)) + .child( + tabular(div()) + .w(theme.label_width() * 0.6) + .text_center() + .text_size(theme.text_size(TextSize::Sm)) + .when(marked, |price| { + price + .bg(colors.active) + .text_color(colors.fg) + .font_weight(FontWeight::SEMIBOLD) + }) + .when(!marked, |price| price.text_color(colors.fg_muted)) + .child(price(at, self.places)), + ) + .child(side_cell(ask, Side::Sell, fall)) + }); + div() + .flex() + .flex_col() + .rounded(theme.radius(crate::theme::Radius::Md)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .children(rows) + } +} + +#[cfg(test)] +mod tests { + use super::rungs; + + #[test] + fn rungs_step_around_the_last() { + let prices = rungs(100.02, 0.05, 5); + assert_eq!(prices.len(), 5); + assert!( + (prices[2] - 100.0).abs() < 1e-9, + "the middle rung is the last price on a whole step" + ); + assert!(prices[0] > prices[4], "highest first"); + } +} diff --git a/src/finance/tools.rs b/src/finance/tools.rs new file mode 100644 index 0000000..6c956bf --- /dev/null +++ b/src/finance/tools.rs @@ -0,0 +1,337 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, Window, fill, +}; + +use super::{ + draw::Scene, + series::{Overlay, Study}, +}; +use crate::{ + buttons::{ButtonVariant, ToggleGroup, ToggleItem}, + charts::{Rect, at, finish, place, ring, tint}, + menus::{DropdownMenu, Menu, MenuItem}, + primitives::IconName, + theme::ControlSize, +}; + +/// A tool that draws on a market chart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tool { + Trend, + Level, + Box, + Fib, +} + +/// A mark drawn on a market chart, pinned to candles and prices so it moves with them: each point is a candle, which may fall between two, and a price. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Drawing { + /// A straight line between two points. + Trend((f64, f64), (f64, f64)), + /// A level across the chart. + Level(f64), + /// A shaded rectangle between two corners. + Box((f64, f64), (f64, f64)), + /// Retracement levels between a swing's two ends. + Fib((f64, f64), (f64, f64)), +} + +/// The shares of a swing that retracement levels mark. +pub const FIB: [f64; 6] = [0.0, 0.236, 0.382, 0.5, 0.618, 1.0]; + +impl Drawing { + /// What a tool draws from where a drag began to where it is. + pub(crate) fn of(tool: Tool, from: (f64, f64), to: (f64, f64)) -> Drawing { + match tool { + Tool::Trend => Drawing::Trend(from, to), + Tool::Level => Drawing::Level(to.1), + Tool::Box => Drawing::Box(from, to), + Tool::Fib => Drawing::Fib(from, to), + } + } + + /// Each retracement level's price, from the swing's end back toward its start. + pub(crate) fn levels((_, start): (f64, f64), (_, end): (f64, f64)) -> [(f64, f64); 6] { + FIB.map(|share| (share, end - (end - start) * share)) + } +} + +/// Each tool with its key, words and icon; the pointer draws nothing. +const TOOLS: [(Option, &str, &str, IconName); 5] = [ + (None, "pointer", "Point", IconName::MousePointer2), + ( + Some(Tool::Trend), + "trend", + "Trend line", + IconName::TrendingUp, + ), + (Some(Tool::Level), "level", "Level", IconName::Minus), + (Some(Tool::Box), "box", "Box", IconName::Square), + ( + Some(Tool::Fib), + "fib", + "Fibonacci retracement", + IconName::AlignJustify, + ), +]; + +type OnTool = Rc, &mut Window, &mut App)>; + +/// The tools that draw on a market chart, and the pointer that draws nothing, one chosen at a time. +#[derive(IntoElement)] +pub struct DrawingToolbar { + id: ElementId, + tool: Option, + on_change: Option, +} + +impl DrawingToolbar { + pub fn new(id: impl Into, tool: Option) -> Self { + Self { + id: id.into(), + tool, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for DrawingToolbar { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let chosen = TOOLS + .iter() + .find(|(tool, ..)| *tool == self.tool) + .map(|(_, key, ..)| *key) + .expect("every tool is listed"); + let group = TOOLS.iter().fold( + ToggleGroup::new(self.id).size(ControlSize::Sm), + |group, (_, key, words, icon)| { + group.item(ToggleItem::new(*key).icon(*icon).tooltip(*words)) + }, + ); + let group = group.selected([chosen]); + match self.on_change { + Some(on_change) => group.on_change(move |keys, window, cx| { + let tool = keys + .first() + .and_then(|key| TOOLS.iter().find(|(_, listed, ..)| *listed == key.as_ref())) + .and_then(|(tool, ..)| *tool); + log::info!("drawing toolbar: {tool:?}"); + on_change(tool, window, cx) + }), + None => group, + } + } +} + +/// The overlays and studies an indicator menu offers. +const OVERLAYS: [(Overlay, &str); 5] = [ + (Overlay::Sma(20), "Moving average 20"), + (Overlay::Ema(50), "Exponential average 50"), + (Overlay::Bollinger(20, 2.0), "Bollinger bands"), + (Overlay::Vwap, "VWAP"), + (Overlay::Sma(200), "Moving average 200"), +]; + +const STUDIES: [(Study, &str); 4] = [ + (Study::Macd, "MACD"), + (Study::Rsi, "RSI"), + (Study::Kdj, "KDJ"), + (Study::Obv, "On-balance volume"), +]; + +type OnIndicators = Rc, Vec, &mut Window, &mut App)>; + +/// A menu of indicators, each checked when shown: overlays over the prices, and studies in panes of their own. +#[derive(IntoElement)] +pub struct IndicatorSelector { + id: ElementId, + overlays: Vec, + studies: Vec, + on_change: Option, +} + +impl IndicatorSelector { + pub fn new(id: impl Into, overlays: Vec, studies: Vec) -> Self { + Self { + id: id.into(), + overlays, + studies, + on_change: None, + } + } + + /// Gets the overlays and studies after one is checked or cleared. + pub fn on_change( + mut self, + handler: impl Fn(Vec, Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for IndicatorSelector { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (overlays, studies) = (Rc::new(self.overlays), Rc::new(self.studies)); + let on_change = self.on_change; + let flip = |overlay: Option, study: Option| { + let (overlays, studies, on_change) = + (overlays.clone(), studies.clone(), on_change.clone()); + move |window: &mut Window, cx: &mut App| { + let (mut overlays, mut studies) = ((*overlays).clone(), (*studies).clone()); + if let Some(overlay) = overlay { + match overlays.iter().position(|shown| *shown == overlay) { + Some(ix) => drop(overlays.remove(ix)), + None => overlays.push(overlay), + } + } + if let Some(study) = study { + match studies.iter().position(|shown| *shown == study) { + Some(ix) => drop(studies.remove(ix)), + None => studies.push(study), + } + } + log::info!("indicators: {overlays:?} and {studies:?}"); + if let Some(on_change) = &on_change { + on_change(overlays, studies, window, cx); + } + } + }; + let over = OVERLAYS.iter().map(|(overlay, words)| { + MenuItem::check(*words, overlays.contains(overlay)).on_click(flip(Some(*overlay), None)) + }); + let under = STUDIES.iter().map(|(study, words)| { + MenuItem::check(*words, studies.contains(study)).on_click(flip(None, Some(*study))) + }); + let menu = Menu::new() + .group("Over the prices", over) + .group("In a pane below", under); + let shown = overlays.len() + studies.len(); + let label: SharedString = if shown == 0 { + "Indicators".into() + } else { + format!("Indicators · {shown}").into() + }; + DropdownMenu::new(self.id, label, menu) + .icon(IconName::Activity) + .variant(ButtonVariant::Ghost) + } +} + +/// Marks drawn over the prices: trend lines with their ends, levels, boxes, and retracement levels with the middle band shaded. +pub(super) fn drawings(scene: &Scene, origin: Point, window: &mut Window) { + let (main, scale, pen) = (scene.panes[0].0, &scene.panes[0].1, &scene.pen); + let spot = |(ix, price): (f64, f64)| (scene.visible.x(ix, (main.x, main.w)), scale.at(price)); + let (ink, accent) = (pen.palette.fg_muted, tint(&pen.palette, 0)); + for drawing in &scene.drawings { + match *drawing { + Drawing::Trend(from, to) => { + let (from, to) = (spot(from), spot(to)); + let mut line = PathBuilder::stroke(pen.stroke * 0.75); + line.move_to(at(origin, from)); + line.line_to(at(origin, to)); + finish(line, ink, window); + for end in [from, to] { + ring( + at(origin, end), + pen.stroke * 1.5, + (pen.palette.bg, ink), + pen.hairline, + window, + ); + } + } + Drawing::Level(price) => { + let y = scale.at(price); + window.paint_quad(fill( + place( + origin, + Rect { + x: main.x, + y, + w: main.w, + h: f32::from(pen.hairline), + }, + ), + ink, + )); + } + Drawing::Box(from, to) => { + let ((x0, y0), (x1, y1)) = (spot(from), spot(to)); + let rect = Rect { + x: x0.min(x1), + y: y0.min(y1), + w: (x1 - x0).abs(), + h: (y1 - y0).abs(), + }; + window.paint_quad( + fill(place(origin, rect), accent.opacity(0.1)) + .border_widths(pen.hairline) + .border_color(accent), + ); + } + Drawing::Fib(from, to) => { + let (x0, x1) = (spot(from).0.min(spot(to).0), spot(from).0.max(spot(to).0)); + let levels = Drawing::levels(from, to); + let (upper, lower) = (scale.at(levels[2].1), scale.at(levels[4].1)); + let band = Rect { + x: x0, + y: upper.min(lower), + w: x1 - x0, + h: (upper - lower).abs(), + }; + window.paint_quad(fill(place(origin, band), accent.opacity(0.08))); + for (share, price) in levels { + let color = if share == FIB[0] || share == FIB[5] { + ink + } else { + pen.palette.fg_subtle + }; + window.paint_quad(fill( + place( + origin, + Rect { + x: x0, + y: scale.at(price), + w: x1 - x0, + h: f32::from(pen.hairline), + }, + ), + color, + )); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tools_make_their_drawings_and_levels_run_back_from_the_end() { + assert_eq!( + Drawing::of(Tool::Level, (1.0, 10.0), (4.0, 12.0)), + Drawing::Level(12.0) + ); + assert_eq!( + Drawing::of(Tool::Trend, (1.0, 10.0), (4.0, 12.0)), + Drawing::Trend((1.0, 10.0), (4.0, 12.0)) + ); + let levels = Drawing::levels((0.0, 100.0), (5.0, 200.0)); + assert_eq!(levels[0], (0.0, 200.0), "the swing's end"); + assert_eq!(levels[3], (0.5, 150.0), "halfway back"); + assert_eq!(levels[5], (1.0, 100.0), "all the way back"); + } +} diff --git a/src/finance/trade.rs b/src/finance/trade.rs new file mode 100644 index 0000000..b9e1575 --- /dev/null +++ b/src/finance/trade.rs @@ -0,0 +1,378 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use super::{book::Side, quotes::price}; +use crate::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + forms::NumberInput, + overlays::ConfirmDialog, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::{format, tabular}, +}; + +/// How an order fills: now at the market, at its price or better, or once the market reaches its stop. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OrderKind { + #[default] + Market, + Limit, + Stop, +} + +impl OrderKind { + pub(crate) fn word(self) -> &'static str { + match self { + OrderKind::Market => "Market", + OrderKind::Limit => "Limit", + OrderKind::Stop => "Stop", + } + } +} + +/// An order before it is placed: its symbol, which way, how it fills, how much, and the price it waits for when it waits. +#[derive(Clone, Debug, PartialEq)] +pub struct Order { + pub symbol: SharedString, + pub side: Side, + pub kind: OrderKind, + pub quantity: f64, + pub price: f64, +} + +impl Order { + /// What it comes to at `last` for a market order, or at its own price. + pub fn value(&self, last: f64) -> f64 { + self.quantity + * if self.kind == OrderKind::Market { + last + } else { + self.price + } + } + + /// The order in a line, such as "Buy 100 ELY at the market". + pub fn words(&self, places: usize) -> String { + let side = if self.side == Side::Buy { + "Buy" + } else { + "Sell" + }; + let how = match self.kind { + OrderKind::Market => "at the market".to_string(), + OrderKind::Limit => format!("at {} or better", price(self.price, places)), + OrderKind::Stop => format!("once it reaches {}", price(self.price, places)), + }; + format!( + "{side} {} {} {how}", + format::number(self.quantity, 0, format::Separators::EN), + self.symbol + ) + } +} + +type OnOrder = Rc; +type Run = Rc; + +/// An order to fill in: its side, how it fills, its price when it waits for one, its quantity and what it comes to; the button places it. +#[derive(IntoElement)] +pub struct OrderEntry { + id: ElementId, + order: Order, + last: f64, + on_change: Option, + on_submit: Option, +} + +impl OrderEntry { + pub fn new(id: impl Into, order: Order, last: f64) -> Self { + Self { + id: id.into(), + order, + last, + on_change: None, + on_submit: None, + } + } + + /// Gets the order after each change to it. + pub fn on_change(mut self, handler: impl Fn(Order, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn on_submit(mut self, handler: impl Fn(Order, &mut Window, &mut App) + 'static) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } +} + +/// A labelled row of a form. +fn field(label: &'static str, body: impl IntoElement, cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(label), + ) + .child(body) +} + +impl RenderOnce for OrderEntry { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let order = self.order.clone(); + let change = |edit: fn(&mut Order, f64), order: Order, on_change: Option| { + move |value: f64, window: &mut Window, cx: &mut App| { + let mut next = order.clone(); + edit(&mut next, value); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + } + }; + let pick = |order: Order, on_change: Option| { + move |key: &SharedString, window: &mut Window, cx: &mut App| { + let mut next = order.clone(); + match key.as_ref() { + "buy" => next.side = Side::Buy, + "sell" => next.side = Side::Sell, + "limit" => next.kind = OrderKind::Limit, + "stop" => next.kind = OrderKind::Stop, + _ => next.kind = OrderKind::Market, + } + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + } + }; + let side = SegmentedControl::new( + (self.id.clone(), "side"), + if order.side == Side::Buy { + "buy" + } else { + "sell" + }, + ) + .segment("buy", "Buy", None) + .segment("sell", "Sell", None) + .on_change(pick(order.clone(), self.on_change.clone())); + let kind = [OrderKind::Market, OrderKind::Limit, OrderKind::Stop] + .iter() + .fold( + SegmentedControl::new((self.id.clone(), "kind"), order.kind.word().to_lowercase()) + .size(ControlSize::Sm), + |control, kind| control.segment(kind.word().to_lowercase(), kind.word(), None), + ); + let kind = kind.on_change(pick(order.clone(), self.on_change.clone())); + let price_field = if order.kind == OrderKind::Market { + div() + .flex() + .justify_between() + .px_3() + .py_1p5() + .rounded(theme.radius(crate::theme::Radius::Md)) + .bg(colors.sunken) + .text_color(colors.fg_muted) + .child("Market") + .child(tabular(div()).child(price(self.last, 2))) + .into_any_element() + } else { + NumberInput::new((self.id.clone(), "price"), order.price) + .step(0.01) + .precision(2) + .on_change(change( + |order, value| order.price = value, + order.clone(), + self.on_change.clone(), + )) + .into_any_element() + }; + let quantity = NumberInput::new((self.id.clone(), "quantity"), order.quantity) + .range(0.0, f64::MAX) + .step(1.0) + .precision(0) + .on_change(change( + |order, value| order.quantity = value, + order.clone(), + self.on_change.clone(), + )); + let variant = if order.side == Side::Buy { + ButtonVariant::Success + } else { + ButtonVariant::Danger + }; + let submit = Button::new((self.id.clone(), "place"), order.words(2)) + .variant(variant) + .full_width(); + let placed = (order.clone(), self.on_submit.clone()); + let submit = submit + .disabled(order.quantity <= 0.0) + .on_click(move |_, window, cx| { + log::info!("order entry: {}", placed.0.words(2)); + if let Some(on_submit) = &placed.1 { + on_submit(placed.0.clone(), window, cx); + } + }); + div() + .flex() + .flex_col() + .gap_3() + .child(side) + .child(kind) + .child(field("Price", price_field, cx)) + .child(field("Quantity", quantity, cx)) + .child( + div() + .flex() + .justify_between() + .text_size(theme.text_size(TextSize::Sm)) + .child(div().text_color(colors.fg_muted).child("Value")) + .child( + tabular(div()) + .text_color(colors.fg) + .child(format!("${}", price(order.value(self.last), 2))), + ), + ) + .child(submit) + } +} + +type OnSide = Rc; + +/// Buy or sell at a press: the bid on the sell button, the ask on the buy button, and the spread between them. +#[derive(IntoElement)] +pub struct QuickTradeButtons { + id: ElementId, + bid: f64, + ask: f64, + on_trade: Option, +} + +impl QuickTradeButtons { + pub fn new(id: impl Into, bid: f64, ask: f64) -> Self { + assert!(bid < ask, "a bid sits below its ask"); + Self { + id: id.into(), + bid, + ask, + on_trade: None, + } + } + + pub fn on_trade(mut self, handler: impl Fn(Side, &mut Window, &mut App) + 'static) -> Self { + self.on_trade = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for QuickTradeButtons { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let button = |side: Side, at: f64| { + let (name, variant) = if side == Side::Buy { + ("Buy", ButtonVariant::Success) + } else { + ("Sell", ButtonVariant::Danger) + }; + let trade = self.on_trade.clone(); + div().flex_1().child( + Button::new((self.id.clone(), name), format!("{name} {}", price(at, 2))) + .variant(variant) + .size(ControlSize::Lg) + .full_width() + .on_click(move |_, window, cx| { + log::info!("quick trade: {side:?} at {at}"); + if let Some(trade) = &trade { + trade(side, window, cx); + } + }), + ) + }; + div() + .flex() + .items_center() + .gap_2() + .child(button(Side::Sell, self.bid)) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(price(self.ask - self.bid, 2)), + ) + .child(button(Side::Buy, self.ask)) + } +} + +/// Asks before an order goes, reading back what it will do and what it comes to. +#[derive(IntoElement)] +pub struct OrderConfirmDialog { + id: ElementId, + order: Order, + last: f64, + on_confirm: Option, + on_close: Run, +} + +impl OrderConfirmDialog { + /// `on_close` runs on every way out, placing the order included. + pub fn new( + id: impl Into, + order: Order, + last: f64, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + order, + last, + on_confirm: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_confirm(mut self, handler: impl Fn(Order, &mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for OrderConfirmDialog { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let message = format!( + "{}, about ${} in all.", + self.order.words(2), + price(self.order.value(self.last), 2) + ); + let close = self.on_close.clone(); + let dialog = + ConfirmDialog::new(self.id, "Place this order?", message, move |window, cx| { + close(window, cx) + }) + .confirm(if self.order.side == Side::Buy { + "Buy" + } else { + "Sell" + }); + let dialog = if self.order.side == Side::Sell { + dialog.destructive() + } else { + dialog + }; + match self.on_confirm { + Some(on_confirm) => { + let order = self.order; + dialog.on_confirm(move |window, cx| on_confirm(order.clone(), window, cx)) + } + None => dialog, + } + } +} diff --git a/src/finance/wallet.rs b/src/finance/wallet.rs new file mode 100644 index 0000000..147b1dc --- /dev/null +++ b/src/finance/wallet.rs @@ -0,0 +1,441 @@ +use std::{cmp::Reverse, rc::Rc}; + +use gpui::{ + App, ClipboardItem, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, +}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::quotes::price; +use crate::{ + buttons::{ButtonVariant, IconButton}, + data_display::{Badge, QrCode, Tone}, + forms::{Combobox, NumberInput, TextInput}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{Ellipsis, format, tabular}, +}; + +type OnConvert = Rc; + +/// An amount in one currency as another: the amount and both currencies to choose, a swap between them, the result, and the rate the owner gives. +#[derive(IntoElement)] +pub struct CurrencyConverter { + id: ElementId, + amount: f64, + from: SharedString, + to: SharedString, + rate: f64, + on_change: Option, +} + +impl CurrencyConverter { + /// `rate` is how much of `to` one `from` buys; codes are ISO 4217. + pub fn new(id: impl Into, amount: f64, (from, to): (&str, &str), rate: f64) -> Self { + assert!(rate.is_finite() && rate > 0.0, "a rate is positive"); + Self { + id: id.into(), + amount, + from: from.to_string().into(), + to: to.to_string().into(), + rate, + on_change: None, + } + } + + /// Gets the amount and both codes after any of them changes. + pub fn on_change( + mut self, + handler: impl Fn(f64, SharedString, SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CurrencyConverter { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let from_field = + window.use_keyed_state((self.id.clone(), "from-field"), cx, |window, cx| { + TextInput::new(window, cx) + }); + let to_field = window.use_keyed_state((self.id.clone(), "to-field"), cx, |window, cx| { + TextInput::new(window, cx) + }); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (amount, from, to, on_change) = ( + self.amount, + self.from.clone(), + self.to.clone(), + self.on_change.clone(), + ); + let tell = move |amount: f64, + from: SharedString, + to: SharedString, + window: &mut Window, + cx: &mut App| { + log::info!("currency converter: {amount} {from} to {to}"); + if let Some(on_change) = &on_change { + on_change(amount, from, to, window, cx); + } + }; + let tell = Rc::new(tell); + let (on_amount, on_from, on_to, on_swap) = (tell.clone(), tell.clone(), tell.clone(), tell); + let (to_now, from_now) = (to.clone(), from.clone()); + let (_, places, _) = format::currency_parts(&self.to); + let result = format::currency(self.amount * self.rate, &self.to); + div() + .flex() + .flex_col() + .gap_3() + .child( + div().w_full().child( + NumberInput::new((self.id.clone(), "amount"), self.amount) + .range(0.0, f64::MAX) + .precision(2) + .on_change({ + let (from, to) = (from.clone(), to.clone()); + move |amount, window, cx| { + on_amount(amount, from.clone(), to.clone(), window, cx) + } + }), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div().flex_1().min_w_0().child( + Combobox::currencies((self.id.clone(), "from"), &from_field) + .selected(self.from.clone()) + .on_change(move |code, window, cx| { + on_from(amount, code.clone(), to_now.clone(), window, cx) + }), + ), + ) + .child( + IconButton::new((self.id.clone(), "swap"), IconName::ChevronsLeftRight) + .variant(ButtonVariant::Ghost) + .tooltip("Swap") + .on_click({ + let (from, to) = (from.clone(), to.clone()); + move |_, window, cx| { + on_swap(amount, to.clone(), from.clone(), window, cx) + } + }), + ) + .child( + div().flex_1().min_w_0().child( + Combobox::currencies((self.id.clone(), "to"), &to_field) + .selected(self.to.clone()) + .on_change(move |code, window, cx| { + on_to(amount, from_now.clone(), code.clone(), window, cx) + }), + ), + ), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(result), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(format!( + "1 {} = {} {}", + self.from, + format::number(self.rate, places.max(4), format::Separators::EN), + self.to + )), + ) + } +} + +/// An address shortened to its ends, as wallets show them. +fn shortened(address: &str) -> String { + let count = address.chars().count(); + if count <= 14 { + return address.to_string(); + } + let head: String = address.chars().take(6).collect(); + let tail: String = address.chars().skip(count - 4).collect(); + format!("{head}…{tail}") +} + +/// A wallet at a glance: its name and network, the balance in its coin and in money, its address to copy, and a code to scan. +#[derive(IntoElement)] +pub struct CryptoWalletCard { + id: ElementId, + name: SharedString, + network: SharedString, + balance: (f64, SharedString), + worth: (f64, SharedString), + address: SharedString, +} + +impl CryptoWalletCard { + /// The balance in its coin, such as `(0.84, "BTC")`, and what it is worth in an ISO 4217 currency. + pub fn new( + id: impl Into, + (name, network): (impl Into, impl Into), + balance: (f64, &str), + worth: (f64, &str), + address: impl Into, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + network: network.into(), + balance: (balance.0, balance.1.to_string().into()), + worth: (worth.0, worth.1.to_string().into()), + address: address.into(), + } + } +} + +impl RenderOnce for CryptoWalletCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let code = QrCode::new(self.address.as_bytes()) + .expect("an address fits a code") + .size(theme.qr_code() * 0.6); + let address = self.address.clone(); + div() + .flex() + .gap_6() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.name), + ) + .child(Badge::new(self.network)), + ) + .child( + div() + .flex() + .flex_col() + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(format!( + "{} {}", + format::number(self.balance.0, 4, format::Separators::EN), + self.balance.1 + )), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(format::currency(self.worth.0, &self.worth.1)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .child( + div() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(shortened(&self.address)), + ) + .child( + IconButton::new((self.id.clone(), "copy"), IconName::Copy) + .variant(ButtonVariant::Ghost) + .tooltip("Copy the address") + .on_click(move |_, _, cx| { + log::info!("wallet card: address copied"); + cx.write_to_clipboard(ClipboardItem::new_string( + address.to_string(), + )); + }), + ), + ), + ) + .child(code) + } +} + +/// Whether a transfer has settled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Settled { + Pending, + Done, + Failed, +} + +/// A transfer in or out: when, with whom, how much, positive when received, in what unit, and whether it settled. +#[derive(Clone, Debug, PartialEq)] +pub struct Transfer { + pub time: Timestamp, + pub party: SharedString, + pub amount: f64, + pub unit: SharedString, + pub settled: Settled, +} + +/// Transfers newest first: which way each went, with whom, when, how much, and whether it has settled. +#[derive(IntoElement)] +pub struct TransactionList { + transfers: Vec, + zone: TimeZone, +} + +impl TransactionList { + pub fn new(transfers: impl IntoIterator) -> Self { + let mut transfers: Vec = transfers.into_iter().collect(); + transfers.sort_by_key(|transfer| Reverse(transfer.time)); + Self { + transfers, + zone: TimeZone::system(), + } + } + + /// The time zone its times read in; the system's unless set. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = zone; + self + } +} + +/// An amount in its unit: a currency's own places, or as many as a coin needs, up to eight. +fn amount(value: f64, unit: &str) -> String { + match iso_currency::Currency::from_code(unit) { + Some(currency) => price(value, currency.exponent().map_or(0, usize::from)), + None => price(value, 8) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string(), + } +} + +impl RenderOnce for TransactionList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .children(self.transfers.iter().map(|transfer| { + let received = transfer.amount >= 0.0; + let (icon, ink) = if received { + (IconName::ArrowDownLeft, colors.success) + } else { + (IconName::ArrowUpRight, colors.fg_muted) + }; + let status = match transfer.settled { + Settled::Pending => Some(Badge::new("Pending").tone(Tone::Warning)), + Settled::Failed => Some(Badge::new("Failed").tone(Tone::Danger)), + Settled::Done => None, + }; + let sign = if received { "+" } else { "" }; + div() + .flex() + .items_center() + .gap_3() + .py_2() + .border_b_1() + .border_color(colors.border.opacity(0.5)) + .child( + div() + .size(theme.icon_size(IconSize::Xl)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(colors.hover) + .child(Icon::new(icon).size(IconSize::Sm).color(ink)), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(Ellipsis::new(transfer.party.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child( + transfer + .time + .to_zoned(self.zone.clone()) + .strftime("%b %-d, %H:%M") + .to_string(), + ), + ), + ) + .children(status) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if received { colors.success } else { colors.fg }) + .child(format!( + "{sign}{} {}", + amount(transfer.amount, &transfer.unit), + transfer.unit + )), + ) + })) + } +} + +#[cfg(test)] +mod tests { + use super::{amount, shortened}; + + #[test] + fn amounts_keep_their_units_places() { + assert_eq!(amount(-500.0, "EUR"), "\u{2212}500.00"); + assert_eq!(amount(1250.0, "JPY"), "1,250"); + assert_eq!(amount(0.0421, "BTC"), "0.0421"); + assert_eq!(amount(2.0, "ETH"), "2"); + } + + #[test] + fn addresses_shorten_to_their_ends() { + assert_eq!( + shortened("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"), + "bc1qxy…0wlh" + ); + assert_eq!(shortened("short"), "short"); + } +} diff --git a/src/forms/catalogs.rs b/src/forms/catalogs.rs index 178f6c7..f899ce0 100644 --- a/src/forms/catalogs.rs +++ b/src/forms/catalogs.rs @@ -11,7 +11,7 @@ use super::{Choice, Combobox, TextInput, options::OnValue}; use crate::theme::{ActiveTheme, TextSize}; /// The flag emoji of a two-letter region code. -fn flag(region: &str) -> String { +pub(crate) fn flag(region: &str) -> String { region .chars() .map(|letter| { diff --git a/src/forms/mod.rs b/src/forms/mod.rs index bb3d9fc..8609231 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -48,6 +48,7 @@ pub use avatar::AvatarUpload; pub use card::{CheckboxCard, RadioCard}; pub use cascade::{Cascade, Cascader}; pub use catalogs::FontPicker; +pub(crate) use catalogs::flag; pub(crate) use check::check_mark; pub use check::{CheckState, Checkbox, CheckboxGroup}; pub use checked::{EmailInput, UrlInput, is_email, is_url}; diff --git a/src/forms/options.rs b/src/forms/options.rs index 745ce0d..693a379 100644 --- a/src/forms/options.rs +++ b/src/forms/options.rs @@ -10,6 +10,7 @@ use crate::{ motion, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, + typography::Ellipsis, }; /// One option: a value, the label shown for it, and an optional icon and note. @@ -130,10 +131,7 @@ pub(crate) fn option_row( div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .child(choice.label.clone()), + .child(Ellipsis::new(choice.label.clone())), ) .when_some(choice.note.clone(), |row, note| { row.child(div().text_color(colors.fg_subtle).child(note)) diff --git a/src/forms/select.rs b/src/forms/select.rs index 10f9c84..7b2874a 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -14,6 +14,7 @@ use super::{ use crate::{ primitives::{Icon, IconName, tab_stop}, theme::{ActiveTheme, ControlSize, IconSize, Radius}, + typography::Ellipsis, }; /// An open list's place and cursor, kept between frames. @@ -117,11 +118,8 @@ pub(crate) fn field_text( div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() .text_color(color) - .child(text.unwrap_or(placeholder)) + .child(Ellipsis::new(text.unwrap_or(placeholder))) } /// The list a trigger opens: its rows, the one chosen, and whether the trigger holds focus. diff --git a/src/forms/upload.rs b/src/forms/upload.rs index 83733ec..7f05e56 100644 --- a/src/forms/upload.rs +++ b/src/forms/upload.rs @@ -10,7 +10,7 @@ use crate::{ motion::ProgressBar, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, TextSize}, - typography::{format::file_size, tabular}, + typography::{Ellipsis, format::file_size, tabular}, }; /// Where one upload stands. @@ -164,9 +164,8 @@ impl RenderOnce for UploadList { div() .flex_1() .min_w_0() - .truncate() .font_weight(FontWeight::MEDIUM) - .child(upload.name.clone()), + .child(Ellipsis::new(upload.name.clone())), ) .child( tabular(div()) diff --git a/src/lib.rs b/src/lib.rs index 02cff06..db58ed9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod buttons; pub mod charts; pub mod data_display; pub mod feedback; +pub mod finance; pub mod forms; pub mod layout; pub mod lists; diff --git a/src/lists/item.rs b/src/lists/item.rs index d18341f..4b1c719 100644 --- a/src/lists/item.rs +++ b/src/lists/item.rs @@ -9,7 +9,7 @@ use smallvec::SmallVec; use crate::{ theme::{ActiveTheme, ControlSize, Radius, TextSize}, - typography::LEADING, + typography::{Ellipsis, LEADING}, }; type OnClick = Rc; @@ -95,7 +95,12 @@ impl RenderOnce for ListItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = &theme.colors; - let line = |text: SharedString| div().truncate().line_height(relative(LEADING)).child(text); + let line = |text: SharedString| { + div() + .min_w_0() + .line_height(relative(LEADING)) + .child(Ellipsis::new(text)) + }; let pressable = self.on_click.is_some() && !self.disabled; div() .id(self.id) diff --git a/src/lists/tree/row.rs b/src/lists/tree/row.rs index 9eb97bc..2d37544 100644 --- a/src/lists/tree/row.rs +++ b/src/lists/tree/row.rs @@ -16,6 +16,7 @@ use crate::{ motion::{self, Spinner}, primitives::{Disclosure, DragGhost, Icon}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, }; /// What drawing rows needs, shared by every range the list asks for. @@ -151,9 +152,8 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement None => div() .flex_1() .min_w_0() - .truncate() .when_some(ink, |text, ink| text.text_color(ink)) - .child(label.clone()) + .child(Ellipsis::new(label.clone())) .into_any_element(), }; let line = |at: DropAt| { diff --git a/src/motion/effects.rs b/src/motion/effects.rs index 76403d1..3f0b1bc 100644 --- a/src/motion/effects.rs +++ b/src/motion/effects.rs @@ -105,6 +105,7 @@ impl RenderOnce for Blink { pub struct Flash { id: ElementId, key: K, + tint: Option, base: Div, body: SmallVec<[AnyElement; 2]>, } @@ -114,10 +115,17 @@ impl Flash { Self { id: id.into(), key, + tint: None, base: div(), body: SmallVec::new(), } } + + /// The color it flashes; the selection's unless set. + pub fn tint(mut self, tint: Hsla) -> Self { + self.tint = Some(tint); + self + } } impl Styled for Flash { @@ -135,7 +143,7 @@ impl ParentElement for Flash { impl RenderOnce for Flash { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let share = since_change(&self.id, self.key, duration(FLASH, cx), window, cx); - let tint = cx.theme().colors.selection; + let tint = self.tint.unwrap_or(cx.theme().colors.selection); self.base.children(self.body).when_some(share, |held, t| { held.bg(tint.opacity(1.0 - ease_out_cubic(t))) }) diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index a0fea02..7e5adf2 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -37,6 +37,7 @@ icons! { Activity => "activity", AlarmClock => "alarm-clock", AlignCenter => "align-center", + AlignJustify => "align-justify", AlignLeft => "align-left", AlignRight => "align-right", AppWindow => "app-window", @@ -63,8 +64,10 @@ icons! { CalendarRange => "calendar-range", Calendar => "calendar", Camera => "camera", + ChartArea => "chart-area", ChartBar => "chart-bar", ChartCandlestick => "chart-candlestick", + ChartColumn => "chart-column", ChartLine => "chart-line", ChartPie => "chart-pie", CheckCheck => "check-check", @@ -94,6 +97,7 @@ icons! { Cloud => "cloud", CloudUpload => "cloud-upload", Code => "code", + Columns2 => "columns-2", Command => "command", Compass => "compass", Contrast => "contrast", @@ -207,6 +211,7 @@ icons! { Rocket => "rocket", RotateCcw => "rotate-ccw", RotateCw => "rotate-cw", + Rows2 => "rows-2", Save => "save", Scissors => "scissors", Search => "search", diff --git a/src/shell/tabs.rs b/src/shell/tabs.rs index 6aab688..cdd353d 100644 --- a/src/shell/tabs.rs +++ b/src/shell/tabs.rs @@ -11,6 +11,7 @@ use crate::{ motion, primitives::{DragGhost, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, }; /// One window-level tab. @@ -199,10 +200,7 @@ impl RenderOnce for TabBar { div() .flex_1() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(tab.title.clone()), + .child(Ellipsis::new(tab.title.clone())), ) .when_some(close, |el, close| { el.child( diff --git a/src/tables/cell.rs b/src/tables/cell.rs index ac51a43..8c77407 100644 --- a/src/tables/cell.rs +++ b/src/tables/cell.rs @@ -6,7 +6,7 @@ use crate::{ data_display::{Avatar, Badge, Sparkline, Tone}, motion::ProgressBar, theme::{ActiveTheme, AvatarSize}, - typography::{ExternalLink, format, tabular}, + typography::{Ellipsis, ExternalLink, format, tabular}, }; /// What a cell holds; its column says how numbers read. @@ -211,11 +211,7 @@ pub(crate) fn draw(cell: &Cell, column: &Column, id: ElementId, cx: &App) -> Any let colors = &cx.theme().colors; match cell { Cell::Empty => div().into_any_element(), - Cell::Text(text) => div() - .min_w_0() - .truncate() - .child(text.clone()) - .into_any_element(), + Cell::Text(text) => Ellipsis::new(text.clone()).into_any_element(), Cell::Number(value) => tabular(div()) .child(column.reads(*value)) .into_any_element(), @@ -243,7 +239,7 @@ pub(crate) fn draw(cell: &Cell, column: &Column, id: ElementId, cx: &App) -> Any .gap_2() .min_w_0() .child(Avatar::new(id, name.clone()).size(AvatarSize::Xs)) - .child(div().min_w_0().truncate().child(name.clone())) + .child(Ellipsis::new(name.clone())) .into_any_element(), Cell::Link(label, url) => { ExternalLink::new(id, label.clone(), url.clone()).into_any_element() diff --git a/src/tables/grid.rs b/src/tables/grid.rs index bfb534f..b3f14a6 100644 --- a/src/tables/grid.rs +++ b/src/tables/grid.rs @@ -11,6 +11,7 @@ use crate::{ forms::{Editing, Input}, primitives::{FocusNext, FocusPrev}, theme::{ActiveTheme, ControlSize, TextSize}, + typography::Ellipsis, }; pub(crate) type OnCells = Rc; @@ -151,7 +152,7 @@ pub(crate) fn grid(face: Face, base: Div, window: &mut Window, cx: &mut App) -> .child(Input::new(field).size(ControlSize::Sm)) .into_any_element() } - _ => div().min_w_0().truncate().child(text).into_any_element(), + _ => Ellipsis::new(text).into_any_element(), }; Some( div() diff --git a/src/tables/pivot.rs b/src/tables/pivot.rs index 8bf7d3c..dda19b6 100644 --- a/src/tables/pivot.rs +++ b/src/tables/pivot.rs @@ -6,7 +6,7 @@ use gpui::{ use super::{Aggregate, Cell, model::combine}; use crate::{ theme::{ActiveTheme, TextSize}, - typography::{format, tabular}, + typography::{Ellipsis, format, tabular}, }; /// A cross-tab: labels down the side and across the top, a figure where they meet, and totals. @@ -151,8 +151,7 @@ impl RenderOnce for PivotTable { .w(theme.label_width() * 0.8) .flex_none() .px_3() - .truncate() - .child(text) + .child(Ellipsis::new(text)) }; let number = |text: String, strong: bool| { tabular(div()) diff --git a/src/tables/tree_table.rs b/src/tables/tree_table.rs index 5bc30fb..82a6048 100644 --- a/src/tables/tree_table.rs +++ b/src/tables/tree_table.rs @@ -94,19 +94,18 @@ impl RenderOnce for TreeTable { theme.tree_indent(), ); let columns = Rc::new(self.columns); - let head = - div() - .flex() - .items_center() - .h(height) - .border_b_1() - .border_color(colors.border) - .text_size(theme.text_size(TextSize::Xs)) - .font_weight(FontWeight::MEDIUM) - .text_color(colors.fg_muted) - .children(columns.iter().map(|column| { - sized(div().px_3(), column, narrowest).child(column.title.clone()) - })); + let head = div() + .flex() + .items_center() + .h(height) + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .children(columns.iter().map(|column| { + sized(div().px_3().flex(), column, narrowest).child(column.title.clone()) + })); let rows = shown.into_iter().map(|(row, depth)| { assert_eq!( row.cells.len(), diff --git a/src/theme/chart.rs b/src/theme/chart.rs index 09fa2bc..6b0d4b0 100644 --- a/src/theme/chart.rs +++ b/src/theme/chart.rs @@ -18,6 +18,8 @@ pub struct ChartSizes { pub stroke: Rems, /// Widest a bubble's radius grows. pub bubble: Rems, + /// A market chart's room for each candle before any zoom. + pub candle: Rems, /// How far a slice under the pointer lifts out. pub lift: Rems, /// Gridlines and rules. @@ -34,6 +36,7 @@ impl Theme { label: px_to_rems(64.0), stroke: px_to_rems(2.0), bubble: px_to_rems(24.0), + candle: px_to_rems(8.0), lift: px_to_rems(6.0), hairline: px(1.0), } diff --git a/src/typography/fit.rs b/src/typography/fit.rs index cd548e4..36df19d 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -1,7 +1,7 @@ use gpui::{ App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, prelude::*, - relative, + relative, transparent_black, }; use crate::{ @@ -21,6 +21,23 @@ fn elide(chars: &[char], kept: usize) -> String { out } +/// How wide `text` sets at `size` in the text style around it. +pub(crate) fn text_width(text: &str, size: Pixels, window: &Window) -> Pixels { + let style = window.text_style(); + let run = TextRun { + len: text.len(), + font: style.font(), + color: style.color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(SharedString::from(text.to_string()), size, &[run], None) + .width +} + fn shape(text: &str, window: &mut Window) -> ShapedLine { let style = window.text_style(); let run = TextRun { @@ -62,6 +79,76 @@ fn fit_middle(text: &str, width: Pixels, window: &mut Window) -> String { elide(&chars, low) } +/// Longest front that fits `width` with an ellipsis after it. +fn fit_end(text: &str, width: Pixels, window: &mut Window) -> String { + if width_of(text, window) <= width { + return text.to_string(); + } + if width_of("…", window) > width { + return String::new(); + } + let chars: Vec = text.chars().collect(); + let cut = |kept: usize| { + let front: String = chars[..kept].iter().collect(); + let mut out: String = front + .trim_end_matches(|ch: char| ch.is_whitespace() || matches!(ch, '.' | ',' | ';' | ':')) + .into(); + out.push('…'); + out + }; + let (mut low, mut high) = (0, chars.len()); + while low < high { + let mid = (low + high).div_ceil(2); + if width_of(&cut(mid), window) <= width { + low = mid; + } else { + high = mid - 1; + } + } + cut(low) +} + +/// One line in the text style around it, cut at the end with an ellipsis when its box is too narrow. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. +#[derive(IntoElement)] +pub struct Ellipsis { + text: SharedString, +} + +impl Ellipsis { + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +impl RenderOnce for Ellipsis { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let text = self.text.clone(); + div() + .relative() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(div().text_color(transparent_black()).child(self.text)) + .child( + canvas( + move |bounds, window, _| { + shape(&fit_end(&text, bounds.size.width, window), window) + }, + |bounds, shaped, window, cx| { + let line = window.text_style().line_height_in_pixels(window.rem_size()); + shaped + .paint(bounds.origin, line, window, cx) + .expect("an ellipsis line paints"); + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + } +} + /// Shortens in the middle, keeping both ends. Paths, hashes. #[derive(IntoElement)] pub struct MiddleEllipsis { @@ -176,3 +263,35 @@ mod tests { assert_eq!(elide(&wide, 4), "路径…rs"); } } + +#[cfg(all(test, feature = "test-support"))] +mod fits { + use gpui::{Pixels, TestAppContext}; + + use super::{fit_end, width_of}; + + #[gpui::test] + fn an_ellipsis_keeps_the_longest_front_that_fits(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, _| { + let text = "Good interfaces. Quiet ones"; + let whole = width_of(text, window); + assert_eq!( + fit_end(text, whole, window), + text, + "a line that fits stays whole" + ); + let room = width_of("Good interfaces.…", window); + assert_eq!( + fit_end(text, room, window), + "Good interfaces…", + "no stop or space before the ellipsis" + ); + assert_eq!( + fit_end(text, Pixels::ZERO, window), + "", + "no room shows nothing" + ); + }); + } +} diff --git a/src/typography/mod.rs b/src/typography/mod.rs index 89cb83f..535ffbd 100644 --- a/src/typography/mod.rs +++ b/src/typography/mod.rs @@ -15,8 +15,8 @@ mod text; pub use copy::CopyableText; pub use effects::{GradientText, ShimmerText, Typewriter}; pub use emoji::Emoji; -pub(crate) use fit::LEADING; -pub use fit::{EllipsisTooltip, MiddleEllipsis}; +pub use fit::{Ellipsis, EllipsisTooltip, MiddleEllipsis}; +pub(crate) use fit::{LEADING, text_width}; pub use format::DurationStyle; pub use formatted::{ CurrencyText, DateTimeText, DurationText, FileSizeText, NumberText, PercentText, PluralText, diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index d9decc1..67ede0d 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -43,7 +43,7 @@ - [x] KbdCombo - [x] Blockquote - [x] Highlight / Mark -- [x] Truncate — native `.truncate()` +- [x] Truncate → `typography::Ellipsis`; gpui's `.truncate()` holds only in a block of fixed width - [x] LineClamp — native `.line_clamp(n)` - [x] MiddleEllipsis - [x] EllipsisTooltip diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 51f09b0..5c87036 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -192,84 +192,84 @@ ## T16a Finance · Market charts — `src/finance` -- [ ] CandlestickChart -- [ ] OHLCChart → `finance::CandlestickChart` -- [ ] HeikinAshiChart → `finance::CandlestickChart` -- [ ] LineQuoteChart -- [ ] VolumeChart -- [ ] DepthChart -- [ ] MarketProfile / VolumeProfile -- [ ] RenkoChart / PointAndFigure -- [ ] MultiChartLayout -- [ ] ChartSync -- [ ] TimeRangeSelector -- [ ] IntervalSelector -- [ ] ChartTypeSwitcher +- [x] CandlestickChart +- [x] OHLCChart → `finance::CandlestickChart` with `ChartKind::Bars` +- [x] HeikinAshiChart → `finance::CandlestickChart` with `ChartKind::HeikinAshi` +- [x] LineQuoteChart → `finance::CandlestickChart` with `ChartKind::Line` or `ChartKind::Area` +- [x] VolumeChart → `CandlestickChart::volume` +- [x] DepthChart +- [x] MarketProfile / VolumeProfile → `CandlestickChart::profile` +- [x] RenkoChart / PointAndFigure → `RenkoChart`, `PointFigureChart` +- [x] MultiChartLayout +- [x] ChartSync +- [x] TimeRangeSelector +- [x] IntervalSelector +- [x] ChartTypeSwitcher ## T16b Finance · Technical analysis -- [ ] IndicatorOverlay — MA, EMA, BOLL, VWAP -- [ ] IndicatorPane — MACD, RSI, KDJ, OBV -- [ ] IndicatorSelector -- [ ] DrawingTools -- [ ] DrawingToolbar -- [ ] CompareSymbol -- [ ] PriceAlertLine +- [x] IndicatorOverlay — MA, EMA, BOLL, VWAP → `CandlestickChart::overlay` with `Overlay` +- [x] IndicatorPane — MACD, RSI, KDJ, OBV → `CandlestickChart::study` with `Study` +- [x] IndicatorSelector +- [x] DrawingTools → `CandlestickChart::tool` and `drawings`: trend, level, box, Fibonacci retracement +- [x] DrawingToolbar +- [x] CompareSymbol → `CandlestickChart::compare` +- [x] PriceAlertLine → `CandlestickChart::alert` ## T16c Finance · Quotes & book -- [ ] Ticker / TickerTape -- [ ] QuoteCard -- [ ] PriceText -- [ ] PriceChangeBadge -- [ ] OrderBook -- [ ] Level2 Quotes -- [ ] TimeAndSales / TradeTape -- [ ] MarketDepthLadder / DOM -- [ ] SpreadIndicator -- [ ] BidAskBar +- [x] Ticker / TickerTape → `TickerTape` +- [x] QuoteCard +- [x] PriceText +- [x] PriceChangeBadge +- [x] OrderBook +- [x] Level2 Quotes → `Level2Quotes` +- [x] TimeAndSales / TradeTape → `TimeAndSales` +- [x] MarketDepthLadder / DOM → `DomLadder` +- [x] SpreadIndicator +- [x] BidAskBar ## T16d Finance · Trading -- [ ] OrderEntry / TradePanel -- [ ] QuickTradeButtons -- [ ] PositionTable -- [ ] OrderTable -- [ ] TradeHistoryTable -- [ ] PnLDisplay -- [ ] LeverageSlider -- [ ] MarginIndicator -- [ ] RiskMeter -- [ ] OrderConfirmDialog -- [ ] OptionChain -- [ ] GreeksTable -- [ ] PayoffDiagram +- [x] OrderEntry / TradePanel → `OrderEntry` +- [x] QuickTradeButtons +- [x] PositionTable +- [x] OrderTable +- [x] TradeHistoryTable +- [x] PnLDisplay +- [x] LeverageSlider +- [x] MarginIndicator +- [x] RiskMeter +- [x] OrderConfirmDialog +- [x] OptionChain +- [x] GreeksTable +- [x] PayoffDiagram ## T16e Finance · Markets & assets -- [ ] Watchlist -- [ ] SymbolSearch -- [ ] SymbolBadge -- [ ] MarketHeatmap -- [ ] SectorTreemap → `charts::Treemap` -- [ ] Screener / FilterPanel -- [ ] MarketOverview -- [ ] IndexCard -- [ ] MarketStatus -- [ ] TradingSessionClock -- [ ] EconomicCalendar -- [ ] EarningsCalendar -- [ ] NewsFeed -- [ ] SentimentGauge → `data_display::Gauge` -- [ ] PortfolioSummary -- [ ] AssetAllocationChart → `charts::PieChart` -- [ ] PerformanceChart — equity curve, drawdown -- [ ] DividendTable -- [ ] FinancialStatementTable -- [ ] CurrencyConverter -- [ ] CryptoWalletCard -- [ ] TransactionList -- [ ] Candle Countdown +- [x] Watchlist +- [x] SymbolSearch +- [x] SymbolBadge +- [x] MarketHeatmap +- [x] SectorTreemap → `finance::MarketHeatmap`, or `charts::Treemap` for plain weights +- [x] Screener / FilterPanel → `Screener`, its rules from `tables::FilterBuilder` +- [x] MarketOverview +- [x] IndexCard → `finance::QuoteCard`, in a row by `MarketOverview::index` +- [x] MarketStatus +- [x] TradingSessionClock +- [x] EconomicCalendar +- [x] EarningsCalendar +- [x] NewsFeed +- [x] SentimentGauge → `data_display::Gauge` +- [x] PortfolioSummary +- [x] AssetAllocationChart → `charts::PieChart` with `donut` +- [x] PerformanceChart — equity curve, drawdown +- [x] DividendTable +- [x] FinancialStatementTable +- [x] CurrencyConverter +- [x] CryptoWalletCard +- [x] TransactionList +- [x] Candle Countdown → `feedback::Countdown` ## T17a Editor · Core — `src/editor`