1use super::{
4 BackgroundType, ButtonVisibility, ChartSettingsState, GridLinesMode, PrecisionMode,
5 WatermarkMode,
6};
7use crate::{
8 Chart,
9 config::{BackgroundStyle, ChartConfig, NavigationButtons},
10 model::CandlesStyle,
11 scales::{PriceScaleMode, TimezoneMode},
12 theme::Theme,
13};
14use egui::Color32;
15
16fn axis_font_size(config: &ChartConfig) -> u8 {
18 config
19 .axis_font_size
20 .unwrap_or(Theme::default().text.label)
21 .round() as u8
22}
23
24fn axis_line_color(config: &ChartConfig) -> Color32 {
26 if config.price_axis.border_visible {
27 config
28 .price_axis
29 .border_color
30 .unwrap_or(Theme::default().ui.border)
31 } else {
32 Color32::TRANSPARENT
33 }
34}
35
36fn button_visibility(buttons: NavigationButtons) -> ButtonVisibility {
37 match buttons {
38 NavigationButtons::Always => ButtonVisibility::AlwaysVisible,
39 NavigationButtons::OnHover => ButtonVisibility::VisibleOnMouseOver,
40 NavigationButtons::Never => ButtonVisibility::Hidden,
41 }
42}
43
44fn navigation_buttons(visibility: ButtonVisibility) -> NavigationButtons {
45 match visibility {
46 ButtonVisibility::AlwaysVisible => NavigationButtons::Always,
47 ButtonVisibility::VisibleOnMouseOver => NavigationButtons::OnHover,
48 ButtonVisibility::Hidden => NavigationButtons::Never,
49 }
50}
51
52#[derive(Clone, Debug, PartialEq)]
55#[non_exhaustive]
56pub enum ChartSettingsError {
57 Timezone(crate::model::ParseError),
59 Margins {
61 top: f32,
63 bottom: f32,
65 },
66 CrosshairWidth(f32),
68 FontSize(u8),
70 PriceToBarRatio(f64),
72 ExecutionSoundVolume(f32),
74 AlertSoundVolume(f32),
76 ChartTypeParams {
78 chart_type: crate::model::ChartType,
80 reason: &'static str,
82 },
83}
84
85impl std::fmt::Display for ChartSettingsError {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str(match self {
88 Self::Timezone(_) => "Invalid timezone",
89 Self::Margins { .. } => "Margins must leave room for prices",
90 Self::CrosshairWidth(_) => "Crosshair width must be above zero",
91 Self::FontSize(_) => "Font sizes must be between 1 and 256",
92 Self::PriceToBarRatio(_) => "Price-to-bar ratio must be positive and finite",
93 Self::ExecutionSoundVolume(_) | Self::AlertSoundVolume(_) => {
94 "Sound volume must be between zero and one"
95 }
96 Self::ChartTypeParams { reason, .. } => reason,
97 })
98 }
99}
100impl std::error::Error for ChartSettingsError {
101 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102 match self {
103 Self::Timezone(error) => Some(error),
104 _ => None,
105 }
106 }
107}
108
109impl ChartSettingsState {
110 pub fn from_chart(chart: &Chart) -> Self {
113 let config = chart.config();
114 let mut settings = Self {
115 chart_type: chart.chart_type(),
116 chart_type_params: Some(chart.chart_type_params().clone()),
117 price_source: Some(chart.config().price_source),
118 events: chart.event_visibility().clone(),
119 order_flow: super::OrderFlowSettings {
120 show_heatmap: chart
121 .feed_link
122 .as_ref()
123 .is_some_and(|link| link.heatmap.is_some()),
124 heatmap: chart.liquidity_heatmap_config(),
125 show_order_book: chart
126 .feed_link
127 .as_ref()
128 .is_some_and(|link| link.book_visible.unwrap_or(link.heatmap.is_some())),
129 order_book: chart.order_book_view(),
130 show_volume_bubbles: chart
131 .feed_link
132 .as_ref()
133 .is_some_and(|link| link.bubbles.is_some()),
134 volume_bubbles: chart
135 .feed_link
136 .as_ref()
137 .map(|link| link.bubbles_config.clone())
138 .unwrap_or_default(),
139 },
140 show_price_targets: chart.price_targets_visible(),
141 ..Self::default()
142 };
143 settings.candle_colors.body_up = config.bullish_color;
144 settings.candle_colors.body_down = config.bearish_color;
145 settings.candle_colors.border_up =
146 config.bullish_border_color.unwrap_or(config.bullish_color);
147 settings.candle_colors.border_down =
148 config.bearish_border_color.unwrap_or(config.bearish_color);
149 settings.candle_colors.wick_up = config
150 .bullish_wick_color
151 .unwrap_or(settings.candle_colors.border_up);
152 settings.candle_colors.wick_down = config
153 .bearish_wick_color
154 .unwrap_or(settings.candle_colors.border_down);
155 let candles = &chart.chart_type_params().styles.candles;
156 settings.candle_colors.show_body = candles.body_visible;
157 settings.candle_colors.show_border = config.candle_border_width > 0.0;
158 settings.candle_colors.show_wick = candles.wick_visible;
159 settings.candle_colors.color_by_previous_close = candles.color_by_previous_close;
160 settings.chart_basic_styles.background_color = config.background_color;
161 settings.grid_lines.horizontal_color = config.grid_color;
162 settings.grid_lines.vertical_color =
163 config.vertical_grid_color.unwrap_or(config.grid_color);
164 settings.status_line.show_logo = config.show_legend_badge;
165 settings.status_line.show_market_status = config.show_market_status;
166 settings.status_line.show_symbol = config.show_symbol_labels;
167 settings.status_line.title_as_description = config.title_as_description;
168 settings.status_line.show_quote_boxes = config.show_quote_boxes;
169 settings.status_line.show_ohlc = config.show_ohlc_info;
170 settings.status_line.show_change = config.show_ohlc_change;
171 settings.status_line.show_change_percent = config.show_ohlc_change_percent;
172 settings.status_line.show_volume = config.show_legend_volume;
173 settings.status_line.show_indicator_titles = config.show_indicator_labels;
174 settings.status_line.show_indicator_inputs = config.show_indicator_inputs;
175 settings.status_line.show_indicator_values = config.show_indicator_last_val;
176 settings.status_line.show_last_day_change = config.show_last_day_change;
177 settings.status_line.indicator_background_opacity =
178 (config.indicator_backdrop_opacity.clamp(0.0, 1.0) * 100.0).round() as u8;
179 settings.scales_and_lines.price_scale_left =
180 !config.show_right_axis && config.show_left_axis;
181 settings.scales_and_lines.show_currency = config.show_currency;
182 settings.scales_and_lines.show_unit = config.show_unit;
183 settings.scales_and_lines.show_scale_mode_buttons = config.show_scale_mode_buttons;
184 settings.scales_and_lines.show_plus_button = config.show_plus_button;
185 settings.scales_and_lines.allow_label_overlap = config.price_axis.allow_label_overlap;
186 settings.scales_and_lines.show_study_values = config.price_axis.show_study_values;
187 settings.scales_and_lines.study_value_color = config.price_axis.study_value_color;
188 settings.scales_and_lines.show_symbol_name = config.price_axis.show_symbol_name;
189 settings.scales_and_lines.show_study_names = config.price_axis.show_study_names;
190 settings.scales_and_lines.show_extended_session_price = config.show_extended_session_price;
191 settings.scales_and_lines.extended_session_price_color =
192 config.extended_session_price_color;
193 settings.scales_and_lines.symbol_price_color = chart
194 .series_style(crate::widget::SeriesId::MAIN)
195 .and_then(|style| style.price_line_color);
196 settings.scales_and_lines.lock_price_to_bar_ratio =
197 chart.state().price_scale().bar_ratio_locked();
198 settings.scales_and_lines.price_to_bar_ratio =
199 chart.state().price_scale().bar_ratio().unwrap_or(1.0);
200 settings.scales_and_lines.show_last_price_label = config.show_symbol_last_val;
201 settings.scales_and_lines.show_price_line = config.show_price_line;
202 settings.scales_and_lines.show_prev_close_label = config.show_symbol_prev_close;
203 settings.scales_and_lines.show_prev_close = config.show_prev_close_line;
204 settings.scales_and_lines.show_countdown = config.show_countdown;
205 settings.scales_and_lines.show_bid_ask = config.show_bid_ask;
206 settings.scales_and_lines.show_high_low = config.show_high_low;
207 settings.margins.top_percent =
208 (chart.state().price_scale().options().scale_margins.top * 100.0) as f32;
209 settings.margins.bottom_percent =
210 (chart.state().price_scale().options().scale_margins.bottom * 100.0) as f32;
211 settings.margins.right_bars = chart.chart_options().time_scale.right_offset.max(0.0) as u32;
212 let scale = chart.state().price_scale();
213 settings.scales_and_lines.auto_scale = !scale.is_manual();
214 settings.scales_and_lines.log_scale = scale.options().mode == PriceScaleMode::Logarithmic;
215 settings.scales_and_lines.percentage_scale =
216 scale.options().mode == PriceScaleMode::Percentage;
217 settings.scales_and_lines.invert_scale = scale.options().invert_scale;
218
219 settings.timezone = match &chart.chart_options().time_scale.timezone {
220 TimezoneMode::Utc => "UTC".into(),
221 TimezoneMode::Local => "Local".into(),
222 TimezoneMode::Timezone(zone) => zone.to_string(),
223 TimezoneMode::Exchange(zone) => format!("Exchange:{zone}"),
224 };
225 settings.grid_lines.mode = match (
226 config.show_grid && config.show_horizontal_grid,
227 config.show_grid && config.show_vertical_grid,
228 ) {
229 (true, true) => GridLinesMode::Both,
230 (true, false) => GridLinesMode::Horizontal,
231 (false, true) => GridLinesMode::Vertical,
232 _ => GridLinesMode::None,
233 };
234 settings.btns.navigation = button_visibility(config.navigation_buttons);
235 let (kind, first, second) = match config.background_style {
236 BackgroundStyle::Solid => (
237 BackgroundType::Solid,
238 config.background_color,
239 config.background_color,
240 ),
241 BackgroundStyle::VerticalGradient {
242 top_color,
243 bottom_color,
244 } => (BackgroundType::VerticalGradient, top_color, bottom_color),
245 BackgroundStyle::HorizontalGradient {
246 left_color,
247 right_color,
248 } => (BackgroundType::HorizontalGradient, left_color, right_color),
249 };
250 settings.chart_basic_styles.background_type = kind;
251 settings.chart_basic_styles.background_gradient_top = first;
252 settings.chart_basic_styles.background_gradient_bottom = second;
253 let crosshair = &chart.chart_options().crosshair;
254 settings.crosshair.color = crosshair.horz_line_color;
255 settings.crosshair.line_width = crosshair.horz_line_width;
256 settings.crosshair.line_style = crosshair.horz_line_style;
257 let decimals = chart
258 .state()
259 .price_scale()
260 .map(Default::default(), 0.0, 1.0)
261 .price_precision();
262 settings.precision = PrecisionMode::all()
263 .iter()
264 .copied()
265 .find(|mode| mode.decimals() == decimals)
266 .unwrap_or_default();
267 settings.watermark.mode = if !config.show_watermark {
268 WatermarkMode::ReplayMode
269 } else if config.watermark_text.is_some() {
270 WatermarkMode::Custom
271 } else if config.watermark_description {
272 WatermarkMode::SymbolAndDescription
273 } else {
274 WatermarkMode::Symbol
275 };
276 settings.watermark.color = config.watermark_color;
277 settings.scales_appearance.text_color = config.text_color;
278 settings.scales_appearance.font_size = axis_font_size(config);
279 settings.scales_appearance.lines_color = axis_line_color(config);
280 settings.btns.pane = button_visibility(config.pane_buttons);
281
282 settings
283 }
284
285 pub(crate) fn validate(&self) -> Result<TimezoneMode, ChartSettingsError> {
286 let timezone = match self.timezone.as_str() {
287 "UTC" => TimezoneMode::Utc,
288 "Local" => TimezoneMode::Local,
289 name => {
290 if let Some(name) = name.strip_prefix("Exchange:") {
291 TimezoneMode::Exchange(name.parse().map_err(ChartSettingsError::Timezone)?)
292 } else {
293 TimezoneMode::Timezone(name.parse().map_err(ChartSettingsError::Timezone)?)
294 }
295 }
296 };
297 let top = self.margins.top_percent;
298 let bottom = self.margins.bottom_percent;
299 if !top.is_finite()
300 || !bottom.is_finite()
301 || top < 0.0
302 || bottom < 0.0
303 || top + bottom >= 100.0
304 {
305 return Err(ChartSettingsError::Margins { top, bottom });
306 }
307 if !self.crosshair.line_width.is_finite() || self.crosshair.line_width <= 0.0 {
308 return Err(ChartSettingsError::CrosshairWidth(
309 self.crosshair.line_width,
310 ));
311 }
312 if self.scales_appearance.font_size == 0 {
313 return Err(ChartSettingsError::FontSize(
314 self.scales_appearance.font_size,
315 ));
316 }
317 if self.scales_and_lines.lock_price_to_bar_ratio
318 && (!self.scales_and_lines.price_to_bar_ratio.is_finite()
319 || self.scales_and_lines.price_to_bar_ratio <= 0.0)
320 {
321 return Err(ChartSettingsError::PriceToBarRatio(
322 self.scales_and_lines.price_to_bar_ratio,
323 ));
324 }
325 let volume = self.trading.execution_sound_volume;
326 if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
327 return Err(ChartSettingsError::ExecutionSoundVolume(volume));
328 }
329 let volume = self.alerts.sound_volume;
330 if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
331 return Err(ChartSettingsError::AlertSoundVolume(volume));
332 }
333 if let Some(params) = &self.chart_type_params
334 && let Some(error) = params.invalid(self.chart_type)
335 {
336 return Err(ChartSettingsError::ChartTypeParams {
337 chart_type: self.chart_type,
338 reason: error,
339 });
340 }
341 Ok(timezone)
342 }
343
344 pub fn apply_to_chart(&self, chart: &mut Chart) -> Result<(), ChartSettingsError> {
348 let timezone = self.validate()?;
349 if let Some(params) = &self.chart_type_params {
350 if let Some(error) = params.invalid(chart.chart_type()) {
351 return Err(ChartSettingsError::ChartTypeParams {
352 chart_type: chart.chart_type(),
353 reason: error,
354 });
355 }
356 chart.set_chart_type_params(params.clone());
357 }
358 if let Some(source) = self.price_source {
359 chart.config_mut().price_source = source;
360 }
361 chart.set_liquidity_heatmap_config(self.order_flow.heatmap);
362 chart.set_liquidity_heatmap(self.order_flow.show_heatmap);
363 chart.set_order_book_view(self.order_flow.order_book.clone());
364 chart.set_order_book_visible(self.order_flow.show_order_book);
365 if let Some(link) = &mut chart.feed_link {
366 link.bubbles_config = self.order_flow.volume_bubbles.clone();
367 }
368 chart.set_volume_bubbles(
369 self.order_flow
370 .show_volume_bubbles
371 .then(|| self.order_flow.volume_bubbles.clone()),
372 );
373 chart.set_event_visibility(self.events.clone());
374 chart.set_price_targets_visible(self.show_price_targets);
375 chart.chart_options.time_scale.timezone = timezone;
376 if self.scales_appearance.lines_color != axis_line_color(chart.config()) {
377 let line = (self.scales_appearance.lines_color != Color32::TRANSPARENT)
379 .then_some(self.scales_appearance.lines_color);
380 chart.config_mut().price_axis.border_visible = line.is_some();
381 chart.config_mut().price_axis.border_color = line;
382 chart.chart_options.time_scale.border_visible = line.is_some();
383 chart.chart_options.time_scale.border_color = line;
384 }
385 let crosshair = &mut chart.chart_options.crosshair;
386 crosshair.horz_line_color = self.crosshair.color;
387 crosshair.vert_line_color = self.crosshair.color;
388 crosshair.horz_line_width = self.crosshair.line_width;
389 crosshair.vert_line_width = self.crosshair.line_width;
390 crosshair.horz_line_style = self.crosshair.line_style;
391 crosshair.vert_line_style = crosshair.horz_line_style;
392 let config = chart.config_mut();
393 config.bullish_color = self.candle_colors.body_up;
394 config.bearish_color = self.candle_colors.body_down;
395 config.bullish_border_color = Some(self.candle_colors.border_up);
396 config.bearish_border_color = Some(self.candle_colors.border_down);
397 config.bullish_wick_color = Some(self.candle_colors.wick_up);
398 config.bearish_wick_color = Some(self.candle_colors.wick_down);
399 if !self.candle_colors.show_border {
401 config.candle_border_width = 0.0;
402 } else if config.candle_border_width <= 0.0 {
403 config.candle_border_width = 1.0;
404 }
405
406 if self.chart_basic_styles.background_color != Color32::PLACEHOLDER {
407 config.background_color = self.chart_basic_styles.background_color;
408 }
409 config.grid_color = self.grid_lines.horizontal_color;
410
411 let (h, v) = match self.grid_lines.mode {
412 GridLinesMode::None => (false, false),
413 GridLinesMode::Horizontal => (true, false),
414 GridLinesMode::Vertical => (false, true),
415 GridLinesMode::Both => (true, true),
416 };
417 config.show_horizontal_grid = h;
418 config.show_vertical_grid = v;
419 config.show_grid = h || v;
420
421 config.navigation_buttons = navigation_buttons(self.btns.navigation);
422 config.pane_buttons = navigation_buttons(self.btns.pane);
423 config.show_ohlc_info = self.status_line.show_ohlc;
424 config.show_legend_badge = self.status_line.show_logo;
425 config.show_market_status = self.status_line.show_market_status;
426 config.show_last_day_change = self.status_line.show_last_day_change;
427 config.indicator_backdrop_opacity =
428 f32::from(self.status_line.indicator_background_opacity.min(100)) / 100.0;
429 config.show_symbol_labels = self.status_line.show_symbol;
430 config.title_as_description = self.status_line.title_as_description;
431 config.show_quote_boxes = self.status_line.show_quote_boxes;
432 config.show_ohlc_change = self.status_line.show_change;
433 config.show_ohlc_change_percent = self.status_line.show_change_percent;
434 config.show_legend_volume = self.status_line.show_volume;
435 config.show_indicator_labels = self.status_line.show_indicator_titles;
436 config.show_indicator_inputs = self.status_line.show_indicator_inputs;
437 config.show_indicator_last_val = self.status_line.show_indicator_values;
438 config.show_right_axis = !self.scales_and_lines.price_scale_left;
439 config.show_left_axis = self.scales_and_lines.price_scale_left;
440 config.show_currency = self.scales_and_lines.show_currency;
441 config.show_unit = self.scales_and_lines.show_unit;
442 config.show_scale_mode_buttons = self.scales_and_lines.show_scale_mode_buttons;
443 config.show_plus_button = self.scales_and_lines.show_plus_button;
444 config.price_axis.allow_label_overlap = self.scales_and_lines.allow_label_overlap;
445 config.price_axis.show_study_values = self.scales_and_lines.show_study_values;
446 config.price_axis.study_value_color = self.scales_and_lines.study_value_color;
447 config.price_axis.show_symbol_name = self.scales_and_lines.show_symbol_name;
448 config.price_axis.show_study_names = self.scales_and_lines.show_study_names;
449 config.show_extended_session_price = self.scales_and_lines.show_extended_session_price;
450 config.extended_session_price_color = self.scales_and_lines.extended_session_price_color;
451 config.lock_price_to_bar_ratio = self.scales_and_lines.lock_price_to_bar_ratio;
452 config.show_symbol_last_val = self.scales_and_lines.show_last_price_label;
453 config.show_price_line = self.scales_and_lines.show_price_line;
454 config.show_symbol_prev_close = self.scales_and_lines.show_prev_close_label;
455 config.show_prev_close_line = self.scales_and_lines.show_prev_close;
456 config.show_countdown = self.scales_and_lines.show_countdown;
457 config.show_bid_ask = self.scales_and_lines.show_bid_ask;
458 config.show_high_low = self.scales_and_lines.show_high_low;
459
460 config.vertical_grid_color = Some(self.grid_lines.vertical_color);
461 let resolved = |color| {
462 if color == Color32::PLACEHOLDER {
463 config.background_color
464 } else {
465 color
466 }
467 };
468 let first = resolved(self.chart_basic_styles.background_gradient_top);
469 let second = resolved(self.chart_basic_styles.background_gradient_bottom);
470 config.background_style = match self.chart_basic_styles.background_type {
471 BackgroundType::Solid => BackgroundStyle::Solid,
472 BackgroundType::VerticalGradient => BackgroundStyle::VerticalGradient {
473 top_color: first,
474 bottom_color: second,
475 },
476 BackgroundType::HorizontalGradient => BackgroundStyle::HorizontalGradient {
477 left_color: first,
478 right_color: second,
479 },
480 };
481 config.text_color = self.scales_appearance.text_color;
482 if self.scales_appearance.font_size != axis_font_size(config) {
484 config.axis_font_size = Some(f32::from(self.scales_appearance.font_size));
485 }
486 config.show_watermark = self.watermark.mode != WatermarkMode::ReplayMode;
487 config.watermark_color = self.watermark.color;
488 if self.watermark.mode != WatermarkMode::Custom {
489 config.watermark_text = None;
490 config.watermark_description =
491 self.watermark.mode == WatermarkMode::SymbolAndDescription;
492 }
493 chart.price_scale_mut().options_mut().scale_margins = crate::scales::PriceScaleMargins {
494 top: f64::from(self.margins.top_percent.clamp(0.0, 100.0)) / 100.0,
495 bottom: f64::from(self.margins.bottom_percent.clamp(0.0, 100.0)) / 100.0,
496 };
497 if self.margins.right_bars != chart.chart_options().time_scale.right_offset.max(0.0) as u32
498 {
499 chart.set_right_margin(self.margins.right_bars as f32);
500 }
501 let mode = if self.scales_and_lines.log_scale {
502 PriceScaleMode::Logarithmic
503 } else if self.scales_and_lines.percentage_scale {
504 PriceScaleMode::Percentage
505 } else if chart.state.price_scale().options().mode == PriceScaleMode::IndexedTo100 {
506 PriceScaleMode::IndexedTo100
507 } else {
508 PriceScaleMode::Normal
509 };
510 let config = chart.config_mut();
513 if config.show_left_axis && !config.show_right_axis {
514 config.left_axis_scale_mode = mode;
515 } else {
516 config.right_axis_scale_mode = mode;
517 }
518 chart.applied_config_scale_mode = mode;
519 if mode != chart.state.price_scale().options().mode {
520 chart.state.price_scale_mut().reset();
521 chart.state.clear_zoom_history();
522 }
523 if self.scales_and_lines.auto_scale {
524 chart.state.price_scale_mut().reset();
525 } else if !chart.state.price_scale().is_manual() {
526 let range = chart.state.price_scale().range();
527 chart
528 .state
529 .price_scale_mut()
530 .set_range(range.min, range.max);
531 }
532 let options = chart.state.price_scale_mut().options_mut();
533 options.mode = mode;
534 options.invert_scale = self.scales_and_lines.invert_scale;
535 let precision = self
536 .precision
537 .decimals()
538 .map(|value| value as u8)
539 .or_else(|| {
540 chart
541 .instrument()
542 .and_then(crate::scales::instrument_price_precision)
543 });
544 chart.state.price_scale_mut().set_price_precision(precision);
545 if let Some(style) = chart.series_style_mut(crate::widget::SeriesId::MAIN) {
546 style.price_line_color = self.scales_and_lines.symbol_price_color;
547 }
548 chart.price_scale_mut().set_bar_ratio(
549 self.scales_and_lines
550 .lock_price_to_bar_ratio
551 .then_some(self.scales_and_lines.price_to_bar_ratio),
552 );
553 chart.last_ratio_lock = self.scales_and_lines.lock_price_to_bar_ratio;
554 let candles = CandlesStyle {
555 body_visible: self.candle_colors.show_body,
556 wick_visible: self.candle_colors.show_wick,
557 color_by_previous_close: self.candle_colors.color_by_previous_close,
558 };
559 if chart.chart_type_params().styles.candles != candles {
561 let mut params = chart.chart_type_params().clone();
562 params.styles.candles = candles;
563 chart.set_chart_type_params(params);
564 }
565 Ok(())
566 }
567}
568
569#[cfg(all(test, feature = "studies"))]
570mod tests {
571 use super::*;
572 use crate::model::{Bar, BarData, Instrument, quote::SessionQuote};
573 use crate::studies::{CustomIndicator, IndicatorValue};
574 use crate::theme::Theme;
575 use egui::{Context, Shape, Vec2};
576
577 #[cfg(feature = "drawings")]
578 struct DepthFeed;
579
580 #[cfg(feature = "drawings")]
581 impl crate::data::Datafeed for DepthFeed {
582 fn resolve(&self, symbol: &str, reply: crate::data::Reply<Instrument>) {
583 reply.send(Ok(Instrument::new(symbol).with_tick_size(1.0)));
584 }
585
586 fn history(
587 &self,
588 _: crate::data::HistoryRequest,
589 reply: crate::data::Reply<crate::data::HistoryPage>,
590 ) {
591 reply.send(Ok(crate::data::HistoryPage::new(
592 vec![Bar::new(
593 chrono::DateTime::UNIX_EPOCH,
594 100.0,
595 110.0,
596 90.0,
597 105.0,
598 0.0,
599 )],
600 true,
601 )));
602 }
603
604 fn subscribe(&self, subscription: crate::data::Subscription) {
605 subscription.sink.push_depth_snapshot(
606 1,
607 crate::model::OrderBook {
608 ts: chrono::DateTime::UNIX_EPOCH,
609 symbol: subscription.symbol,
610 bids: vec![crate::model::OrderBookLevel {
611 price: 99.0,
612 size: 10.0,
613 order_cnt: None,
614 }],
615 asks: vec![],
616 },
617 );
618 }
619 }
620
621 #[test]
624 #[cfg(feature = "drawings")]
625 fn order_flow_settings_round_trip_and_render_independently() {
626 use crate::chart::overlays::{BubbleColor, BubbleGrouping, BubbleScalingMode};
627 use crate::widget::{ChartBuilder, ChartSnapshot};
628 for (kind, clamp_std_devs) in [
629 (crate::model::ChartType::Candles, 3.0),
630 (crate::model::ChartType::Line, f64::INFINITY),
631 ] {
632 for flags in 0..8 {
633 let ctx = Context::default();
634 let mut chart = ChartBuilder::new()
635 .with_feed(std::sync::Arc::new(DepthFeed))
636 .with_symbol("A")
637 .with_timeframe(crate::model::Timeframe::Min1)
638 .with_chart_type(kind)
639 .build();
640 chart.set_size(Some(Vec2::new(800.0, 600.0)));
641 for _ in 0..4 {
642 ctx.run_ui(Default::default(), |ui| {
643 chart.show(ui);
644 })
645 .drop_without_applying_deltas();
646 }
647 let mut settings = ChartSettingsState::from_chart(&chart);
648 assert_eq!(
649 settings.order_flow,
650 super::super::OrderFlowSettings::default()
651 );
652 let flow = &mut settings.order_flow;
653 flow.show_heatmap = flags & 1 != 0;
654 flow.show_order_book = flags & 2 != 0;
655 flow.show_volume_bubbles = flags & 4 != 0;
656 flow.heatmap.brightness = 0.5;
657 flow.heatmap.lower_cutoff = 2.0;
658 flow.heatmap.full_intensity_size = Some(20.0);
659 flow.volume_bubbles.color_by = BubbleColor::Split;
660 flow.volume_bubbles.grouping =
661 BubbleGrouping::TimeWindow(chrono::TimeDelta::milliseconds(250));
662 flow.volume_bubbles.scaling_mode = BubbleScalingMode::SquareRoot;
663 flow.volume_bubbles.min_size_filter = Some(3.0);
664 flow.volume_bubbles.clamp_std_devs = clamp_std_devs;
665 settings.apply_to_chart(&mut chart).unwrap();
666 assert_eq!(chart.chart_type(), kind);
667 assert_eq!(
668 ChartSettingsState::from_chart(&chart).order_flow,
669 settings.order_flow
670 );
671 let output = ctx.run_ui(Default::default(), |ui| {
672 chart.show(ui);
673 });
674 let book_drawn = output.shapes.iter().any(|shape| {
675 matches!(&shape.shape,
676 Shape::Rect(rect) if (rect.rect.width() - 60.0).abs() < 0.01
677 && rect.fill == chart.config().bullish_color.gamma_multiply(0.7))
678 });
679 output.drop_without_applying_deltas();
680 assert_eq!(
681 book_drawn, settings.order_flow.show_order_book,
682 "flags={flags}"
683 );
684 let saved = chart.snapshot().unwrap();
685 let json = serde_json::to_value(&saved).unwrap();
686 let loaded: ChartSnapshot = serde_json::from_value(json.clone()).unwrap();
687 chart.restore(&loaded).unwrap();
688 assert_eq!(
689 ChartSettingsState::from_chart(&chart).order_flow,
690 settings.order_flow
691 );
692 let mut legacy = json;
693 legacy["settings"]
694 .as_object_mut()
695 .unwrap()
696 .remove("order_flow");
697 chart
698 .restore(&serde_json::from_value(legacy).unwrap())
699 .unwrap();
700 assert_eq!(
701 ChartSettingsState::from_chart(&chart).order_flow,
702 super::super::OrderFlowSettings::default()
703 );
704 }
705 }
706 }
707
708 #[test]
709 fn scale_settings_render_values_and_preserve_the_entered_ratio() {
710 for theme in [Theme::light(), Theme::dark()] {
711 for (left, mode) in [
712 (false, PriceScaleMode::Normal),
713 (true, PriceScaleMode::Normal),
714 (false, PriceScaleMode::Logarithmic),
715 (true, PriceScaleMode::Logarithmic),
716 (false, PriceScaleMode::Percentage),
717 (true, PriceScaleMode::Percentage),
718 ] {
719 for width in [320.0, 800.0] {
720 let locked_ratio = if mode == PriceScaleMode::Logarithmic {
721 0.005
722 } else {
723 0.5
724 };
725 let ctx = Context::default();
726 crate::theme::apply_to_egui(&ctx, &theme);
727 let bars = (0..10)
728 .map(|i| {
729 Bar::new(
730 chrono::DateTime::UNIX_EPOCH + chrono::Duration::minutes(i),
731 100.0,
732 120.0,
733 90.0,
734 112.0,
735 0.0,
736 )
737 })
738 .collect();
739 let mut chart = Chart::new(BarData::from_bars(bars));
740 chart.set_size(Some(Vec2::new(width, 600.0)));
741 chart.set_instrument(Some(
742 Instrument::new("SAMPLE")
743 .with_currency("USD")
744 .with_unit("contracts per barrel"),
745 ));
746 chart.set_session_quote(Some(SessionQuote::new(115.0, "EXT")));
747 chart.add_indicator(Box::new(CustomIndicator::new("Constant", |bars| {
748 vec![IndicatorValue::Single(110.0); bars.len()]
749 })));
750 for _ in 0..2 {
751 ctx.run_ui(Default::default(), |ui| {
752 chart.show(ui);
753 })
754 .drop_without_applying_deltas();
755 }
756 let mut settings = ChartSettingsState::from_chart(&chart);
757 let scales = &mut settings.scales_and_lines;
758 scales.price_scale_left = left;
759 scales.log_scale = mode == PriceScaleMode::Logarithmic;
760 scales.percentage_scale = mode == PriceScaleMode::Percentage;
761 scales.show_scale_mode_buttons = false;
762 scales.show_plus_button = false;
763 scales.allow_label_overlap = true;
764 scales.auto_scale = false;
765 scales.lock_price_to_bar_ratio = true;
766 scales.price_to_bar_ratio = locked_ratio;
767 scales.symbol_price_color = Some(Color32::GOLD);
768 scales.study_value_color = Some(Color32::PURPLE);
769 scales.extended_session_price_color = Color32::LIGHT_RED;
770 settings.apply_to_chart(&mut chart).unwrap();
771 assert_eq!(
772 ChartSettingsState::from_chart(&chart).scales_and_lines,
773 settings.scales_and_lines
774 );
775 for (currency, unit, shown) in [
776 (true, true, true),
777 (true, false, true),
778 (false, true, true),
779 (false, false, false),
780 ] {
781 settings.scales_and_lines.show_currency = currency;
782 settings.scales_and_lines.show_unit = unit;
783 settings.scales_and_lines.show_study_values = shown;
784 settings.scales_and_lines.show_last_price_label = shown;
785 settings.scales_and_lines.show_extended_session_price = shown;
786 settings.apply_to_chart(&mut chart).unwrap();
787 for frame in 0..3 {
788 let mut output = ctx.run_ui(Default::default(), |ui| {
789 chart.show(ui);
790 });
791 let shapes = std::mem::take(&mut output.shapes);
792 output.drop_without_applying_deltas();
793 if frame == 2 {
794 let plot = chart.last_rendered_price_rect;
795 let expected = match (currency, unit) {
796 (true, true) => Some("USD · contracts per barrel"),
797 (true, false) => Some("USD"),
798 (false, true) => Some("contracts per barrel"),
799 _ => None,
800 };
801 let header = shapes.iter().find_map(|s| match &s.shape {
802 Shape::Text(t)
803 if [
804 "USD · contracts per barrel",
805 "USD",
806 "contracts per barrel",
807 ]
808 .contains(&t.galley.text()) =>
809 {
810 Some(t.galley.text())
811 }
812 _ => None,
813 });
814 assert_eq!(header, expected);
815 if let Some(expected) = expected {
816 let header_fits = shapes.iter().any(|s| match &s.shape {
817 Shape::Text(t) if t.galley.text() == expected => {
818 s.clip_rect.contains_rect(egui::Rect::from_min_size(
819 t.pos,
820 t.galley.size(),
821 ))
822 }
823 _ => false,
824 });
825 assert!(header_fits, "unit header must fit its axis column");
826 }
827 for color in [Color32::GOLD, Color32::PURPLE, Color32::LIGHT_RED] {
828 let tag = shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == color && (r.rect.right() <= plot.left() || r.rect.left() >= plot.right())));
829 assert_eq!(tag, shown, "{color:?}, left={left}");
830 }
831 let scale = chart.state().price_scale();
834 let span = scale.range().max - scale.range().min;
835 let ratio = span
836 * f64::from(
837 chart.state().time_scale().bar_spacing() / plot.height(),
838 );
839 assert!((ratio - locked_ratio).abs() < 1e-6, "{ratio}");
840 if mode == PriceScaleMode::Logarithmic {
841 assert!(
842 (chart
843 .last_price_map
844 .transform()
845 .to_scale(std::f64::consts::E)
846 - 1.0)
847 .abs()
848 < 1e-12
849 );
850 }
851 }
852 }
853 }
854 for invalid in [0.0, -1.0, f64::NAN, f64::INFINITY] {
855 settings.scales_and_lines.price_to_bar_ratio = invalid;
856 assert!(settings.apply_to_chart(&mut chart).is_err());
857 assert_eq!(chart.state().price_scale().bar_ratio(), Some(locked_ratio));
858 }
859 }
860 }
861 }
862 }
863
864 #[test]
867 fn scale_appearance_and_pane_button_settings_render_and_read_back() {
868 use crate::studies::RSI;
869 use crate::widget::test_frames::{chart, frame, shapes};
870 for (visibility, maximized) in [
871 (ButtonVisibility::AlwaysVisible, false),
872 (ButtonVisibility::VisibleOnMouseOver, false),
873 (ButtonVisibility::VisibleOnMouseOver, true),
874 (ButtonVisibility::Hidden, false),
875 (ButtonVisibility::Hidden, true),
876 ] {
877 let ctx = Context::default();
878 let mut chart = chart(&[100.0, 105.0], &[110.0, 115.0]);
879 let study = chart.add_indicator(Box::new(RSI::new(14)));
880 frame(&ctx, &mut chart, 0.0, Vec::new());
881 chart.pane_layout.maximized = maximized.then(|| chart.series_pane(study).unwrap());
882 let mut settings = ChartSettingsState::from_chart(&chart);
883 settings.scales_appearance.font_size = 18;
884 settings.scales_appearance.lines_color = Color32::RED;
885 settings.btns.pane = visibility;
886 settings.apply_to_chart(&mut chart).unwrap();
887 assert_eq!(
888 ChartSettingsState::from_chart(&chart).scales_appearance,
889 settings.scales_appearance
890 );
891 assert_eq!(ChartSettingsState::from_chart(&chart).btns, settings.btns);
892 frame(&ctx, &mut chart, 0.1, Vec::new());
893 let painted = shapes(&ctx, &mut chart, 0.2, Vec::new());
894 let pane = chart.get_rendered_indicator_panes()[0].clone();
895 let plot = if maximized {
896 pane.chart_rect
897 } else {
898 chart.get_rendered_price_rect()
899 };
900 for rect in [plot, pane.chart_rect] {
901 let axis_text = painted.iter().filter_map(|shape| match shape {
902 Shape::Text(text)
903 if text.pos.x >= rect.right() && rect.y_range().contains(text.pos.y) =>
904 {
905 Some(text.galley.job.sections[0].format.font_id.size)
906 }
907 _ => None,
908 });
909 let sizes: Vec<_> = axis_text.collect();
910 assert!(
911 !sizes.is_empty() && sizes.iter().all(|size| *size == 18.0),
912 "{sizes:?}"
913 );
914 let line = painted.iter().any(|shape| {
915 matches!(shape, Shape::LineSegment { points, stroke }
916 if stroke.color == Color32::RED
917 && points[0].x == points[1].x
918 && points[0].x >= rect.right()
919 && rect.y_range().contains(points[0].y.max(points[1].y) - 1.0))
920 });
921 assert!(line, "axis line beside {rect:?}");
922 }
923 let button = egui::Rect::from_min_size(
924 egui::Pos2::new(pane.chart_rect.right() - 22.0, pane.panel_rect.top() + 2.0),
925 Vec2::splat(20.0),
926 );
927 let menu = painted.iter().any(|shape| {
928 matches!(shape, Shape::Text(text)
929 if text.galley.text() == "..." && pane.panel_rect.contains(text.pos))
930 });
931 assert_eq!(menu, visibility == ButtonVisibility::AlwaysVisible);
932 let icon = painted
933 .iter()
934 .any(|shape| matches!(shape, Shape::Rect(rect) if button.contains_rect(rect.rect)));
935 assert_eq!(
936 icon,
937 visibility == ButtonVisibility::AlwaysVisible
938 || (visibility == ButtonVisibility::VisibleOnMouseOver && maximized),
939 "{visibility:?}, maximized={maximized}"
940 );
941 settings.scales_appearance.font_size = 0;
943 assert!(settings.apply_to_chart(&mut chart).is_err());
944 }
945 }
946
947 #[test]
952 fn watermark_modes_follow_symbol_and_interval_changes() {
953 use crate::model::Timeframe;
954 use crate::widget::test_frames::{chart, shapes};
955 let color = Color32::from_rgb(1, 2, 3);
956 for mode in WatermarkMode::all().iter().copied() {
957 let ctx = Context::default();
958 let mut chart = chart(&[100.0], &[110.0]);
959 chart.config_mut().watermark_text =
960 (mode == WatermarkMode::Custom).then(|| "Desk 4\nNight".to_owned());
961 let mut settings = ChartSettingsState::from_chart(&chart);
962 settings.watermark.mode = mode;
963 settings.watermark.color = color;
964 settings.apply_to_chart(&mut chart).unwrap();
965 for (symbol, interval, description, title) in [
966 ("AAPL", Timeframe::Day1, "Apple Inc", "AAPL, 1D"),
967 ("MSFT", Timeframe::Day1, "Microsoft Corp", "MSFT, 1D"),
968 ("MSFT", Timeframe::Hour4, "Microsoft Corp", "MSFT, 4h"),
969 ] {
970 chart.set_symbol(symbol);
971 chart.set_timeframe(interval);
972 chart.set_instrument(Some(Instrument::new(symbol).with_description(description)));
973 let painted: Vec<_> = shapes(&ctx, &mut chart, 0.0, Vec::new())
974 .into_iter()
975 .filter_map(|shape| match shape {
976 Shape::Text(text) if text.fallback_color == color => Some((
977 text.galley.text().to_owned(),
978 text.galley.job.sections[0].format.font_id.size,
979 )),
980 _ => None,
981 })
982 .collect();
983 let expected: Vec<(String, f32)> = match mode {
984 WatermarkMode::ReplayMode => vec![],
985 WatermarkMode::Symbol => vec![(title.into(), 96.0)],
986 WatermarkMode::SymbolAndDescription => {
987 vec![(title.into(), 96.0), (description.into(), 44.0)]
988 }
989 WatermarkMode::Custom => vec![("Desk 4".into(), 48.0), ("Night".into(), 48.0)],
990 };
991 assert_eq!(painted, expected, "{mode:?} {title}");
992 assert_eq!(
993 ChartSettingsState::from_chart(&chart).watermark.mode,
994 mode,
995 "{title}"
996 );
997 }
998 }
999 }
1000}