T16 Finance: market charts with kinds, volume, profile, studies, drawings, compare, alerts and sync (T16a–b); quotes, book, tape and DOM (T16c); order entry, blotter, risk, options and payoff (T16d); markets, calendars, portfolio and wallet (T16e); typography::Ellipsis ends lines that gpui's truncate clipped; chart notes stack and ticks label round values
This commit is contained in:
76 files changed
+10311
-173
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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::<f64>() + 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<f64>, Vec<f64>) = 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),
|
||||
))),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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::<f64>(),
|
||||
asks.iter().map(|level| level.1).sum::<f64>(),
|
||||
);
|
||||
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<Trade> = (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::<SharedString>, 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}.")))),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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<Candle> {
|
||||
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::<gpui::Entity<ChartSync>>,
|
||||
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)))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Watch> {
|
||||
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<f64>, Option<f64>, Option<f64>),
|
||||
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::<usize>, 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)))),
|
||||
)
|
||||
}
|
||||
@@ -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::<SharedString>, 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::<SharedString>, 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)),
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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<Row> {
|
||||
let mut next = noise(7);
|
||||
(0..24)
|
||||
|
||||
@@ -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<> {
|
||||
|
||||
@@ -94,3 +94,14 @@ pub fn set<T: 'static>(state: &Entity<T>, 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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user