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"); } }