1use super::{
7 actions::IndicatorDialogAction,
8 config::IndicatorDialogConfig,
9 data::{IndicatorInfo, IndicatorPreferences},
10 types::{IndicatorCategory, IndicatorTab},
11};
12use crate::data::inbox::Inbox;
13use crate::data::{Datafeed, FeedCapabilities, FeedError, MetricSeries};
14use crate::icons::icons;
15use crate::localization as l10n;
16use crate::model::Instrument;
17use crate::studies::*;
18use crate::styles::focus::draw_focus_ring;
19use crate::theme::Theme;
20use crate::theme::ThemeExt;
21use crate::tokens::DESIGN_TOKENS;
22use crate::ui_kit::EmptyState;
23use crate::ui_kit::filter::FilterWidget;
24use crate::ui_kit::modal::Modal;
25use crate::ui_kit::sidebar_layout::{SidebarLayout, SidebarTab};
26use crate::ui_kit::tab_bar::TabBar;
27use egui::text::{LayoutJob, TextWrapping};
28use egui::{
29 Align2, Color32, FontId, Key, Modifiers, Pos2, Rect, Response, RichText, Role, Sense, Stroke,
30 StrokeKind, Ui, Vec2, WidgetInfo,
31};
32use std::cell::Cell;
33use std::collections::HashSet;
34use std::sync::Arc;
35
36const SIDEBAR_WIDTH: f32 = 200.0;
38
39pub struct IndicatorDialog {
47 pub is_open: bool,
49 search: FilterWidget,
51 focus_search: bool,
53 focus_first_row: bool,
55 active_tab: IndicatorTab,
57 sel_category: IndicatorCategory,
59 pub favorites: HashSet<String>,
61 factory: IndicatorFactory,
63 feed_capabilities: Option<(Option<FeedCapabilities>, Option<Instrument>)>,
64 unavailable_order_flow: HashSet<String>,
65 metric_source: Option<(Arc<dyn Datafeed>, String)>,
66 metric_catalog: Option<Arc<Inbox>>,
67 metric_names: Vec<String>,
68 metric_error: Option<FeedError>,
69 source: Option<StudySource>,
70 source_error: Option<StudyError>,
71 full: bool,
72 indicators: Vec<IndicatorInfo>,
74 config: IndicatorDialogConfig,
76}
77
78impl Default for IndicatorDialog {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl IndicatorDialog {
85 pub fn set_feed_capabilities(
91 &mut self,
92 capabilities: Option<&FeedCapabilities>,
93 instrument: Option<&Instrument>,
94 ) {
95 if self
96 .feed_capabilities
97 .as_ref()
98 .is_some_and(|(previous, symbol)| {
99 previous.as_ref() == capabilities && symbol.as_ref() == instrument
100 })
101 {
102 return;
103 }
104 self.feed_capabilities = Some((capabilities.cloned(), instrument.cloned()));
105 self.refresh_order_flow_availability();
106 }
107
108 fn refresh_order_flow_availability(&mut self) {
109 self.unavailable_order_flow.clear();
110 let Some((capabilities, instrument)) = &self.feed_capabilities else {
111 return;
112 };
113 for ind in &self.indicators {
114 if ind.category != IndicatorFactory::ORDER_FLOW {
115 continue;
116 }
117 let available = self.factory.create(&ind.id).is_some_and(|study| {
118 study.order_flow_inputs().iter().all(|input| {
119 input.services().iter().all(|service| {
120 capabilities.as_ref().zip(instrument.as_ref()).is_some_and(
121 |(capabilities, instrument)| {
122 capabilities.declares(*service, instrument)
123 },
124 )
125 })
126 })
127 });
128 if !available {
129 self.unavailable_order_flow.insert(ind.id.clone());
130 }
131 }
132 }
133
134 pub fn preferences(&self) -> IndicatorPreferences {
136 IndicatorPreferences {
137 favorites: self.favorites.iter().cloned().collect(),
138 }
139 }
140
141 pub fn set_preferences(&mut self, preferences: IndicatorPreferences) {
143 self.favorites = preferences.favorites.into_iter().collect();
144 }
145
146 pub fn new() -> Self {
148 let factory = IndicatorFactory::new();
149 Self {
150 is_open: false,
151 search: FilterWidget::new()
152 .placeholder("Search indicators")
153 .icon_size(DESIGN_TOKENS.sizing.icon_md),
154 focus_search: false,
155 focus_first_row: false,
156 active_tab: IndicatorTab::Indicators,
157 sel_category: IndicatorCategory::BuiltIns,
158 favorites: HashSet::new(),
159 indicators: IndicatorInfo::from_factory(&factory),
160 factory,
161 feed_capabilities: None,
162 unavailable_order_flow: HashSet::new(),
163 metric_source: None,
164 metric_catalog: None,
165 metric_names: Vec::new(),
166 metric_error: None,
167 source: None,
168 source_error: None,
169 full: false,
170 config: IndicatorDialogConfig::default(),
171 }
172 }
173
174 pub fn with_factory(mut self, factory: IndicatorFactory) -> Self {
177 self.indicators = IndicatorInfo::from_factory(&factory);
178 self.factory = factory;
179 self.refresh_order_flow_availability();
180 self
181 }
182
183 pub fn set_metric_source(&mut self, feed: Option<Arc<dyn Datafeed>>, symbol: &str) {
186 if match (&self.metric_source, &feed) {
187 (Some((previous, key)), Some(feed)) => key == symbol && Arc::ptr_eq(previous, feed),
188 (None, None) => true,
189 _ => false,
190 } {
191 return;
192 }
193 self.metric_source = feed.map(|feed| (feed, symbol.to_owned()));
194 self.metric_catalog = None;
195 self.metric_error = None;
196 for name in self.metric_names.drain(..) {
197 self.factory.unregister(&name);
198 }
199 self.indicators = IndicatorInfo::from_factory(&self.factory);
200 self.refresh_order_flow_availability();
201 }
202
203 fn poll_metrics(&mut self, ctx: &egui::Context) {
204 let Some((feed, symbol)) = &self.metric_source else {
205 return;
206 };
207 if self.metric_catalog.is_none() {
208 let inbox = Arc::new(Inbox::default());
209 let ctx = ctx.clone();
210 inbox.set_waker(move || ctx.request_repaint());
211 feed.metrics(symbol, inbox.reply(0));
212 self.metric_catalog = Some(inbox);
213 }
214 for (_, answer) in self.metric_catalog.as_ref().unwrap().drain().answers {
215 let result = answer
216 .downcast::<Result<Vec<MetricSeries>, FeedError>>()
217 .map(|result| *result)
218 .unwrap_or(Err(FeedError::Unanswered));
219 match result {
220 Ok(metrics) => {
221 for metric in metrics {
222 if metric.id.is_empty() {
223 continue;
224 }
225 let key = format!("Metric: {}", metric.id);
226 if self.factory.has(&key) {
227 continue;
228 }
229 let mut info = IndicatorInfo::new(&key, &metric.name, &metric.unit);
230 info.category = "Metrics".into();
231 self.factory.register(key.clone(), move || {
232 Box::new(MetricStudy::new(metric.clone()))
233 });
234 self.metric_names.push(key);
235 self.indicators.push(info);
236 }
237 self.indicators.sort_by_key(|info| info.name.to_lowercase());
238 }
239 Err(FeedError::NotSupported) => {}
240 Err(error) => self.metric_error = Some(error),
241 }
242 }
243 }
244
245 pub fn indicators(&self) -> &[IndicatorInfo] {
248 &self.indicators
249 }
250
251 pub fn factory(&self) -> &IndicatorFactory {
254 &self.factory
255 }
256
257 pub fn with_config(mut self, config: IndicatorDialogConfig) -> Self {
259 self.config = config;
260 self
261 }
262
263 pub fn open(&mut self) {
265 self.open_tab(IndicatorTab::Indicators);
266 }
267
268 pub fn open_for_chart(
273 &mut self,
274 chart: &crate::widget::Chart,
275 source: Option<StudySource>,
276 ) -> bool {
277 if let Some(StudySource::Study { id, plot }) = source
278 && !chart
279 .indicators()
280 .any(|(study, indicator)| study.0 == id && plot < indicator.line_names().len())
281 {
282 self.close();
283 return false;
284 }
285 self.open();
286 self.source = source;
287 true
288 }
289
290 pub fn open_tab(&mut self, tab: IndicatorTab) {
293 if tab != IndicatorTab::Indicators {
294 self.sel_category = IndicatorCategory::BuiltIns;
295 }
296 self.source = None;
297 self.source_error = None;
298 self.active_tab = tab;
299 self.is_open = true;
300 self.search.clear();
301 self.focus_search = true;
302 }
303
304 pub fn set_full(&mut self, full: bool) {
307 self.full = full;
308 }
309
310 pub fn close(&mut self) {
312 self.source = None;
313 self.is_open = false;
314 }
315
316 pub fn is_open(&self) -> bool {
318 self.is_open
319 }
320
321 pub fn sync_open_state(&mut self, should_be_open: bool) {
323 if should_be_open && !self.is_open {
324 self.open();
325 } else if !should_be_open && self.is_open {
326 self.close();
327 }
328 }
329
330 pub fn show(&mut self, ctx: &egui::Context, theme: &Theme) -> IndicatorDialogAction {
332 if !self.is_open {
333 return IndicatorDialogAction::None;
334 }
335
336 self.poll_metrics(ctx);
337 let config = self.config.resolved(theme);
338
339 if !self.search.is_empty() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
341 {
342 self.search.clear();
343 }
344
345 let mut is_open = true;
346 let action = Modal::new("indicator_dialog", "Indicators")
347 .width(config.width)
348 .height(config.height)
349 .dim_background(false)
350 .show(ctx, &mut is_open, |ui| {
351 self.draw_content(ui, &config, theme)
352 })
353 .map_or(IndicatorDialogAction::None, |shown| shown.inner);
354
355 if !is_open {
356 self.close();
357 return IndicatorDialogAction::Close;
358 }
359 action
360 }
361
362 fn draw_content(
363 &mut self,
364 ui: &mut Ui,
365 config: &IndicatorDialogConfig,
366 theme: &Theme,
367 ) -> IndicatorDialogAction {
368 ui.add_space(theme.spacing.xxl);
369 if let Some(error) = &self.metric_error {
370 ui.label(l10n::format_text(
371 ui.ctx(),
372 "Metrics: {error}",
373 &[("error", format!("{}", error))],
374 ));
375 }
376 if let Some(error) = &self.source_error {
377 ui.colored_label(theme.ui.error, l10n::text(ui.ctx(), error.to_string()));
378 }
379 if self.full {
380 ui.colored_label(
381 theme.ui.error,
382 l10n::text(ui.ctx(), "The chart has reached its study limit"),
383 );
384 }
385
386 let search = ui
387 .scope(|ui| {
388 ui.spacing_mut().interact_size.y = 40.0;
389 ui.style_mut().override_font_id = Some(FontId::proportional(theme.text.heading));
390 self.search.show(ui).response
391 })
392 .inner;
393 if std::mem::take(&mut self.focus_search) {
394 search.request_focus();
395 }
396 self.focus_first_row = search.has_focus() && ui.input(|i| i.key_pressed(Key::ArrowDown));
397 ui.add_space(theme.spacing.lg);
398
399 let offered: Vec<_> = IndicatorCategory::all()
401 .iter()
402 .copied()
403 .filter(|category| self.offers(*category))
404 .collect();
405 if !offered.contains(&self.sel_category) {
406 self.sel_category = offered
407 .iter()
408 .copied()
409 .find(|category| *category != IndicatorCategory::Favorites)
410 .unwrap_or(IndicatorCategory::Favorites);
411 }
412 let mut action = IndicatorDialogAction::None;
413 if ui.available_width() < 600.0 {
414 egui::ComboBox::from_label(l10n::text(ui.ctx(), "Category"))
415 .selected_text(l10n::text(ui.ctx(), self.sel_category.name()))
416 .show_ui(ui, |ui| {
417 for &category in &offered {
418 ui.selectable_value(
419 &mut self.sel_category,
420 category,
421 l10n::text(ui.ctx(), category.name()),
422 );
423 }
424 });
425 self.draw_tabs(ui, theme);
426 return self.draw_indicator_list(ui, config, theme);
427 }
428 let category = Cell::new(self.sel_category);
430 SidebarLayout::new(SIDEBAR_WIDTH).show(
431 ui,
432 |ui| {
433 use IndicatorCategory as C;
434 for (heading, items) in [
435 ("PERSONAL", &[C::Favorites, C::MyScripts][..]),
436 ("BUILT-IN", &[C::BuiltIns]),
437 ("COMMUNITY", &[C::Community]),
438 ] {
439 let items: Vec<_> = items.iter().filter(|c| offered.contains(c)).collect();
440 if items.is_empty() {
441 continue;
442 }
443 ui.add_space(theme.spacing.xxl);
444 egui::Frame::new()
445 .inner_margin(egui::Margin {
446 left: theme.spacing.xl as i8,
447 ..Default::default()
448 })
449 .show(ui, |ui| {
450 ui.label(
451 RichText::new(l10n::text(ui.ctx(), heading))
452 .size(theme.text.small)
453 .color(theme.ui.text_muted),
454 )
455 });
456 ui.add_space(theme.spacing.lg);
457 for &item in items {
458 if SidebarTab::new(item.name(), category.get() == item)
459 .icon(item.icon())
460 .fill(theme.ui.panel_bg_secondary)
461 .show(ui)
462 {
463 category.set(item);
464 }
465 }
466 }
467 },
468 |ui| {
469 self.sel_category = category.get();
470 self.draw_tabs(ui, theme);
471 action = self.draw_indicator_list(ui, config, theme);
472 },
473 );
474 action
475 }
476
477 fn destination(ind: &IndicatorInfo) -> (IndicatorCategory, IndicatorTab) {
479 use {IndicatorCategory as C, IndicatorTab as T};
480 match ind.category.as_str() {
481 IndicatorFactory::PERSONAL => (C::MyScripts, T::Indicators),
482 IndicatorFactory::COMMUNITY => (C::Community, T::Indicators),
483 IndicatorFactory::PROFILES => (C::BuiltIns, T::Profiles),
484 IndicatorFactory::PATTERNS => (C::BuiltIns, T::Patterns),
485 IndicatorFactory::ORDER_FLOW => (C::BuiltIns, T::OrderFlow),
486 _ => (C::BuiltIns, T::Indicators),
487 }
488 }
489
490 fn offers(&self, category: IndicatorCategory) -> bool {
492 category == IndicatorCategory::Favorites
493 || self
494 .indicators
495 .iter()
496 .any(|ind| Self::destination(ind).0 == category && self.accepts(ind))
497 }
498
499 fn draw_tabs(&mut self, ui: &mut Ui, theme: &Theme) {
502 let tabs: Vec<_> = IndicatorTab::all()
503 .iter()
504 .copied()
505 .filter(|tab| {
506 self.indicators.iter().any(|ind| {
507 Self::destination(ind) == (IndicatorCategory::BuiltIns, *tab)
508 && self.accepts(ind)
509 })
510 })
511 .collect();
512 if !tabs.contains(&self.active_tab) {
513 self.active_tab = tabs.first().copied().unwrap_or(IndicatorTab::Indicators);
514 }
515 if self.sel_category == IndicatorCategory::BuiltIns
516 && tabs.len() > 1
517 && self.search.is_empty()
518 {
519 TabBar::new(&tabs, &mut self.active_tab).pills().show(ui);
520 ui.add_space(theme.spacing.xl);
521 }
522 }
523
524 fn accepts(&self, ind: &IndicatorInfo) -> bool {
525 !self.unavailable_order_flow.contains(&ind.id)
526 && (self.source.is_none()
527 || self
528 .factory
529 .create(&ind.id)
530 .is_some_and(|study| study.source().is_some()))
531 }
532
533 fn matches(&self, ctx: &egui::Context, ind: &IndicatorInfo) -> bool {
536 if !self.accepts(ind) {
537 return false;
538 }
539 let searched = [&ind.name, &ind.desc, &ind.id, &ind.category]
540 .iter()
541 .any(|text| self.search.matches(text) || self.search.matches(&l10n::text(ctx, text)));
542 if !searched {
543 return false;
544 }
545
546 let (category, tab) = Self::destination(ind);
547 match self.sel_category {
548 IndicatorCategory::Favorites => self.favorites.contains(&ind.id),
549 IndicatorCategory::Premium => ind.is_premium,
550 IndicatorCategory::BuiltIns => {
551 category == IndicatorCategory::BuiltIns
552 && !ind.is_premium
553 && (!self.search.is_empty() || tab == self.active_tab)
554 }
555 selected => category == selected,
556 }
557 }
558
559 fn draw_indicator_list(
560 &mut self,
561 ui: &mut Ui,
562 config: &IndicatorDialogConfig,
563 theme: &Theme,
564 ) -> IndicatorDialogAction {
565 let mut filtered: Vec<IndicatorInfo> = self
566 .indicators
567 .iter()
568 .filter(|ind| self.matches(ui.ctx(), ind))
569 .cloned()
570 .collect();
571 filtered.sort_by_key(|indicator| !self.favorites.contains(&indicator.id));
572 if filtered.is_empty() {
573 self.draw_empty_state(ui);
574 return IndicatorDialogAction::None;
575 }
576
577 let (_, header) = ui.allocate_space(Vec2::new(
579 ui.available_width(),
580 DESIGN_TOKENS.sizing.button_sm,
581 ));
582 let label = ui.painter().layout_no_wrap(
583 l10n::text(ui.ctx(), "NAME"),
584 FontId::proportional(theme.text.small),
585 theme.ui.text_muted,
586 );
587 let label_pos = Pos2::new(
588 header.min.x + DESIGN_TOKENS.sizing.button_md,
589 header.center().y - label.size().y / 2.0,
590 );
591 ui.painter().galley(label_pos, label, theme.ui.text_muted);
592
593 let mut action = IndicatorDialogAction::None;
594 crate::styles::menu_scroll_bar(ui);
596 egui::ScrollArea::vertical()
597 .auto_shrink([false, false])
598 .show(ui, |ui| {
599 ui.spacing_mut().item_spacing.y = 0.0;
600 let ring = theme.spacing.xs;
602 ui.add_space(ring);
603 for (i, indicator) in filtered.iter().enumerate() {
604 if let Some(a) = self.draw_indicator_item(ui, config, theme, indicator, i == 0)
605 {
606 action = a;
607 }
608 }
609 ui.add_space(ring);
610 });
611 action
612 }
613
614 fn draw_empty_state(&self, ui: &mut Ui) {
616 let query = self.search.query();
617 let no_match = l10n::format_text(
618 ui.ctx(),
619 "No indicators match \"{query}\"",
620 &[("query", query)],
621 );
622 let state = match self.sel_category {
623 _ if !query.is_empty() => EmptyState::new(&no_match)
624 .description("Check the spelling or try another category."),
625 IndicatorCategory::Favorites => EmptyState::new("No favorites yet")
626 .icon(&icons::STAR_EMPTY)
627 .description("Star an indicator to keep it here."),
628 _ => EmptyState::new("Nothing here yet"),
629 };
630 state.fill_height().show(ui);
631 }
632
633 fn draw_indicator_item(
634 &mut self,
635 ui: &mut Ui,
636 config: &IndicatorDialogConfig,
637 theme: &Theme,
638 indicator: &IndicatorInfo,
639 is_first: bool,
640 ) -> Option<IndicatorDialogAction> {
641 let desired_size = Vec2::new(
642 ui.available_width(),
643 32.0_f32.max(theme.text.title + 2.0 * theme.spacing.sm),
644 );
645 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
646
647 let is_favorite = self.favorites.contains(&indicator.id);
648 let translated_label = l10n::text(ui.ctx(), &indicator.name);
649 response.widget_info(|| {
650 WidgetInfo::labeled(Role::ListBoxOption, response.enabled(), &translated_label)
651 });
652 if is_first && self.focus_first_row {
653 response.request_focus();
654 }
655 if response.gained_focus() {
656 response.scroll_to_me(None);
657 }
658
659 if response.hovered() {
660 ui.painter()
661 .rect_filled(rect, theme.radius.md, config.hover_color);
662 }
663 let star_res = Self::draw_favorite_star(
664 ui,
665 config,
666 rect,
667 is_favorite,
668 response.hovered() || response.has_focus(),
669 );
670 let text_right = Self::draw_item_badges(ui, theme, rect, indicator, config.muted_color);
671 Self::draw_item_text(ui, rect, text_right, indicator, config.text_color);
672 draw_focus_ring(ui, &response, theme.radius.md);
673
674 self.handle_item_clicks(indicator, is_favorite, &response, &star_res)
675 }
676
677 fn star_rect(ui: &Ui, row: Rect) -> Rect {
679 Rect::from_min_size(
680 Pos2::new(row.min.x + ui.active_theme().spacing.sm, row.min.y),
681 Vec2::new(DESIGN_TOKENS.sizing.button_sm, row.height()),
682 )
683 }
684
685 fn draw_favorite_star(
688 ui: &mut Ui,
689 config: &IndicatorDialogConfig,
690 rect: Rect,
691 is_favorite: bool,
692 row_active: bool,
693 ) -> Response {
694 let star_rect = Self::star_rect(ui, rect);
695 let star_res = ui.allocate_rect(star_rect, Sense::click());
696 let tooltip = if is_favorite {
697 "Remove from favorites"
698 } else {
699 "Add to favorites"
700 };
701 let translated_label = l10n::text(ui.ctx(), tooltip);
702 star_res.widget_info(|| {
703 WidgetInfo::selected(
704 Role::Button,
705 star_res.enabled(),
706 is_favorite,
707 &translated_label,
708 )
709 });
710
711 let (icon, tint) = if is_favorite {
712 (&icons::STAR_FILLED, config.favorite_color)
713 } else if star_res.hovered() {
714 (&icons::STAR_EMPTY, ui.visuals().text_color())
715 } else {
716 (&icons::STAR_EMPTY, ui.visuals().weak_text_color())
717 };
718 if is_favorite || row_active || star_res.hovered() || star_res.has_focus() {
719 let size = Vec2::splat(DESIGN_TOKENS.sizing.icon_sm);
720 icon.as_image_tinted(size, tint)
721 .paint_at(ui, Rect::from_center_size(star_rect.center(), size));
722 }
723 draw_focus_ring(ui, &star_res, 0.0);
724 star_res.on_hover_text(l10n::text(ui.ctx(), tooltip))
725 }
726
727 fn draw_item_text(ui: &Ui, rect: Rect, right: f32, indicator: &IndicatorInfo, text: Color32) {
729 let x = rect.min.x + DESIGN_TOKENS.sizing.button_md;
730 let mut job = LayoutJob::simple_singleline(
731 l10n::text(ui.ctx(), indicator.name.clone()),
732 FontId::proportional(ui.active_theme().text.title),
733 text,
734 );
735 job.wrap = TextWrapping::truncate_at_width(right - x);
736 let name = ui.painter().layout_job(job);
737 let top = rect.center().y - name.size().y / 2.0;
738 ui.painter().galley(Pos2::new(x, top), name, text);
739 }
740
741 fn draw_item_badges(
743 ui: &Ui,
744 theme: &Theme,
745 rect: Rect,
746 indicator: &IndicatorInfo,
747 color: Color32,
748 ) -> f32 {
749 let painter = ui.painter();
750 let pad = Vec2::new(theme.spacing.sm, theme.spacing.xs);
751 let mut right = rect.right() - theme.spacing.lg;
752 for (shown, label) in [
753 (indicator.is_premium, "PREMIUM"),
754 (indicator.is_overlay, "OVERLAY"),
755 ] {
756 if !shown {
757 continue;
758 }
759 let galley = painter.layout_no_wrap(
760 l10n::text(ui.ctx(), label),
761 FontId::proportional(theme.text.small),
762 color,
763 );
764 let badge = Align2::RIGHT_CENTER.align_size_within_rect(
765 galley.size() + 2.0 * pad,
766 Rect::from_x_y_ranges(rect.left()..=right, rect.y_range()),
767 );
768 painter.rect_stroke(
769 badge,
770 theme.radius.sm,
771 Stroke::new(DESIGN_TOKENS.stroke.hairline, theme.ui.border),
772 StrokeKind::Inside,
773 );
774 painter.galley(badge.min + pad, galley, color);
775 right = badge.left() - theme.spacing.sm;
776 }
777 right
778 }
779
780 fn handle_item_clicks(
781 &mut self,
782 indicator: &IndicatorInfo,
783 is_favorite: bool,
784 response: &Response,
785 star_res: &Response,
786 ) -> Option<IndicatorDialogAction> {
787 if star_res.clicked() {
788 if is_favorite {
789 self.favorites.remove(&indicator.id);
790 } else {
791 self.favorites.insert(indicator.id.clone());
792 }
793 return Some(IndicatorDialogAction::ToggleFavorite(indicator.id.clone()));
794 }
795
796 let on_star = star_res
797 .rect
798 .contains(response.hover_pos().unwrap_or_default());
799 if response.clicked() && !on_star {
801 return self.add(&indicator.id);
802 }
803 None
804 }
805
806 fn add(&mut self, id: &str) -> Option<IndicatorDialogAction> {
809 self.source_error = None;
810 if self.full {
811 return None;
812 }
813 let mut study = self.factory.create(id)?;
814 if let Some(source) = self.source {
815 if !study
816 .inputs()
817 .iter()
818 .any(|input| input.id == "source" && matches!(input.kind, InputKind::Source))
819 {
820 study = Box::new(ConfiguredStudy::new(study));
821 }
822 if let Err(error) = study.set_input("source", InputValue::Source(source)) {
823 self.source_error = Some(error);
824 return None;
825 }
826 }
827 Some(IndicatorDialogAction::AddIndicator(study))
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::scales::StudyFormat;
835
836 #[test]
837 fn metric_catalog_ignores_old_symbols_and_creates_host_studies() {
838 use crate::data::{HistoryPage, HistoryRequest, Reply};
839 use std::sync::Mutex;
840 #[derive(Default)]
841 struct Feed(Mutex<Vec<Reply<Vec<MetricSeries>>>>);
842 impl Datafeed for Feed {
843 fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
844 reply.send(Err(FeedError::NotSupported));
845 }
846 fn metrics(&self, _: &str, reply: Reply<Vec<MetricSeries>>) {
847 self.0.lock().unwrap().push(reply);
848 }
849 }
850 let feed = Arc::new(Feed::default());
851 let ctx = egui::Context::default();
852 let mut dialog = IndicatorDialog::new();
853 dialog.set_metric_source(Some(feed.clone()), "OLD");
854 dialog.poll_metrics(&ctx);
855 dialog.set_metric_source(Some(feed.clone()), "CURRENT");
856 dialog.poll_metrics(&ctx);
857 let mut replies = feed.0.lock().unwrap();
858 for (reply, name) in replies.drain(..).zip(["Stale metric", "Funding rate"]) {
859 reply.send(Ok(vec![MetricSeries {
860 id: "funding".into(),
861 name: name.into(),
862 unit: "%".into(),
863 format: StudyFormat::Percent,
864 }]));
865 }
866 dialog.poll_metrics(&ctx);
867 assert!(
868 dialog
869 .indicators
870 .iter()
871 .any(|info| info.name == "Funding rate")
872 );
873 assert!(
874 !dialog
875 .indicators
876 .iter()
877 .any(|info| info.name == "Stale metric")
878 );
879 let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("Metric: funding") else {
880 panic!("the metric is added");
881 };
882 assert_eq!(study.name(), "Funding rate");
883 assert!(!study.is_overlay());
884 assert_eq!(study.value_format(), StudyFormat::Percent);
885 assert_eq!(study.data_requests()[0].metric.as_deref(), Some("funding"));
886 use egui_kittest::{Harness, kittest::Queryable};
887 for theme in [Theme::light(), Theme::dark()] {
888 for width in [640.0, 800.0, 1440.0] {
889 dialog.open();
890 *dialog.search.query_mut() = "Funding rate".into();
891 let style = theme.clone();
892 let mut added = false;
893 let mut harness = Harness::builder()
894 .with_size(Vec2::new(width, 640.0))
895 .with_context_setup(move |ctx| {
896 egui_extras::install_image_loaders(ctx);
897 crate::theme::apply_to_egui(ctx, &style);
898 })
899 .build_ui(|ui| {
900 if let IndicatorDialogAction::AddIndicator(study) =
901 dialog.show(ui.ctx(), &theme)
902 {
903 assert_eq!(study.name(), "Funding rate");
904 added = true;
905 }
906 });
907 harness.run();
908 let row = harness.get_by_role_and_label(Role::ListBoxOption, "Funding rate");
909 assert!(
910 harness.ctx.content_rect().contains_rect(row.rect()),
911 "width {width}"
912 );
913 row.click();
914 harness.run();
915 drop(harness);
916 assert!(added, "width {width}");
917 }
918 }
919 dialog.set_metric_source(None, "CURRENT");
920 assert!(dialog.add("Metric: funding").is_none());
921 assert!(dialog.add("SMA").is_some());
922 }
923
924 #[test]
925 fn source_chooser_lists_favorites_first_and_adds_with_the_requested_source() {
926 use egui_kittest::{Harness, kittest::Queryable};
927 let mut chart = crate::widget::ChartBuilder::new().build();
928 let parent = chart.add_indicator(Box::new(SMA::new(3)));
929 let source = StudySource::Study {
930 id: parent.0,
931 plot: 0,
932 };
933 for theme in [Theme::light(), Theme::dark()] {
934 for width in [320.0, 640.0] {
935 let mut dialog = IndicatorDialog::new();
936 let name = dialog
937 .indicators
938 .iter()
939 .find(|info| info.id == "SMA")
940 .unwrap()
941 .name
942 .clone();
943 dialog.favorites.insert("SMA".into());
944 assert!(dialog.open_for_chart(&chart, Some(source)));
945 let mut added = None;
946 let style = theme.clone();
947 let mut harness = Harness::builder()
948 .with_size(Vec2::new(width, 640.0))
949 .with_context_setup(move |ctx| {
950 egui_extras::install_image_loaders(ctx);
951 crate::theme::apply_to_egui(ctx, &style);
952 })
953 .build_ui(|ui| {
954 if let IndicatorDialogAction::AddIndicator(study) =
955 dialog.show(ui.ctx(), &theme)
956 {
957 added = study.source();
958 }
959 });
960 harness.run();
961 let row = harness.get_by_role_and_label(Role::ListBoxOption, &name);
962 assert!(
963 harness
964 .query_all_by_role(Role::ListBoxOption)
965 .all(|other| other.rect().top() >= row.rect().top())
966 );
967 row.click();
968 harness.run();
969 drop(harness);
970 assert_eq!(added, Some(source), "width {width}");
971 assert!(dialog.is_open(), "the dialog stays open for the next study");
972 dialog.open();
973 assert!(
974 dialog.source.is_none(),
975 "opening normally clears the previous source"
976 );
977 }
978 }
979 }
980
981 #[test]
983 fn dialog_at_the_study_limit_says_so_and_adds_nothing() {
984 use egui_kittest::{Harness, kittest::Queryable};
985 let theme = Theme::light();
986 let mut dialog = IndicatorDialog::new();
987 dialog.open();
988 dialog.set_full(true);
989 let style = theme.clone();
990 let mut harness = Harness::builder()
991 .with_size(Vec2::new(640.0, 640.0))
992 .with_context_setup(move |ctx| {
993 egui_extras::install_image_loaders(ctx);
994 crate::theme::apply_to_egui(ctx, &style);
995 })
996 .build_ui(|ui| {
997 dialog.show(ui.ctx(), &theme);
998 });
999 harness.run();
1000 harness.get_by_label("The chart has reached its study limit");
1001 drop(harness);
1002 assert!(dialog.add("SMA").is_none());
1003 dialog.set_full(false);
1004 assert!(dialog.add("SMA").is_some());
1005 }
1006
1007 #[test]
1008 fn saved_favorites_restore_the_favorites_list() {
1009 let mut dialog = IndicatorDialog::new();
1010 dialog
1011 .favorites
1012 .extend(["BB".to_owned(), "Host study".to_owned()]);
1013 let saved = serde_json::to_string(&dialog.preferences()).unwrap();
1014 let mut restored = IndicatorDialog::new();
1015 restored.set_preferences(serde_json::from_str(&saved).unwrap());
1016 restored.sel_category = IndicatorCategory::Favorites;
1017 let ctx = egui::Context::default();
1018 let listed: Vec<_> = restored
1019 .indicators
1020 .iter()
1021 .filter(|i| restored.matches(&ctx, i))
1022 .collect();
1023 assert_eq!(listed.len(), 1);
1024 assert_eq!(listed[0].id, "BB");
1025 assert!(restored.favorites.contains("Host study"));
1026 assert_eq!(
1027 saved,
1028 serde_json::to_string(&restored.preferences()).unwrap()
1029 );
1030 for category in [IndicatorCategory::BuiltIns, IndicatorCategory::Favorites] {
1031 restored.sel_category = category;
1032 let output = ctx.run_ui(Default::default(), |ui| {
1033 restored.draw_indicator_list(ui, &restored.config.clone(), &Theme::dark());
1034 });
1035 let first = output.shapes.iter().find_map(|shape| match &shape.shape {
1036 egui::Shape::Text(text)
1037 if restored
1038 .indicators
1039 .iter()
1040 .any(|info| info.name == text.galley.job.text) =>
1041 {
1042 Some(text.galley.job.text.clone())
1043 }
1044 _ => None,
1045 });
1046 output.drop_without_applying_deltas();
1047 let favorite = restored
1048 .indicators
1049 .iter()
1050 .find(|info| info.id == "BB")
1051 .unwrap();
1052 assert_eq!(
1053 first.as_deref(),
1054 Some(favorite.name.as_str()),
1055 "{category:?}"
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn added_study_keeps_the_selected_source_and_reopening_clears_it() {
1062 use crate::model::PriceSource;
1063 let mut chart = crate::widget::ChartBuilder::new().build();
1064 let id = chart.add_indicator(Box::new(SMA::new(3)));
1065 let mut dialog = IndicatorDialog::new();
1066 for source in [
1067 StudySource::Study { id: id.0, plot: 0 },
1068 StudySource::Price(PriceSource::Open),
1069 ] {
1070 assert!(dialog.open_for_chart(&chart, Some(source)));
1071 let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("SMA") else {
1072 panic!("a source-capable study is added");
1073 };
1074 assert_eq!(study.source(), Some(source));
1075 }
1076 assert!(dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 0 })));
1077 assert!(
1078 dialog.add("ATR").is_none(),
1079 "OHLC studies cannot silently ignore the source"
1080 );
1081 dialog.open();
1082 assert!(matches!(
1083 dialog.add("ATR"),
1084 Some(IndicatorDialogAction::AddIndicator(_))
1085 ));
1086 assert!(!dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 99 })));
1087 assert!(!dialog.is_open());
1088
1089 let mut factory = IndicatorFactory::new();
1090 factory.register("Host source", || {
1091 let mut study = ConfiguredStudy::new(Box::new(SMA::new(3)));
1092 study
1093 .set_input(
1094 "calculation_timeframe",
1095 InputValue::Timeframe(Some(crate::model::Timeframe::Hour1)),
1096 )
1097 .unwrap();
1098 Box::new(study)
1099 });
1100 let mut dialog = IndicatorDialog::new().with_factory(factory);
1101 assert!(dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 0 })));
1102 assert!(
1103 dialog.add("Host source").is_none(),
1104 "the host's source validation must be retained"
1105 );
1106 assert!(
1107 matches!(dialog.source_error, Some(StudyError::InvalidInput { ref id, .. }) if id == "source")
1108 );
1109 assert!(dialog.is_open());
1110 let message = dialog.source_error.as_ref().unwrap().to_string();
1111 for theme in [Theme::light(), Theme::dark()] {
1112 for width in [320.0, 640.0] {
1113 use egui_kittest::{Harness, kittest::Queryable};
1114 let style = theme.clone();
1115 let mut harness = Harness::builder()
1116 .with_size(Vec2::new(width, 640.0))
1117 .with_context_setup(move |ctx| {
1118 egui_extras::install_image_loaders(ctx);
1119 crate::theme::apply_to_egui(ctx, &style);
1120 })
1121 .build_ui(|ui| {
1122 dialog.show(ui.ctx(), &theme);
1123 });
1124 harness.run();
1125 let error = harness.get_by_label(&message);
1126 assert!(
1127 harness.ctx.content_rect().contains_rect(error.rect()),
1128 "source error at width {width}"
1129 );
1130 }
1131 }
1132 assert!(dialog.open_for_chart(&chart, Some(StudySource::Price(PriceSource::Open))));
1133 let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("Host source") else {
1134 panic!("a compatible price source is accepted");
1135 };
1136 assert!(dialog.source_error.is_none());
1137 assert_eq!(
1138 study
1139 .inputs()
1140 .iter()
1141 .find(|input| input.id == "calculation_timeframe")
1142 .unwrap()
1143 .value,
1144 InputValue::Timeframe(Some(crate::model::Timeframe::Hour1))
1145 );
1146 }
1147
1148 #[test]
1149 fn each_tab_and_category_lists_only_its_own_indicators() {
1150 use IndicatorCategory::*;
1151 use IndicatorTab::*;
1152 let cases: [(_, _, _, &[&str], &[&str]); 9] = [
1155 (
1156 Indicators,
1157 BuiltIns,
1158 "",
1159 &["RSI", "Host SMA"],
1160 &[
1161 "Vol Profile VR",
1162 "All Candlestick Patterns",
1163 "Execution Delta",
1164 "Cumulative Execution Delta",
1165 "Host flow",
1166 "My SMA",
1167 "Shared SMA",
1168 ],
1169 ),
1170 (
1171 Profiles,
1172 BuiltIns,
1173 "",
1174 &["Vol Profile VR"],
1175 &["RSI", "All Candlestick Patterns"],
1176 ),
1177 (
1178 Patterns,
1179 BuiltIns,
1180 "",
1181 &["All Candlestick Patterns"],
1182 &["RSI", "Vol Profile VR"],
1183 ),
1184 (
1185 OrderFlow,
1186 BuiltIns,
1187 "",
1188 &["Execution Delta", "Cumulative Execution Delta", "Host flow"],
1189 &["RSI", "Vol Profile VR", "All Candlestick Patterns"],
1190 ),
1191 (
1192 Indicators,
1193 BuiltIns,
1194 "Vol Profile VR",
1195 &["Vol Profile VR"],
1196 &["RSI"],
1197 ),
1198 (
1199 OrderFlow,
1200 BuiltIns,
1201 "delta",
1202 &[
1203 "Execution Delta",
1204 "Cumulative Execution Delta",
1205 "Multi-Range Delta",
1206 ],
1207 &["RSI", "Quoted Spread"],
1208 ),
1209 (
1210 OrderFlow,
1211 Favorites,
1212 "",
1213 &["Execution Delta", "Host flow"],
1214 &["Cumulative Execution Delta", "RSI"],
1215 ),
1216 (
1217 Indicators,
1218 MyScripts,
1219 "",
1220 &["My SMA"],
1221 &["Host SMA", "Shared SMA", "RSI"],
1222 ),
1223 (
1224 Indicators,
1225 Community,
1226 "",
1227 &["Shared SMA"],
1228 &["My SMA", "RSI"],
1229 ),
1230 ];
1231 let factory = || {
1232 let mut factory = IndicatorFactory::new();
1233 factory.register("Host SMA", || Box::new(SMA::new(42)));
1234 factory.register_in(IndicatorFactory::ORDER_FLOW, "Host flow", || {
1235 Box::new(SMA::new(42))
1236 });
1237 factory.register_in(IndicatorFactory::PERSONAL, "My SMA", || {
1238 Box::new(SMA::new(42))
1239 });
1240 factory.register_in(IndicatorFactory::COMMUNITY, "Shared SMA", || {
1241 Box::new(SMA::new(42))
1242 });
1243 factory
1244 };
1245 let mut builtin = IndicatorDialog::new();
1247 let hosted = IndicatorDialog::new().with_factory(factory());
1248 for category in [MyScripts, Community] {
1249 assert!(
1250 !builtin.offers(category) && hosted.offers(category),
1251 "{category:?}"
1252 );
1253 }
1254 let mut searched = IndicatorDialog::new().with_factory(factory());
1255 let study = searched
1256 .indicators
1257 .iter()
1258 .find(|ind| ind.id == "Host SMA")
1259 .unwrap()
1260 .clone();
1261 let name = study.name.clone();
1262 let ctx = egui::Context::default();
1263 for tab in [Indicators, OrderFlow] {
1264 builtin.active_tab = tab;
1265 for ind in &builtin.indicators {
1266 if builtin.factory.category(&ind.id) == Some(IndicatorFactory::ORDER_FLOW) {
1267 assert_eq!(builtin.matches(&ctx, ind), tab == OrderFlow, "{}", ind.id);
1268 }
1269 }
1270 }
1271 l10n::set_translator(&ctx, move |english| {
1272 (english == name).then(|| "Moyenne locale".into())
1273 });
1274 for query in ["Moyenne", "Host SMA"] {
1275 *searched.search.query_mut() = query.into();
1276 assert!(searched.matches(&ctx, &study), "{query}");
1277 }
1278 for (tab, category, query, listed, unlisted) in cases {
1279 let mut dialog = IndicatorDialog::new().with_factory(factory());
1280 dialog.active_tab = tab;
1281 dialog.sel_category = category;
1282 dialog
1283 .favorites
1284 .extend(["Execution Delta".into(), "Host flow".into()]);
1285 *dialog.search.query_mut() = query.into();
1286 let lists = |id: &str| {
1287 let ind = dialog.indicators.iter().find(|i| i.id == id).unwrap();
1288 dialog.matches(&ctx, ind)
1289 };
1290 for id in listed {
1291 assert_eq!(
1292 dialog
1293 .indicators
1294 .iter()
1295 .filter(|ind| ind.id == *id && dialog.matches(&ctx, ind))
1296 .count(),
1297 1,
1298 "{tab:?} / {category:?} / {query:?} lists {id} once"
1299 );
1300 }
1301 for id in unlisted {
1302 assert!(!lists(id), "{tab:?} / {category:?} / {query:?} omits {id}");
1303 }
1304 }
1305
1306 for has_flow in [false, true] {
1308 for source in [
1309 None,
1310 Some(StudySource::Price(crate::model::PriceSource::Open)),
1311 ] {
1312 let mut factory = IndicatorFactory::new();
1313 if !has_flow {
1314 for id in factory.list() {
1315 if factory.category(&id) == Some(IndicatorFactory::ORDER_FLOW) {
1316 factory.unregister(&id);
1317 }
1318 }
1319 }
1320 let mut dialog = IndicatorDialog::new().with_factory(factory);
1321 dialog.open_tab(OrderFlow);
1322 dialog.source = source;
1323 let output = ctx.run_ui(Default::default(), |ui| {
1324 dialog.draw_tabs(ui, &Theme::dark());
1325 });
1326 let offered = output.shapes.iter().any(|shape| {
1327 matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text == "Order flow")
1328 });
1329 let accepted = has_flow && source.is_none();
1330 assert_eq!(offered, accepted, "{has_flow} / {source:?}");
1331 assert_eq!(
1332 dialog.active_tab,
1333 if accepted { OrderFlow } else { Indicators }
1334 );
1335 output.drop_without_applying_deltas();
1336 dialog.source = None;
1337 *dialog.search.query_mut() = "delta".into();
1338 for ind in dialog
1339 .indicators
1340 .iter()
1341 .filter(|ind| ind.name.to_lowercase().contains("delta"))
1342 {
1343 assert!(dialog.matches(&ctx, ind), "{} / {has_flow}", ind.id);
1344 }
1345 let ids: Vec<_> = dialog
1346 .indicators
1347 .iter()
1348 .filter(|ind| dialog.matches(&ctx, ind))
1349 .map(|ind| &ind.id)
1350 .collect();
1351 assert_eq!(ids.len(), ids.iter().collect::<HashSet<_>>().len());
1352 }
1353 }
1354
1355 for (category, selected, tab) in [
1357 (IndicatorFactory::PROFILES, BuiltIns, Profiles),
1358 (IndicatorFactory::PATTERNS, BuiltIns, Patterns),
1359 (IndicatorFactory::ORDER_FLOW, BuiltIns, OrderFlow),
1360 (IndicatorFactory::PERSONAL, MyScripts, Indicators),
1361 (IndicatorFactory::COMMUNITY, Community, Indicators),
1362 ] {
1363 for source in [
1364 None,
1365 Some(StudySource::Price(crate::model::PriceSource::Open)),
1366 ] {
1367 let mut factory = IndicatorFactory::new();
1368 for name in factory.list() {
1369 factory.unregister(&name);
1370 }
1371 factory.register_in(category, "Host study", || Box::new(ATR::new(14)));
1372 let mut dialog = IndicatorDialog::new().with_factory(factory);
1373 assert!(dialog.open_for_chart(&crate::widget::ChartBuilder::new().build(), source));
1374 ctx.run_ui(Default::default(), |ui| {
1375 dialog.draw_content(ui, &dialog.config.clone(), &Theme::dark());
1376 })
1377 .drop_without_applying_deltas();
1378 let compatible = source.is_none();
1379 assert_eq!(
1380 dialog.sel_category,
1381 if compatible { selected } else { Favorites },
1382 "{category} / {source:?}"
1383 );
1384 assert_eq!(
1385 dialog.active_tab,
1386 if compatible { tab } else { Indicators },
1387 "{category} / {source:?}"
1388 );
1389 assert_eq!(
1390 dialog.offers(BuiltIns),
1391 compatible && selected == BuiltIns,
1392 "{category} / {source:?}"
1393 );
1394 assert_eq!(
1395 dialog.matches(&ctx, &dialog.indicators[0]),
1396 compatible,
1397 "{category} / {source:?}"
1398 );
1399 }
1400 }
1401
1402 use crate::data::{FeedCapabilities, OrderFlowKind};
1403 use crate::model::{FeedService::*, Instrument};
1404 let instrument = Instrument::new("A");
1405 let none = FeedCapabilities::new().with_services([]);
1406 let live = FeedCapabilities::new().with_services([LiveTrades]);
1407 let sided = FeedCapabilities::new().with_services([LiveTrades, TradeSides]);
1408 let all =
1409 FeedCapabilities::new().with_services([LiveTrades, TradeSides, BestBidAsk, Depth]);
1410 let unknown = FeedCapabilities::new();
1411 let narrowed = instrument.clone().with_services([]);
1412 let declared = instrument.clone().with_services([LiveTrades, TradeSides]);
1413 for (updated, capabilities, symbol, expected) in [
1414 (false, None, None, [true; 4]),
1415 (true, None, None, [false; 4]),
1416 (true, Some(&sided), None, [false; 4]),
1417 (true, None, Some(&declared), [false; 4]),
1418 (true, Some(&unknown), Some(&instrument), [false; 4]),
1419 (true, Some(&none), Some(&instrument), [false; 4]),
1420 (true, Some(&all), Some(&narrowed), [false; 4]),
1421 (true, Some(&none), Some(&declared), [false; 4]),
1422 (
1423 true,
1424 Some(&unknown),
1425 Some(&declared),
1426 [true, true, false, false],
1427 ),
1428 (
1429 true,
1430 Some(&live),
1431 Some(&instrument),
1432 [true, false, false, false],
1433 ),
1434 (
1435 true,
1436 Some(&sided),
1437 Some(&instrument),
1438 [true, true, false, false],
1439 ),
1440 (true, Some(&all), Some(&instrument), [true; 4]),
1441 ] {
1442 let mut dialog = IndicatorDialog::new();
1443 if updated {
1444 dialog.set_feed_capabilities(capabilities, symbol);
1445 }
1446 dialog.favorites.insert("Quoted Spread".into());
1447 dialog.open_tab(OrderFlow);
1448 for query in ["", "Order flow"] {
1449 *dialog.search.query_mut() = query.into();
1450 for (id, accepted) in [
1451 "Trade Rate",
1452 "Rolling Volume and Delta",
1453 "Quoted Spread",
1454 "Book Size",
1455 ]
1456 .into_iter()
1457 .zip(expected)
1458 {
1459 let ind = dialog.indicators.iter().find(|ind| ind.id == id).unwrap();
1460 assert_eq!(
1461 dialog.matches(&ctx, ind),
1462 accepted,
1463 "{id} / {query:?} / {capabilities:?}"
1464 );
1465 }
1466 }
1467 dialog.search.clear();
1468 let output = ctx.run_ui(Default::default(), |ui| {
1469 dialog.draw_tabs(ui, &Theme::dark())
1470 });
1471 assert_eq!(output.shapes.iter().any(|shape| {
1472 matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text == "Order flow")
1473 }), expected.iter().any(|accepted| *accepted));
1474 output.drop_without_applying_deltas();
1475 dialog.sel_category = Favorites;
1476 let spread = dialog
1477 .indicators
1478 .iter()
1479 .find(|ind| ind.id == "Quoted Spread")
1480 .unwrap();
1481 assert_eq!(dialog.matches(&ctx, spread), expected[2]);
1482 assert!(dialog.preferences().favorites.contains("Quoted Spread"));
1483 dialog.sel_category = BuiltIns;
1485 dialog.active_tab = Profiles;
1486 let profile = dialog
1487 .indicators
1488 .iter()
1489 .find(|ind| ind.id == "Vol Profile VR")
1490 .unwrap();
1491 assert!(dialog.matches(&ctx, profile));
1492 dialog.set_feed_capabilities(Some(&all), Some(&instrument));
1493 dialog.sel_category = Favorites;
1494 let spread = dialog
1495 .indicators
1496 .iter()
1497 .find(|ind| ind.id == "Quoted Spread")
1498 .unwrap();
1499 assert!(dialog.matches(&ctx, spread));
1500 }
1501
1502 use std::sync::atomic::{AtomicUsize, Ordering};
1505 let builds = Arc::new(AtomicUsize::new(0));
1506 let counted = |kind| {
1507 let builds = builds.clone();
1508 let mut factory = IndicatorFactory::new();
1509 for id in factory.list() {
1510 factory.unregister(&id);
1511 }
1512 factory.register_in(IndicatorFactory::ORDER_FLOW, "Host book", move || {
1513 builds.fetch_add(1, Ordering::Relaxed);
1514 Box::new(OrderFlowStudy::new(kind))
1515 });
1516 factory
1517 };
1518 let mut dialog = IndicatorDialog::new().with_factory(counted(OrderFlowKind::BookSize {
1519 levels: 5,
1520 distance: 0.0,
1521 }));
1522 dialog.set_feed_capabilities(Some(&live), Some(&instrument));
1523 assert!(!dialog.offers(BuiltIns));
1524 let built = builds.load(Ordering::Relaxed);
1525 for _ in 0..3 {
1526 dialog.set_feed_capabilities(Some(&live), Some(&instrument));
1527 ctx.run_ui(Default::default(), |ui| {
1528 dialog.draw_content(ui, &dialog.config.clone(), &Theme::dark());
1529 })
1530 .drop_without_applying_deltas();
1531 }
1532 assert_eq!(builds.load(Ordering::Relaxed), built);
1533 dialog = dialog.with_factory(counted(OrderFlowKind::TradeRate {
1534 volume: false,
1535 seconds: 60,
1536 instant: 3,
1537 }));
1538 assert!(dialog.offers(BuiltIns));
1539 dialog.set_feed_capabilities(Some(&none), Some(&instrument));
1540 assert!(!dialog.offers(BuiltIns));
1541 dialog.set_feed_capabilities(Some(&all), Some(&instrument));
1542 assert!(dialog.offers(BuiltIns));
1543
1544 for name in ["Host SMA", "RSI"] {
1547 let mut factory = factory();
1548 factory.register(name, || Box::new(SMA::new(42)));
1549 let mut dialog = IndicatorDialog::new().with_factory(factory);
1550 assert!(dialog.indicators.iter().any(|i| i.id == name));
1551 let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add(name) else {
1552 panic!("the host's {name} is added");
1553 };
1554 assert_eq!(study.name(), "SMA", "{name}");
1555 }
1556 }
1557
1558 #[test]
1561 fn each_study_is_added_with_its_own_defaults() {
1562 use crate::model::Bar;
1563 use chrono::{TimeZone, Utc};
1564 let bars: Vec<Bar> = (0..300)
1565 .map(|i: i64| {
1566 let x = i as f64;
1567 let close = 100.0 + (x * 0.13).sin() * 8.0 + (x * 0.029).cos() * 5.0;
1568 let open = close - (x * 0.7).sin() * 1.5;
1569 Bar {
1570 time: Utc.timestamp_opt(i * 3_600, 0).unwrap(),
1571 open,
1572 high: close.max(open) + 0.5 + (x * 0.3).sin().abs(),
1573 low: close.min(open) - 0.5 - (x * 0.5).cos().abs(),
1574 close,
1575 volume: 1_000.0 + ((i * 37) % 211) as f64 * 9.0,
1576 colors: None,
1577 payload: None,
1578 }
1579 })
1580 .collect();
1581 let factory = IndicatorFactory::new();
1582 let mut dialog = IndicatorDialog::new();
1583 let mut differ = Vec::new();
1584 for info in dialog.indicators.clone() {
1585 let Some(IndicatorDialogAction::AddIndicator(mut added)) = dialog.add(&info.id) else {
1586 panic!("{} is added", info.id);
1587 };
1588 let mut default = factory.create(&info.id).unwrap();
1589 added.calculate(&bars);
1590 default.calculate(&bars);
1591 if format!("{:?}", added.values()) != format!("{:?}", default.values()) {
1592 differ.push(info.id);
1593 }
1594 }
1595 assert!(differ.is_empty(), "added with other parameters: {differ:?}");
1596 }
1597
1598 #[test]
1599 fn show_keeps_the_configuration_a_caller_sets() {
1600 let custom = IndicatorDialogConfig {
1601 width: 900.0,
1602 text_color: Color32::RED,
1603 ..IndicatorDialogConfig::default()
1604 };
1605 let hover = custom.hover_color;
1606 let mut dialog = IndicatorDialog::new().with_config(custom);
1607 dialog.open();
1608 egui::Context::default()
1609 .run_ui(egui::RawInput::default(), |ui| {
1610 dialog.show(ui.ctx(), &Theme::light());
1611 })
1612 .drop_without_applying_deltas();
1613 assert_eq!(dialog.config.width, 900.0);
1614 assert_eq!(dialog.config.text_color, Color32::RED);
1615 assert_eq!(dialog.config.hover_color, hover);
1616 }
1617}