1use crate::scales::{CurrencyFormatter, PriceFormatter};
7use egui::text::TextFormat;
8use egui::{Color32, FontId};
9
10pub mod alerts_panel;
11pub mod calendar;
12pub mod chats;
13mod controls;
14pub mod hotlists;
15pub mod market_details;
16pub mod market_heatmap;
17pub mod news;
18pub mod notifications;
19pub mod object_tree;
20#[cfg(feature = "detectors")]
21pub mod order_flow;
22pub mod products;
23pub mod screener;
24pub mod symbol_details;
25pub mod time_and_sales;
26pub mod watchlist;
27
28pub use alerts_panel::*;
29pub use calendar::*;
30pub use chats::*;
31pub use hotlists::*;
32pub use market_details::*;
33pub use market_heatmap::*;
34pub use news::*;
35pub use notifications::*;
36pub use object_tree::*;
37#[cfg(feature = "detectors")]
38pub use order_flow::*;
39pub use products::*;
40pub use screener::*;
41pub use symbol_details::*;
42pub use time_and_sales::*;
43pub use watchlist::*;
44
45const ROW_HEIGHT: f32 = 26.0;
47
48const HEADER_HEIGHT: f32 = 40.0;
50
51fn weighted(size: f32, weight: f32, color: Color32) -> TextFormat {
53 let mut format = TextFormat::simple(FontId::proportional(size), color);
54 format.coords.push("wght", weight);
55 format
56}
57
58fn signed(value: f64, decimals: usize) -> String {
60 let text = grouped(value, decimals);
61 if value > 0.0 && text.bytes().any(|b| matches!(b, b'1'..=b'9')) {
62 format!("+{text}")
63 } else {
64 text
65 }
66}
67
68fn grouped(value: f64, decimals: usize) -> String {
70 if !value.is_finite() {
71 return "-".to_owned();
72 }
73 CurrencyFormatter::new("", decimals).format(value)
74}
75
76fn format_price(price: f64, decimals: Option<u8>) -> String {
79 if !price.is_finite() {
80 return "\u{2014}".to_owned();
81 }
82 let decimals = decimals.unwrap_or_else(|| price_decimals(price));
83 format!("{:.*}", usize::from(decimals), price)
84}
85
86fn price_decimals(price: f64) -> u8 {
88 match price.abs() {
89 m if m >= 10000.0 => 0,
90 m if m >= 1000.0 => 1,
91 m if m >= 10.0 => 2,
92 m if m >= 1.0 => 4,
93 m if m >= 0.001 => 6,
94 _ => 8,
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::data::{
102 Datafeed, FeedError, HistoryPage, HistoryRequest, HotlistEntry, HotlistQuery, Reply,
103 ScreenerColumn, ScreenerColumnSet, ScreenerQuery, ScreenerRow, SymbolDetails,
104 };
105 use crate::model::{Instrument, OrderSide, Quote, Tick};
106 use crate::theme::{Theme, apply_to_egui};
107
108 struct Feed;
109 impl Datafeed for Feed {
110 fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
111 reply.send(Err(FeedError::NotSupported));
112 }
113 fn hotlist(&self, _: HotlistQuery, reply: Reply<Vec<HotlistEntry>>) {
114 reply.send(Ok(vec![
115 HotlistEntry::new("AAA", 123.45, 12.34),
116 HotlistEntry::new("B", 10.0, 0.0),
117 ]));
118 }
119 fn screen(&self, _: ScreenerQuery, reply: Reply<Vec<ScreenerRow>>) {
120 reply.send(Ok(vec![
121 ScreenerRow::new("AAA", vec!["123.45".into()]),
122 ScreenerRow::new("B", vec!["10.00".into()]),
123 ]));
124 }
125 fn symbol_details(&self, _: &str, reply: Reply<SymbolDetails>) {
126 reply.send(Ok(SymbolDetails::new(
127 vec![("Market capitalization".into(), "2.95T USD".into())],
128 vec![("Sector".into(), "Technology".into())],
129 )
130 .with_category("Software")));
131 }
132 }
133
134 #[test]
135 fn market_panel_cells_stay_separate_in_narrow_windows() {
136 for theme in [Theme::light(), Theme::dark()] {
137 for width in [220.0, 280.0] {
138 for panel in [
139 "watchlist",
140 "details",
141 "tape",
142 "tape_combined",
143 "hotlist",
144 "screener",
145 "news",
146 ] {
147 let tape = matches!(panel, "tape" | "tape_combined");
148 let ctx = egui::Context::default();
149 apply_to_egui(&ctx, &theme);
150 let symbol = "EXCHANGE:LONG_SYMBOL_NAME";
151 let mut watchlist = WatchlistPanel::new(vec![Watchlist::from_text(
152 "A long watchlist name that must leave room for its menu",
153 &format!("{symbol},A"),
154 )]);
155 let time = Default::default();
156 watchlist.set_quote(symbol, Quote::new(123456.789, 100000.0, time));
157 watchlist.set_price_decimals(symbol, 3);
158 watchlist.set_quote("A", Quote::new(10.0, 10.0, time));
159 watchlist.set_price_decimals("A", 2);
160 watchlist.set_mark(
161 "A",
162 egui::load::SizedTexture::new(
163 egui::TextureId::User(1),
164 egui::Vec2::splat(18.0),
165 )
166 .into(),
167 );
168 let mut details = SymbolDetailsPanel::new();
169 details.set_instrument(
170 &Instrument::new(symbol)
171 .with_description("A long description supplied by the feed")
172 .with_price_decimals(3),
173 );
174 details.set_quote(Quote::new(123456.789, 100000.0, time));
175 details.set_feed(std::sync::Arc::new(Feed));
176 details.poll(&ctx);
177 let mut trades = TimeAndSalesPanel::default();
178 trades.combine_fills = panel == "tape_combined";
179 trades.config.price_decimals = Some(2);
180 trades.push(Tick::new(
181 "2026-10-23T12:34:55Z".parse().unwrap(),
182 2.0,
183 3.0,
184 None,
185 ));
186 trades.push(Tick::new(
187 "2026-10-23T12:34:56Z".parse().unwrap(),
188 123456789.12,
189 123456.0,
190 Some(OrderSide::Buy),
191 ));
192 if trades.combine_fills {
193 trades.push(Tick::new(
194 "2026-10-23T12:34:56Z".parse().unwrap(),
195 123456789.12,
196 0.0,
197 Some(OrderSide::Buy),
198 ));
199 }
200 trades.filters = vec![
201 TradeSizeFilter {
202 min_size: 1.0,
203 sound: false
204 };
205 20
206 ];
207 let mut hotlist = HotlistsPanel::new(vec!["Gainers".into()]);
208 hotlist.set_feed(std::sync::Arc::new(Feed));
209 hotlist.poll(&ctx);
210 let mut screener = ScreenerPanel::new(vec![ScreenerColumnSet::new(
211 "Overview",
212 vec![ScreenerColumn::new("last", "Last")],
213 )]);
214 screener.set_feed(std::sync::Arc::new(Feed));
215 screener.poll(&ctx);
216 let mut news = NewsPanel::new();
217 news.set_symbol(symbol);
218 let mut used = egui::Rect::NOTHING;
219 let output = ctx.run_ui(
220 egui::RawInput {
221 screen_rect: Some(egui::Rect::from_min_size(
222 egui::Pos2::ZERO,
223 egui::vec2(width, 300.0),
224 )),
225 ..Default::default()
226 },
227 |ui| {
228 if panel == "details" {
229 details.show(ui);
230 } else if panel == "news" {
231 news.show(ui);
232 } else if panel == "hotlist" {
233 hotlist.show(ui);
234 } else if panel == "screener" {
235 screener.show(ui);
236 } else if tape {
237 trades.show(ui);
238 } else {
239 watchlist.show(ui);
240 }
241 used = ui.min_rect();
242 },
243 );
244 let texts: Vec<_> = output
245 .shapes
246 .iter()
247 .filter_map(|shape| {
248 let egui::Shape::Text(text) = &shape.shape else {
249 return None;
250 };
251 let rect = text.visual_bounding_rect().intersect(shape.clip_rect);
252 (rect.is_positive()).then(|| (text.galley.text().to_owned(), rect))
253 })
254 .collect();
255 output.drop_without_applying_deltas();
256 assert!(
257 used.right() <= width,
258 "{panel} expanded beyond {width}: {used:?}"
259 );
260 if panel == "details" {
261 for label in ["Software", "Key statistics", "2.95T USD", "Profile"] {
262 assert!(texts.iter().any(|(text, _)| text == label), "{texts:?}");
263 }
264 continue;
265 }
266 if panel == "news" {
267 assert!(texts.iter().any(|(text, _)| text == "Market"), "{texts:?}");
268 continue;
269 }
270 if matches!(panel, "hotlist" | "screener") {
271 let bounds = |label: &str| {
272 texts
273 .iter()
274 .rev()
275 .find(|(text, _)| text == label)
276 .unwrap_or_else(|| panic!("{panel}: missing {label}: {texts:?}"))
277 .1
278 };
279 for label in ["123.45", "10.00"] {
280 assert!(
281 (bounds(label).right() - bounds("Last").right()).abs() < 2.0,
282 "{panel}: {texts:?}"
283 );
284 }
285 assert!(
286 (bounds("B").center().y - bounds("AAA").center().y - 26.0).abs() < 1.0,
287 "{panel}: rows must have watchlist height: {texts:?}"
288 );
289 assert!(!bounds("AAA").intersects(bounds("123.45")), "{texts:?}");
290 continue;
291 }
292 let row: Vec<_> = texts
293 .iter()
294 .filter(|(text, _)| {
295 if tape {
296 matches!(text.as_str(), "12:34:56" | "123456789.12" | "123.46K")
297 || (panel == "tape_combined" && text == "2")
298 } else {
299 [symbol, "123,456.789", "+23,456.789", "+23.46%"]
300 .contains(&text.as_str())
301 }
302 })
303 .collect();
304 assert_eq!(row.len(), if panel == "tape" { 3 } else { 4 }, "{texts:?}");
305 let bounds = |label: &str| {
307 texts
308 .iter()
309 .rev()
310 .find(|(text, _)| text == label)
311 .unwrap()
312 .1
313 };
314 if !tape {
315 assert!((bounds("A").left() - 34.0).abs() < 2.0, "{texts:?}");
317 assert!(
318 (bounds("A").center().y - bounds(symbol).center().y - 30.0).abs() < 1.0,
319 "{texts:?}"
320 );
321 }
322 let (value, heading) = if tape {
323 ("2.00", "Price")
324 } else {
325 ("10.00", "Last")
326 };
327 assert!(
328 (bounds(value).right() - bounds(heading).right()).abs() < 2.0,
329 "{texts:?}"
330 );
331 for (i, (_, rect)) in row.iter().enumerate() {
332 assert!(rect.left() >= 0.0 && rect.right() <= width, "{row:?}");
333 for (_, other) in &row[i + 1..] {
334 assert!(!rect.intersects(*other), "{row:?}");
335 }
336 }
337 }
338 }
339 }
340 }
341
342 #[test]
343 fn panel_numbers_group_digits_and_omit_signs_at_rounded_zero() {
344 for (value, decimals, price, change) in [
345 (43237.45, 2, "43,237.45", "+43,237.45"),
346 (-1234567.0, 0, "-1,234,567", "-1,234,567"),
347 (863.83, 2, "863.83", "+863.83"),
348 (100000.5, 1, "100,000.5", "+100,000.5"),
349 (0.000123, 6, "0.000123", "+0.000123"),
350 (-0.0, 2, "0.00", "0.00"),
351 (-0.001, 2, "0.00", "0.00"),
352 (0.001, 2, "0.00", "0.00"),
353 (f64::INFINITY, 2, "-", "-"),
354 (f64::NAN, 2, "-", "-"),
355 ] {
356 assert_eq!(grouped(value, decimals), price);
357 assert_eq!(signed(value, decimals), change);
358 }
359 }
360
361 #[test]
362 fn test_format_price() {
363 assert_eq!(format_price(0.0, None), "0.00000000");
364 assert_eq!(format_price(f64::NAN, Some(2)), "\u{2014}");
365 assert_eq!(format_price(50000.0, None), "50000");
366 assert_eq!(format_price(1234.5, None), "1234.5");
367 assert_eq!(format_price(99.99, None), "99.99");
368 assert_eq!(format_price(1.2345, None), "1.2345");
369 assert_eq!(format_price(0.001234, None), "0.001234");
370 assert_eq!(format_price(-37.63, None), "-37.63");
371 for (price, decimals, expected) in [
372 (50000.25, 2, "50000.25"),
373 (-37.625, 3, "-37.625"),
374 (0.0, 2, "0.00"),
375 (1.2345, 4, "1.2345"),
376 ] {
377 assert_eq!(format_price(price, Some(decimals)), expected);
378 }
379 }
380}