1use crate::chart::coords::ChartMapping;
4use crate::data::{
5 OrderFilter, OrderFilterUnavailable, OrderQueue, OrderSizeTier, OrderSizeTiers, RestingOrder,
6};
7use crate::model::{OrderBook, OrderSide};
8use crate::theme::{ChartColors, ThemeExt};
9use egui::{Color32, FontId, Painter, Rect, Stroke, StrokeKind, pos2, vec2};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub enum BookProfile {
16 #[default]
18 Total,
19 LargestOrder,
21}
22
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
29#[serde(default)]
30pub struct OrderBookView {
31 pub filter: OrderFilter,
34 pub profile: BookProfile,
36 pub quantity_unit: Option<String>,
40 pub tiers: Option<OrderSizeTiers>,
45 pub tier_colors: Option<[Color32; 2]>,
49}
50
51impl OrderBookView {
52 fn needs_orders(&self) -> bool {
54 self.filter != OrderFilter::default() || self.profile == BookProfile::LargestOrder
55 }
56
57 pub fn tier_color(&self, tier: OrderSizeTier, colors: &ChartColors) -> Color32 {
60 let [notable, large] = self
61 .tier_colors
62 .unwrap_or([colors.order_notable, colors.order_large]);
63 if tier == OrderSizeTier::Large {
64 large
65 } else {
66 notable
67 }
68 }
69}
70
71pub fn render_book_profile(
81 painter: &Painter,
82 mapping: &ChartMapping,
83 book: Option<&OrderBook>,
84 queue: Option<&OrderQueue>,
85 tick: f64,
86 view: &OrderBookView,
87 colors: [Color32; 2],
88) -> Result<(), OrderFilterUnavailable> {
89 if !tick.is_finite() || tick <= 0.0 {
90 return Ok(());
91 }
92 let level = |price: f64| (price / tick).round() as i64;
93 let mut bars: Vec<(f64, bool, f64, Option<OrderSizeTier>)> = Vec::new();
95 match (queue, book) {
96 (Some(queue), _) => {
97 let summed = queue.filtered("", &view.filter)?;
98 let largest = queue.largest_orders(&view.filter);
99 let tier = |order: &RestingOrder| view.tiers.and_then(|tiers| tiers.tier(order.size));
100 let tiers: BTreeMap<_, _> = largest
101 .iter()
102 .map(|o| ((o.side == OrderSide::Buy, level(o.price)), tier(o)))
103 .collect();
104 match view.profile {
105 BookProfile::Total => {
106 for (bid, levels) in [(true, &summed.bids), (false, &summed.asks)] {
107 for l in levels {
108 let at = tiers.get(&(bid, level(l.price))).copied().flatten();
109 bars.push((l.price, bid, l.size, at));
110 }
111 }
112 }
113 BookProfile::LargestOrder => bars.extend(
114 largest
115 .iter()
116 .map(|o| (o.price, o.side == OrderSide::Buy, o.size, tier(o))),
117 ),
118 }
119 }
120 (None, _) if view.needs_orders() => return Err(OrderFilterUnavailable::AggregateDepth),
121 (None, Some(book)) => {
122 for (bid, levels) in [(true, &book.bids), (false, &book.asks)] {
123 bars.extend(levels.iter().map(|l| (l.price, bid, l.size, None)));
124 }
125 }
126 (None, None) => {}
127 }
128 let max = bars.iter().map(|b| b.2).fold(0.0, f64::max);
129 if max <= 0.0 {
130 return Ok(());
131 }
132 let theme = painter.ctx().active_theme();
133 let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
134 let pointer = painter
135 .ctx()
136 .pointer_hover_pos()
137 .filter(|p| painter.clip_rect().contains(*p) && p.x >= mapping.rect.right() - 60.0);
138 let mut hovered = None;
139 for (price, bid, size, tier) in bars {
140 let color = tier.map_or(colors[usize::from(!bid)], |tier| {
141 view.tier_color(tier, &theme.chart)
142 });
143 let (y0, y1) = (
144 mapping.price_to_y(price - tick * 0.5),
145 mapping.price_to_y(price + tick * 0.5),
146 );
147 let rect = Rect::from_min_max(
148 pos2(
149 mapping.rect.right() - 60.0 * (size / max) as f32,
150 y0.min(y1),
151 ),
152 pos2(mapping.rect.right(), y0.max(y1)),
153 );
154 if rect.is_finite() && rect.intersects(mapping.rect) {
155 painter.rect_filled(rect, 0.0, color.gamma_multiply(0.7));
156 if pointer.is_some_and(|p| rect.y_range().contains(p.y)) {
157 hovered = Some((price, bid, size));
158 painter.rect_stroke(
159 rect,
160 0.0,
161 Stroke::new(1.0, theme.chart.axis_text),
162 StrokeKind::Inside,
163 );
164 }
165 }
166 }
167 let ctx = painter.ctx();
168 let unit = view
169 .quantity_unit
170 .as_deref()
171 .map(str::trim)
172 .filter(|unit| !unit.is_empty())
173 .map_or_else(
174 || crate::localization::text(ctx, "feed units"),
175 str::to_owned,
176 );
177 let measure = crate::localization::text(
178 ctx,
179 match view.profile {
180 BookProfile::Total => "Resting total",
181 BookProfile::LargestOrder => "Largest resting order",
182 },
183 );
184 let key = crate::localization::format_text(
185 ctx,
186 "{measure} ({unit})\nFull width = {max} {unit}\nAll supplied levels, after filtering",
187 &[
188 ("measure", measure.as_str()),
189 ("unit", &unit),
190 ("max", &max.to_string()),
191 ],
192 );
193 let parent = painter.layer_id();
195 let layer = egui::LayerId::new(parent.order, parent.id.with("book_profile_readout"));
196 ctx.set_sublayer(parent, layer);
197 let painter = painter.clone().with_layer_id(layer);
198 let label = |text: String, bottom: f32| {
199 let left = painter.clip_rect().left() + 4.0;
200 let right = (mapping.rect.right() - 64.0).min(painter.clip_rect().right() - 4.0);
201 let galley = painter.layout(
202 text,
203 FontId::proportional(11.0),
204 theme.ui.text,
205 (right - left - 12.0).max(1.0),
206 );
207 let size = galley.size() + vec2(12.0, 8.0);
208 let at = pos2(
209 (right - size.x).max(left),
210 (bottom - size.y).max(painter.clip_rect().top() + 4.0),
211 );
212 let rect = Rect::from_min_size(at, size);
213 painter.rect_filled(rect, 3.0, theme.ui.panel_bg_floating);
214 painter.rect_stroke(
215 rect,
216 3.0,
217 Stroke::new(1.0, theme.ui.border),
218 StrokeKind::Inside,
219 );
220 painter.galley(at + vec2(6.0, 4.0), galley, theme.ui.text);
221 rect
222 };
223 let key_rect = label(key, painter.clip_rect().bottom() - 4.0);
224 if let Some((price, bid, size)) = hovered {
225 let text = crate::localization::format_text(
226 ctx,
227 "{side} · Price {price}\n{measure}: {size} {unit}",
228 &[
229 (
230 "side",
231 crate::localization::text(ctx, if bid { "Bid" } else { "Ask" }),
232 ),
233 ("price", price.to_string()),
234 ("measure", measure),
235 ("size", size.to_string()),
236 ("unit", unit),
237 ],
238 );
239 label(
240 text,
241 (mapping.price_to_y(price) - 4.0).min(key_rect.top() - 4.0),
242 );
243 }
244 Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::model::OrderBookLevel;
251 use crate::scales::{PriceMap, PriceRange, PriceTransform};
252 use egui::epaint::Shape;
253
254 #[test]
260 fn bars_follow_the_measure_and_highlight_the_tier_of_the_largest_order() {
261 let time = "2026-01-05T10:00:00Z".parse().unwrap();
262 let mut queue = OrderQueue::new(Some(0.25));
263 let orders = [(1, 99.0, 3.0), (2, 99.0, 9.0), (3, 98.75, 2.0)]
264 .map(|(id, price, size)| RestingOrder::new(id, OrderSide::Buy, price, size));
265 queue.snapshot(1, time, orders.to_vec()).unwrap();
266 let aggregate = OrderBook {
267 ts: time,
268 symbol: "A".into(),
269 bids: vec![OrderBookLevel {
270 price: 99.0,
271 size: 12.0,
272 order_cnt: None,
273 }],
274 asks: vec![],
275 };
276 let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0));
277 let map = ChartMapping::new(
278 rect,
279 10.0,
280 0,
281 0,
282 0.0,
283 PriceMap::new(
284 PriceTransform::default(),
285 PriceRange::new(98.0, 100.0),
286 0.0,
287 200.0,
288 false,
289 ),
290 );
291 let (bid, ask) = (Color32::GREEN, Color32::RED);
292 let view = |profile| OrderBookView {
293 profile,
294 tiers: Some(OrderSizeTiers {
295 notable: 5.0,
296 large: 10.0,
297 }),
298 ..Default::default()
299 };
300 let notable = view(BookProfile::Total)
301 .tier_color(OrderSizeTier::Notable, &crate::theme::Theme::dark().chart);
302 let draw = |view: &OrderBookView, queue, book| {
303 let ctx = egui::Context::default();
304 let mut result = Ok(());
305 let mut output = ctx.run_ui(
306 egui::RawInput {
307 screen_rect: Some(rect),
308 ..Default::default()
309 },
310 |ui| {
311 result = render_book_profile(
312 ui.painter(),
313 &map,
314 book,
315 queue,
316 0.25,
317 view,
318 [bid, ask],
319 );
320 },
321 );
322 output.textures_delta.clear();
323 let bars = Vec::from_iter(output.shapes.iter().filter_map(|s| {
324 match &s.shape {
325 Shape::Rect(r)
326 if [bid, notable]
327 .map(|c| c.gamma_multiply(0.7))
328 .contains(&r.fill) =>
329 {
330 Some((r.rect.width().round(), r.fill))
331 }
332 _ => None,
333 }
334 }));
335 (result, bars)
336 };
337 let [bid_fill, tier_fill] = [bid, notable].map(|c| c.gamma_multiply(0.7));
338 let (result, bars) = draw(&view(BookProfile::Total), Some(&queue), None);
340 assert_eq!(result, Ok(()));
341 assert_eq!(bars, [(60.0, tier_fill), (10.0, bid_fill)]);
342 let (_, bars) = draw(&view(BookProfile::LargestOrder), Some(&queue), None);
344 assert_eq!(bars, [(60.0, tier_fill), (13.0, bid_fill)]);
345 let (result, bars) = draw(&view(BookProfile::Total), None, Some(&aggregate));
348 assert_eq!((result, bars), (Ok(()), vec![(60.0, bid_fill)]));
349 let (result, bars) = draw(&view(BookProfile::LargestOrder), None, Some(&aggregate));
350 assert_eq!(
351 (result, bars),
352 (Err(OrderFilterUnavailable::AggregateDepth), vec![])
353 );
354 }
355
356 #[test]
360 fn readout_quantifies_the_full_book_and_the_hovered_row() {
361 let time = "2026-01-05T10:00:00Z".parse().unwrap();
362 let mut queue = OrderQueue::new(Some(0.25));
363 queue
364 .snapshot(
365 1,
366 time,
367 vec![
368 RestingOrder::new(1, OrderSide::Buy, 99.75, 25.125),
369 RestingOrder::new(2, OrderSide::Sell, 101.25, 600.0),
370 RestingOrder::new(3, OrderSide::Sell, 101.25, 400.0),
371 RestingOrder::new(4, OrderSide::Sell, 120.0, 2000.0),
372 ],
373 )
374 .unwrap();
375 let book = queue.filtered("A", &OrderFilter::default()).unwrap();
376 for width in [200.0, 800.0, 1440.0] {
377 let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(width, 600.0));
378 let map = ChartMapping::new(
379 rect,
380 10.0,
381 0,
382 0,
383 0.0,
384 PriceMap::new(
385 PriceTransform::default(),
386 PriceRange::new(99.0, 102.0),
387 0.0,
388 600.0,
389 false,
390 ),
391 );
392 for theme in [crate::theme::Theme::dark(), crate::theme::Theme::light()] {
393 for (profile, unit, orders, minimum, price, from_right, measure, size, side) in [
394 (
395 BookProfile::Total,
396 Some("lots"),
397 false,
398 0.0,
399 101.25,
400 50.0,
401 "Resting total",
402 "1000",
403 "Ask",
404 ),
405 (
406 BookProfile::LargestOrder,
407 Some("lots"),
408 true,
409 0.0,
410 101.25,
411 50.0,
412 "Largest resting order",
413 "600",
414 "Ask",
415 ),
416 (
417 BookProfile::Total,
418 Some("lots"),
419 true,
420 500.0,
421 101.25,
422 50.0,
423 "Resting total",
424 "600",
425 "Ask",
426 ),
427 (
428 BookProfile::Total,
429 None,
430 false,
431 0.0,
432 99.75,
433 50.0,
434 "Resting total",
435 "25.125",
436 "Bid",
437 ),
438 (
439 BookProfile::Total,
440 Some(" "),
441 false,
442 0.0,
443 101.25,
444 50.0,
445 "Resting total",
446 "1000",
447 "Ask",
448 ),
449 (
450 BookProfile::Total,
451 Some("lots"),
452 false,
453 0.0,
454 101.25,
455 100.0,
456 "Resting total",
457 "1000",
458 "Ask",
459 ),
460 ] {
461 let mut view: OrderBookView = serde_json::from_value(serde_json::json!({
463 "quantity_unit": unit,
464 }))
465 .unwrap();
466 view.profile = profile;
467 view.filter.min_size = minimum;
468 let ctx = egui::Context::default();
469 crate::theme::apply_to_egui(&ctx, &theme);
470 let mut output = ctx.run_ui(
471 egui::RawInput {
472 screen_rect: Some(rect),
473 events: vec![egui::Event::PointerMoved(pos2(
474 width - from_right,
475 map.price_to_y(price),
476 ))],
477 ..Default::default()
478 },
479 |ui| {
480 render_book_profile(
481 ui.painter(),
482 &map,
483 Some(&book),
484 orders.then_some(&queue),
485 0.25,
486 &view,
487 [Color32::GREEN, Color32::RED],
488 )
489 .unwrap();
490 },
491 );
492 output.textures_delta.clear();
493 for clipped in &output.shapes {
494 if let Shape::Text(text) = &clipped.shape {
495 let bounds = text.visual_bounding_rect();
496 assert!(
497 clipped.clip_rect.contains_rect(bounds),
498 "clipped readout: {bounds:?}"
499 );
500 assert!(
501 bounds.right() <= rect.right() - 64.0,
502 "readout overlaps the strip: {bounds:?}"
503 );
504 }
505 }
506 let texts: Vec<_> = output
507 .shapes
508 .iter()
509 .filter_map(|s| match &s.shape {
510 Shape::Text(t) => Some(t.galley.text()),
511 _ => None,
512 })
513 .collect();
514 let unit = unit
515 .filter(|unit| !unit.trim().is_empty())
516 .unwrap_or("feed units");
517 assert!(texts.contains(&format!("{measure} ({unit})\nFull width = 2000 {unit}\nAll supplied levels, after filtering").as_str()), "{texts:?}");
518 let hover = format!("{side} · Price {price}\n{measure}: {size} {unit}");
519 assert_eq!(
520 texts.contains(&hover.as_str()),
521 from_right <= 60.0,
522 "{texts:?}"
523 );
524 if price == 101.25 {
527 let width = 60.0 * size.parse::<f32>().unwrap() / 2000.0;
528 assert!(output.shapes.iter().any(|s| matches!(&s.shape,
529 Shape::Rect(r) if r.rect.contains(pos2(rect.right() - 1.0, map.price_to_y(price)))
530 && (r.rect.width() - width).abs() < 0.001
531 )));
532 }
533 }
534 }
535 }
536 }
537}