Skip to main content

egui_charts/ui/widget_bar/panels/
order_flow.rs

1//! Settings, feed-time counts and notifications for the order-flow detector service.
2
3use 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
16/// A panel connected to [`OrderFlowDetectors`], available with `ui` and `detectors`.
17/// Call [`poll`](Self::poll) every frame even while hidden. The host feeds the service, applies
18/// [`show`](Self::show)'s visibility changes to its chart, renders the shared toasts and plays
19/// the sounds requested by `poll`.
20pub struct OrderFlowPanel {
21    service: Arc<OrderFlowDetectors>,
22    visible: [bool; DetectionKind::ALL.len()],
23}
24
25impl OrderFlowPanel {
26    /// Connect to the same detector service used as the chart's signal source.
27    pub fn new(service: Arc<OrderFlowDetectors>) -> Self {
28        Self {
29            service,
30            visible: [true; DetectionKind::ALL.len()],
31        }
32    }
33
34    /// Follow a different chart's service while retaining the panel's visibility choices.
35    pub fn set_service(&mut self, service: Arc<OrderFlowDetectors>) {
36        self.service = service;
37    }
38
39    /// Drain live alerts once, append one toast per alert and keep at most five on screen.
40    /// Returns each kind whose alert requested a sound, in delivery order; the host plays it.
41    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, &notification.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    /// Draw settings and counts for the current symbol at `now`, its current feed time.
79    /// Changes take effect through `set_settings` on the next events. Return category visibility
80    /// changes for the host to apply with [`Chart::set_signal_category_visible`](crate::widget::Chart::set_signal_category_visible).
81    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}