1use std::sync::Arc;
4
5use chrono::{DateTime, Utc};
6use egui::Ui;
7
8use crate::localization as l10n;
9use crate::services::{
10 CountAlert, CountWindow, DetectionKind, Detector, OrderFlowDetectors, Threshold,
11};
12use crate::theme::ThemeExt;
13use crate::ui_kit::PanelHeader;
14use crate::ui_kit::notifications::{Toast, Toasts};
15
16pub struct OrderFlowPanel {
21 service: Arc<OrderFlowDetectors>,
22 visible: [bool; DetectionKind::ALL.len()],
23}
24
25impl OrderFlowPanel {
26 pub fn new(service: Arc<OrderFlowDetectors>) -> Self {
28 Self {
29 service,
30 visible: [true; DetectionKind::ALL.len()],
31 }
32 }
33
34 pub fn set_service(&mut self, service: Arc<OrderFlowDetectors>) {
36 self.service = service;
37 }
38
39 pub fn poll(&mut self, ctx: &egui::Context, toasts: &mut Toasts) -> Vec<DetectionKind> {
42 let mut sounds = Vec::new();
43 for alert in self.service.take_alerts() {
44 let notification = alert.notification;
45 let detail = match alert.count {
46 Some((window, count)) => l10n::format_text(
47 ctx,
48 "{window}: {count}",
49 &[
50 ("window", &l10n::text(ctx, window.label())),
51 ("count", &count.to_string()),
52 ],
53 ),
54 None => l10n::format_text(
55 ctx,
56 "Detected at {time} UTC",
57 &[("time", notification.time.format("%H:%M:%S%.3f").to_string())],
58 ),
59 };
60 let title = l10n::format_text(
61 ctx,
62 "{symbol}: {kind}",
63 &[
64 ("symbol", notification.symbol.as_deref().unwrap_or_default()),
65 ("kind", &l10n::text(ctx, ¬ification.title)),
66 ],
67 );
68 toasts.add(Toast::info(detail).with_title(title));
69 if alert.sound {
70 sounds.push(alert.kind);
71 }
72 }
73 let extra = toasts.len().saturating_sub(5);
74 toasts.toasts_mut().drain(..extra);
75 sounds
76 }
77
78 pub fn show(
82 &mut self,
83 ui: &mut Ui,
84 symbol: &str,
85 now: DateTime<Utc>,
86 ) -> Vec<(DetectionKind, bool)> {
87 PanelHeader::new(&l10n::text(ui.ctx(), "Order flow")).show(ui, |_| {});
88 let colors = ui.active_theme().ui;
89 ui.colored_label(colors.text_secondary, symbol);
90 let mut settings = self.service.settings();
91 let before = settings.clone();
92 let visible_before = self.visible;
93 egui::ScrollArea::vertical()
94 .id_salt("order_flow_settings")
95 .show(ui, |ui| {
96 ui.push_id("big_trades", |ui| {
97 detector(ui, "Big trades", &mut settings.big_trades, |ui, rule| {
98 threshold(ui, "Size", &mut rule.threshold);
99 check(ui, "Body zones", &mut rule.body_zones);
100 check(ui, "Wick zones", &mut rule.wick_zones);
101 });
102 });
103 ui.push_id("stop_runs", |ui| {
104 detector(ui, "Stop runs", &mut settings.stop_runs, |ui, _| {
105 ui.label(l10n::text(
106 ui.ctx(),
107 "5 fills, 3 ticks, at most 20 ms apart",
108 ));
109 });
110 self.counts(
111 ui,
112 symbol,
113 DetectionKind::StopRun,
114 now,
115 &mut settings.stop_run_count_alert,
116 );
117 });
118 ui.push_id("stop_sweeps", |ui| {
119 detector(ui, "Stop sweeps", &mut settings.stop_sweeps, |ui, _| {
120 ui.label(l10n::text(
121 ui.ctx(),
122 "Closed bars: delta, imbalance, body and volume",
123 ));
124 });
125 });
126 ui.push_id("icebergs", |ui| {
127 detector(ui, "Icebergs", &mut settings.icebergs, |ui, rule| {
128 ui.horizontal_wrapped(|ui| {
129 let label = ui.label(l10n::text(ui.ctx(), "Minimum refills"));
130 ui.add(egui::DragValue::new(&mut rule.refills).range(1..=u32::MAX))
131 .labelled_by(label.id);
132 });
133 });
134 self.counts(
135 ui,
136 symbol,
137 DetectionKind::Iceberg,
138 now,
139 &mut settings.iceberg_count_alert,
140 );
141 });
142 ui.push_id("absorption", |ui| {
143 detector(ui, "Absorption", &mut settings.absorption, |ui, rule| {
144 threshold(ui, "Size", &mut rule.threshold);
145 });
146 });
147 ui.push_id("liquidity", |ui| {
148 detector(
149 ui,
150 "Resting liquidity",
151 &mut settings.liquidity,
152 |ui, rule| {
153 threshold(ui, "Size", &mut rule.threshold);
154 threshold(ui, "Fresh size", &mut rule.fresh_threshold);
155 },
156 );
157 });
158 ui.separator();
159 ui.collapsing(l10n::text(ui.ctx(), "Chart visibility"), |ui| {
160 for (index, kind) in DetectionKind::ALL.into_iter().enumerate() {
161 check(ui, kind.category(), &mut self.visible[index]);
162 }
163 });
164 });
165 if settings != before {
166 self.service.set_settings(settings);
167 }
168 DetectionKind::ALL
169 .into_iter()
170 .zip(self.visible)
171 .zip(visible_before)
172 .filter_map(|((kind, visible), before)| (visible != before).then_some((kind, visible)))
173 .collect()
174 }
175
176 fn counts(
177 &self,
178 ui: &mut Ui,
179 symbol: &str,
180 kind: DetectionKind,
181 now: DateTime<Utc>,
182 alert: &mut Option<CountAlert>,
183 ) {
184 let counts = self.service.counts(symbol, kind, now);
185 for window in [
186 CountWindow::Total,
187 CountWindow::FiveMinutes,
188 CountWindow::ThirtySeconds,
189 ] {
190 ui.label(l10n::format_text(
191 ui.ctx(),
192 "{window}: {count}",
193 &[
194 ("window", &l10n::text(ui.ctx(), window.label())),
195 ("count", &window.count(counts).to_string()),
196 ],
197 ));
198 }
199 let mut enabled = alert.is_some();
200 if check(ui, "Count alert", &mut enabled) {
201 *alert = enabled.then(CountAlert::default);
202 }
203 if let Some(alert) = alert {
204 ui.horizontal_wrapped(|ui| {
205 let label = ui.label(l10n::text(ui.ctx(), "Minimum count"));
206 ui.add(egui::DragValue::new(&mut alert.minimum).range(1..=u64::MAX))
207 .labelled_by(label.id);
208 });
209 egui::ComboBox::from_id_salt("count_window")
210 .selected_text(l10n::text(ui.ctx(), alert.window.label()))
211 .show_ui(ui, |ui| {
212 for window in [
213 CountWindow::Total,
214 CountWindow::FiveMinutes,
215 CountWindow::ThirtySeconds,
216 ] {
217 ui.selectable_value(
218 &mut alert.window,
219 window,
220 l10n::text(ui.ctx(), window.label()),
221 );
222 }
223 });
224 check(ui, "Count alert sound", &mut alert.sound);
225 }
226 }
227}
228
229fn check(ui: &mut Ui, label: &str, value: &mut bool) -> bool {
230 ui.checkbox(value, l10n::text(ui.ctx(), label)).changed()
231}
232
233fn detector<R>(
234 ui: &mut Ui,
235 name: &str,
236 detector: &mut Detector<R>,
237 rule: impl FnOnce(&mut Ui, &mut R),
238) {
239 ui.separator();
240 check(ui, name, &mut detector.enabled);
241 ui.indent("rule", |ui| {
242 rule(ui, &mut detector.rule);
243 ui.horizontal_wrapped(|ui| {
244 check(ui, "Alert", &mut detector.alert);
245 check(ui, "Sound", &mut detector.sound);
246 });
247 });
248}
249
250fn threshold(ui: &mut Ui, label: &str, threshold: &mut Threshold) {
251 ui.horizontal_wrapped(|ui| {
252 let label_response = ui.label(l10n::text(ui.ctx(), label));
253 let mut automatic = matches!(threshold, Threshold::Auto);
254 if check(ui, "Automatic", &mut automatic) {
255 *threshold = if automatic {
256 Threshold::Auto
257 } else {
258 Threshold::Fixed(1.0)
259 };
260 }
261 if let Threshold::Fixed(value) = threshold {
262 ui.add(
263 egui::DragValue::new(value)
264 .range(f64::MIN_POSITIVE..=f64::MAX)
265 .speed(0.1),
266 )
267 .labelled_by(label_response.id)
268 .on_hover_text(l10n::text(ui.ctx(), label));
269 if !value.is_finite() || *value <= 0.0 {
270 *value = 1.0;
271 }
272 }
273 });
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::data::MarketEvent;
280 use crate::model::{Instrument, OrderSide, Tick};
281 use crate::theme::{Theme, apply_to_egui};
282
283 #[test]
284 fn panel_edits_control_next_detections_and_poll_delivers_each_alert_once() {
285 for width in [220.0, 300.0] {
286 let ctx = egui::Context::default();
287 apply_to_egui(&ctx, &Theme::dark());
288 l10n::set_translator(&ctx, |key| match key {
289 "Order flow" => Some("Auftragsfluss".into()),
290 "{symbol}: {kind}" => Some("{kind} ({symbol})".into()),
291 "Detected at {time} UTC" => Some("Erkannt um {time} UTC".into()),
292 _ => None,
293 });
294 let service = Arc::new(OrderFlowDetectors::new());
295 service
296 .track(&Instrument::new("ES").with_tick_size(0.25))
297 .unwrap();
298 let mut settings = service.settings();
299 settings.big_trades.enabled = false;
300 settings.big_trades.rule.threshold = Threshold::Fixed(10.0);
301 service.set_settings(settings);
302 let now = DateTime::from_timestamp(0, 0).unwrap();
303 let mut panel = OrderFlowPanel::new(service.clone());
304 let mut draw = |events| {
305 let mut used = egui::Rect::NOTHING;
306 let output = ctx.run_ui(
307 egui::RawInput {
308 screen_rect: Some(egui::Rect::from_min_size(
309 egui::Pos2::ZERO,
310 egui::vec2(width, 900.0),
311 )),
312 events,
313 ..Default::default()
314 },
315 |ui| {
316 panel.show(ui, "ES", now);
317 used = ui.min_rect();
318 },
319 );
320 assert!(used.right() <= width, "{used:?}");
321 let texts: Vec<_> = output
322 .shapes
323 .iter()
324 .filter_map(|shape| {
325 let egui::Shape::Text(text) = &shape.shape else {
326 return None;
327 };
328 Some((text.galley.text().to_owned(), text.visual_bounding_rect()))
329 })
330 .collect();
331 output.drop_without_applying_deltas();
332 texts
333 };
334 let mut texts = draw(Vec::new());
335 assert!(texts.iter().any(|(text, _)| text == "Auftragsfluss"));
336 for label in ["Big trades", "Alert", "Sound"] {
337 let pos = texts
338 .iter()
339 .find(|(text, _)| text == label)
340 .unwrap()
341 .1
342 .center();
343 texts = draw(vec![
344 egui::Event::PointerMoved(pos),
345 egui::Event::PointerButton {
346 pos,
347 button: egui::PointerButton::Primary,
348 pressed: true,
349 modifiers: egui::Modifiers::NONE,
350 },
351 egui::Event::PointerButton {
352 pos,
353 button: egui::PointerButton::Primary,
354 pressed: false,
355 modifiers: egui::Modifiers::NONE,
356 },
357 ]);
358 }
359 assert!(service.settings().big_trades.enabled);
360 assert!(service.settings().big_trades.alert);
361 assert!(service.settings().big_trades.sound);
362 for second in 1..=6 {
363 let time = now + chrono::TimeDelta::seconds(second);
364 service.push(
365 "ES",
366 time,
367 &MarketEvent::Trade(Tick::new(time, 100.0, 10.0, Some(OrderSide::Buy))),
368 );
369 }
370 let mut toasts = Toasts::new();
371 let sounds = panel.poll(&ctx, &mut toasts);
372 assert_eq!(sounds, [DetectionKind::BigTrade; 6]);
373 assert_eq!(toasts.len(), 5);
374 assert!(
375 toasts.toasts().iter().all(|toast| toast
376 .title
377 .as_deref()
378 .unwrap()
379 .ends_with("(ES)"))
380 );
381 assert!(
382 toasts
383 .toasts()
384 .iter()
385 .all(|toast| toast.message.starts_with("Erkannt um "))
386 );
387 assert!(panel.poll(&ctx, &mut toasts).is_empty());
388 assert_eq!(toasts.len(), 5);
389 }
390 }
391}