1use crate::chart::rendering;
4use crate::data::FeedState;
5use crate::model::{
6 AreaStyle, Bar, BaselineStyle, ChartType, ChartTypeParams, ChartTypeStyles, LinePlotStyle,
7 LineStyle, Marker, MarkerOptions, PriceLine, SeriesScale, SeriesStyle,
8};
9use crate::scales::StudyFormat;
10use crate::scales::{PriceMap, PriceScale, PriceScaleMode};
11use crate::services::SymbolExpression;
12use crate::studies::{Indicator, IndicatorValues, PlotStyle, PlotType, StudyDataRequest};
13use crate::tokens::DESIGN_TOKENS;
14use crate::widget::{Chart, ChartElementId, PaneId, SeriesId};
15use egui::Color32;
16use serde::{Deserialize, Serialize};
17
18fn spread_of(name: &str) -> Option<SymbolExpression> {
20 SymbolExpression::parse(name)
21 .ok()
22 .filter(SymbolExpression::is_spread)
23}
24
25fn fed_symbols(name: &str) -> Vec<String> {
27 match SymbolExpression::parse(name) {
28 Ok(expression) => expression
29 .symbols()
30 .into_iter()
31 .map(str::to_owned)
32 .collect(),
33 Err(_) => vec![name.to_owned()],
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct SeriesOptions {
40 pub name: String,
42 pub chart_type: ChartType,
46 pub color: Color32,
49 pub up_color: Color32,
51 pub down_color: Color32,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub baseline_style: Option<BaselineStyle>,
57 pub width: f32,
59 #[serde(default)]
64 pub marker_radius: Option<f32>,
65 pub scale: SeriesScale,
68 pub columns_base: Option<f64>,
71 pub style: SeriesStyle,
74}
75
76impl Default for SeriesOptions {
77 fn default() -> Self {
78 Self {
79 name: String::new(),
80 chart_type: ChartType::Line,
81 color: Color32::from_rgb(41, 98, 255),
82 up_color: DESIGN_TOKENS.semantic.extended.bullish,
83 down_color: DESIGN_TOKENS.semantic.extended.bearish,
84 baseline_style: None,
85 width: 2.0,
86 marker_radius: None,
87 scale: SeriesScale::Right,
88 columns_base: None,
89 style: SeriesStyle {
90 price_line_visible: false,
91 ..SeriesStyle::default()
92 },
93 }
94 }
95}
96
97impl SeriesOptions {
98 fn sanitized(mut self) -> Self {
99 if !self.width.is_finite() || self.width <= 0.0 {
100 self.width = 2.0;
101 }
102 if self.chart_type.is_time_independent()
103 || self.chart_type == ChartType::TimePriceOpportunity
104 {
105 self.chart_type = ChartType::Line;
106 }
107 self
108 }
109
110 pub(super) fn type_params(&self) -> ChartTypeParams {
113 let line = |color| LinePlotStyle {
114 color,
115 width: self.width,
116 style: LineStyle::Solid,
117 ..Default::default()
118 };
119 let fill = |color: Color32, alpha| {
120 Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha)
121 };
122 let mut baseline = self
123 .baseline_style
124 .clone()
125 .unwrap_or_else(|| BaselineStyle {
126 above: line(self.up_color),
127 below: line(self.down_color),
128 above_fill: fill(self.up_color, 30),
129 below_fill: fill(self.down_color, 30),
130 ..Default::default()
131 });
132 if let Some(radius) = self.marker_radius {
133 baseline.marker_radius = radius;
134 }
135 ChartTypeParams {
136 styles: ChartTypeStyles {
137 line: line(self.color),
138 step_line: line(self.color),
139 line_with_markers: line(self.color),
140 marker_radius: self.marker_radius.unwrap_or(3.0),
141 area: AreaStyle {
142 line: line(self.color),
143 marker_radius: self.marker_radius.unwrap_or(0.0),
144 top_fill: fill(self.color, 115),
145 bottom_fill: Color32::TRANSPARENT,
146 ..Default::default()
147 },
148 baseline,
149 ..Default::default()
150 },
151 columns_base: self.columns_base,
152 ..Default::default()
153 }
154 }
155}
156
157pub(in crate::widget) fn base_line(map: &PriceMap) -> Option<PriceLine> {
159 let transform = map.transform();
160 matches!(
161 transform.mode,
162 PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100
163 )
164 .then(|| {
165 PriceLine::new(transform.base, DESIGN_TOKENS.semantic.extended.gray)
166 .with_width(DESIGN_TOKENS.stroke.hairline)
167 .with_style(LineStyle::Solid)
168 .with_axis_label(false)
169 })
170}
171
172pub(in crate::widget) struct PriceSeries {
173 pub id: SeriesId,
174 pub options: SeriesOptions,
175 pub visible: bool,
176 pub fed: bool,
178 pub bars: Vec<Bar>,
179 pub revision: u64,
181 pub heikin_ashi: rendering::candles::HeikinAshiCache,
182 pub feed: FeedState,
183 pub markers: crate::widget::marks::TimeOrdered<Marker>,
184 pub marker_options: MarkerOptions,
185 pub change_marks: Option<crate::widget::change_marks::ChangeMarks>,
186 pub lines: Vec<(crate::widget::PriceLineId, PriceLine)>,
187}
188
189impl PriceSeries {
190 pub(in crate::widget) fn extremes(&self, bar: &Bar) -> [f64; 2] {
192 if self.options.chart_type.uses_ohlc() {
193 [bar.low, bar.high]
194 } else {
195 [bar.close; 2]
196 }
197 }
198
199 pub(in crate::widget) fn value_color(&self, bar: &Bar) -> Color32 {
201 if let Some(color) = bar.colors.as_ref().and_then(|own| own.color) {
202 return color;
203 }
204 match self.options.chart_type.uses_ohlc() {
205 true if bar.close >= bar.open => self.options.up_color,
206 true => self.options.down_color,
207 false => self.options.color,
208 }
209 }
210}
211
212#[derive(Clone)]
217pub(in crate::widget) struct SeriesLegend {
218 name: String,
219 style: PlotStyle,
220 close: Option<f64>,
221 values: IndicatorValues,
222 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
223 precision: Option<usize>,
225 thousands_sep: Option<char>,
227}
228
229impl SeriesLegend {
230 pub(in crate::widget) fn new(
231 item: &PriceSeries,
232 close: Option<f64>,
233 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
234 precision: Option<usize>,
235 thousands_sep: Option<char>,
236 ) -> Self {
237 Self {
238 name: item.options.name.clone(),
239 style: PlotStyle {
240 color: item.options.color,
241 width: item.options.width,
242 pattern: LineStyle::Solid,
243 plot_type: PlotType::Line,
244 visible: true,
245 },
246 close,
247 formatter,
248 precision,
249 thousands_sep,
250 values: IndicatorValues::default(),
251 }
252 }
253}
254
255impl Indicator for SeriesLegend {
256 fn plot_style(&self, _line: usize) -> PlotStyle {
257 self.style
258 }
259
260 fn plot_value(&self, _line: usize, _bar: usize) -> Option<f64> {
261 self.close
262 }
263
264 fn format_value(&self, value: f64) -> String {
265 self.formatter.as_ref().map_or_else(
266 || {
267 crate::scales::group_thousands(
268 StudyFormat::Price.format(value, self.precision),
269 self.thousands_sep,
270 )
271 },
272 |formatter| formatter.format(value),
273 )
274 }
275
276 fn value_format(&self) -> StudyFormat {
277 StudyFormat::Price
278 }
279
280 fn name(&self) -> &str {
281 &self.name
282 }
283
284 fn calculate(&mut self, _data: &[Bar]) {}
285
286 fn values(&self) -> &IndicatorValues {
287 &self.values
288 }
289
290 fn colors(&self) -> Vec<Color32> {
291 vec![self.style.color]
292 }
293
294 fn set_colors(&mut self, _colors: Vec<Color32>) {}
295
296 fn line_names(&self) -> Vec<String> {
297 vec![self.name.clone()]
298 }
299
300 fn set_visible(&mut self, _visible: bool) {}
301
302 fn clone_box(&self) -> Box<dyn Indicator> {
303 Box::new(self.clone())
304 }
305}
306
307impl Chart {
308 pub fn add_series(&mut self, options: SeriesOptions) -> SeriesId {
314 let options = options.sanitized();
315 let id = SeriesId(self.next_series);
316 self.next_series += 1;
317 self.ensure_series_scale(options.scale);
318 self.price_series.push(PriceSeries {
319 id,
320 options,
321 visible: true,
322 fed: false,
323 bars: Vec::new(),
324 revision: crate::model::next_revision(),
325 heikin_ashi: Default::default(),
326 feed: FeedState::default(),
327 markers: Default::default(),
328 marker_options: MarkerOptions::default(),
329 change_marks: None,
330 lines: Vec::new(),
331 });
332 self.host_events
333 .push(crate::widget::ChartEvent::SeriesAdded(id));
334 id
335 }
336
337 pub fn set_series_options(&mut self, id: SeriesId, options: SeriesOptions) -> bool {
345 let Some(at) = self.price_series.iter().position(|item| item.id == id) else {
346 return false;
347 };
348 let options = options.sanitized();
349 self.ensure_series_scale(options.scale);
350 let renamed = self.price_series[at].options.name != options.name;
351 self.price_series[at].options = options;
352 if renamed && self.is_fed(id) {
353 self.set_data(id, Vec::new());
354 self.fill_fed_series();
355 }
356 self.prune_series_scales();
357 true
358 }
359
360 pub fn is_fed(&self, id: SeriesId) -> bool {
363 self.feed_link.is_some()
364 && self
365 .price_series
366 .iter()
367 .any(|item| item.id == id && item.fed)
368 }
369
370 pub(in crate::widget) fn fed_series(&self) -> Vec<(SeriesId, String)> {
372 self.price_series
373 .iter()
374 .filter(|item| self.is_fed(item.id))
375 .map(|item| (item.id, item.options.name.clone()))
376 .collect()
377 }
378
379 pub(in crate::widget) fn fed_requests(&self) -> Vec<StudyDataRequest> {
381 let mut requests = Vec::new();
382 for (_, name) in self.fed_series() {
383 for symbol in fed_symbols(&name) {
384 let request = StudyDataRequest {
385 symbol,
386 timeframe: Some(self.timeframe().unwrap_or_default()),
387 metric: None,
388 };
389 if !requests.contains(&request) {
390 requests.push(request);
391 }
392 }
393 }
394 requests
395 }
396
397 pub(in crate::widget) fn fill_fed_series(&mut self) {
399 let fed = self.fed_series();
400 if fed.is_empty() {
401 return;
402 }
403 let updates: Vec<_> = {
404 let data =
405 self.study_data
406 .data(self.fed_requests(), self.symbol(), self.timeframe(), None);
407 let history = |symbol: &str| {
408 data.iter()
409 .find(|data| data.request.symbol == symbol)
410 .map(|data| data.bars)
411 };
412 fed.iter()
413 .map(|(id, name)| {
414 let bars = match spread_of(name) {
415 Some(spread) => spread.bars(history).unwrap_or_default(),
416 None => fed_symbols(name)
417 .first()
418 .and_then(|symbol| history(symbol))
419 .unwrap_or_default()
420 .to_vec(),
421 };
422 (*id, bars)
423 })
424 .collect()
425 };
426 for (id, bars) in updates {
427 if self.bars(id) != bars.as_slice() {
428 self.set_data(id, bars);
429 }
430 }
431 }
432
433 pub fn series_order(&self, id: SeriesId) -> Option<usize> {
437 self.price_series.iter().position(|item| item.id == id)
438 }
439
440 pub fn set_series_order(&mut self, id: SeriesId, order: usize) -> bool {
443 let Some(at) = self.series_order(id) else {
444 return false;
445 };
446 let item = self.price_series.remove(at);
447 self.price_series
448 .insert(order.min(self.price_series.len()), item);
449 true
450 }
451
452 pub fn add_feed_series(&mut self, options: SeriesOptions) -> SeriesId {
460 let id = self.add_series(options);
461 self.price_series.last_mut().expect("added series").fed = self.feed_link.is_some();
462 self.fill_fed_series();
463 id
464 }
465
466 pub fn add_comparison(&mut self, name: impl Into<String>, color: Color32) -> SeriesId {
469 self.state.price_scale_mut().options_mut().mode = PriceScaleMode::Percentage;
470 self.state.price_scale_mut().reset();
471 self.add_series(SeriesOptions {
472 name: name.into(),
473 color,
474 scale: self.main_series_scale,
475 ..Default::default()
476 })
477 }
478
479 pub fn series(&self) -> impl Iterator<Item = (SeriesId, &SeriesOptions)> {
481 self.price_series
482 .iter()
483 .map(|item| (item.id, &item.options))
484 }
485
486 pub fn is_series_visible(&self, id: SeriesId) -> bool {
488 if id == SeriesId::MAIN {
489 self.main_series_present && self.main_series_visible
490 } else if let Some(item) = self.price_series.iter().find(|item| item.id == id) {
491 item.visible
492 } else {
493 self.indicators()
494 .any(|(current, study)| current == id && study.is_visible())
495 }
496 }
497
498 pub fn set_series_visible(&mut self, id: SeriesId, visible: bool) -> bool {
501 if id == SeriesId::MAIN {
502 if !self.main_series_present {
503 return false;
504 }
505 self.set_main_series_visible(visible);
506 } else if let Some(item) = self.price_series.iter_mut().find(|item| item.id == id) {
507 item.visible = visible;
508 if !visible && self.selection.selected_id() == Some(ChartElementId::Series(id)) {
509 self.selection.deselect();
510 }
511 } else {
512 return self.set_indicator_visible(id, visible);
513 }
514 true
515 }
516
517 pub fn set_series_scale(&mut self, id: SeriesId, scale: SeriesScale) -> bool {
520 if id == SeriesId::MAIN && self.main_series_present {
521 if scale == self.main_series_scale {
522 return true;
523 }
524 self.ensure_series_scale(scale);
525 let next = self
526 .series_scales
527 .remove(&scale)
528 .expect("scale was inserted");
529 let old = std::mem::replace(self.state.price_scale_mut(), next);
530 if self.state.price_scale().is_manual() {
532 let range = self.state.price_scale().range();
533 self.state.fit_price_scale(std::iter::empty());
534 self.state.price_scale_mut().set_range(range.min, range.max);
535 }
536 self.series_scales.insert(self.main_series_scale, old);
537 self.main_series_scale = scale;
538 self.config.show_left_axis = scale == SeriesScale::Left;
539 self.config.show_right_axis =
540 matches!(scale, SeriesScale::Right | SeriesScale::Separate(_));
541 } else if let Some(at) = self.price_series.iter().position(|item| item.id == id) {
542 self.ensure_series_scale(scale);
543 self.price_series[at].options.scale = scale;
544 } else {
545 return false;
546 }
547 self.prune_series_scales();
548 true
549 }
550
551 pub fn series_scale(&self, id: SeriesId) -> Option<SeriesScale> {
553 if id == SeriesId::MAIN && self.main_series_present {
554 Some(self.main_series_scale)
555 } else {
556 self.price_series
557 .iter()
558 .find(|item| item.id == id)
559 .map(|item| item.options.scale)
560 }
561 }
562
563 pub fn series_style(&self, id: SeriesId) -> Option<&SeriesStyle> {
566 if id == SeriesId::MAIN && self.main_series_present {
567 Some(&self.main_series_style)
568 } else {
569 self.price_series
570 .iter()
571 .find(|item| item.id == id)
572 .map(|item| &item.options.style)
573 }
574 }
575
576 pub fn series_style_mut(&mut self, id: SeriesId) -> Option<&mut SeriesStyle> {
579 if id == SeriesId::MAIN && self.main_series_present {
580 Some(&mut self.main_series_style)
581 } else {
582 self.price_series
583 .iter_mut()
584 .find(|item| item.id == id)
585 .map(|item| &mut item.options.style)
586 }
587 }
588
589 pub fn series_price_scale(&self, id: SeriesId) -> Option<&PriceScale> {
591 let scale = self.series_scale(id)?;
592 Some(
593 match self
594 .pane_layout
595 .panes
596 .iter()
597 .find(|pane| pane.members.contains(&id))
598 {
599 Some(pane) => &pane.scale,
600 None => self.secondary_scale(scale),
601 },
602 )
603 }
604
605 pub fn series_price_scale_mut(&mut self, id: SeriesId) -> Option<&mut PriceScale> {
609 let scale = self.series_scale(id)?;
610 if let Some(at) = self
611 .pane_layout
612 .panes
613 .iter()
614 .position(|pane| pane.members.contains(&id))
615 {
616 Some(&mut self.pane_layout.panes[at].scale)
617 } else if scale == self.main_series_scale {
618 Some(self.state.price_scale_mut())
619 } else {
620 self.series_scales.get_mut(&scale)
621 }
622 }
623
624 pub fn series_price_map(&self, id: SeriesId) -> Option<PriceMap> {
629 if self.series_pane(id)? != PaneId::MAIN {
630 return self
631 .pane_layout
632 .panes
633 .iter()
634 .find(|pane| pane.members.contains(&id) && pane.rect.is_positive())
635 .map(|pane| {
636 self.series_display_map(id, pane.price_map())
637 .with_thousands_sep(self.config.thousands_sep)
638 });
639 }
640 let rect = self.last_rendered_price_rect;
641 if !rect.is_positive() {
642 None
643 } else if id == SeriesId::MAIN {
644 Some(self.last_price_map)
645 } else {
646 let item = self.price_series.iter().find(|item| item.id == id)?;
647 Some(self.series_map(item, rect))
648 }
649 }
650
651 pub fn format_series_price(&self, id: SeriesId, price: f64) -> Option<String> {
655 let map = self.series_price_map(id)?;
656 let transform = map.transform();
657 Some(map.label(
658 transform.to_scale(price),
659 map.price_precision().unwrap_or(2),
660 self.series_label_formatter(id, transform.mode).as_deref(),
661 ))
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn baseline_marker_override_preserves_explicit_gradient_style() {
671 let style = BaselineStyle {
672 marker_radius: 4.0,
673 above_fill_at_base: Some(Color32::TRANSPARENT),
674 below_fill_at_base: Some(Color32::GOLD),
675 ..Default::default()
676 };
677 for radius in [None, Some(0.0), Some(7.0)] {
678 let options = SeriesOptions {
679 baseline_style: Some(style.clone()),
680 marker_radius: radius,
681 ..Default::default()
682 };
683 let mut expected = style.clone();
684 expected.marker_radius = radius.unwrap_or(style.marker_radius);
685 assert_eq!(options.type_params().styles.baseline, expected);
686 assert_eq!(
687 serde_json::from_value::<SeriesOptions>(serde_json::to_value(&options).unwrap())
688 .unwrap(),
689 options
690 );
691 }
692 }
693
694 #[test]
695 fn added_baseline_series_keep_explicit_styles_and_legacy_colours() {
696 for gradient in [false, true] {
697 let mut saved = serde_json::to_value(SeriesOptions::default()).unwrap();
698 saved["up_color"] = serde_json::json!([0, 0, 255, 255]);
699 saved["down_color"] = serde_json::json!([255, 0, 0, 255]);
700 if gradient {
701 saved["baseline_style"] = serde_json::json!({
702 "above_fill": [0, 200, 0, 200], "above_fill_at_base": [0, 40, 0, 40],
703 "below_fill": [200, 0, 0, 200], "below_fill_at_base": [40, 0, 0, 40]
704 });
705 }
706 let options: SeriesOptions = serde_json::from_value(saved).unwrap();
707 let params = options.type_params();
708 if gradient {
709 let actual = serde_json::to_value(¶ms.styles.baseline).unwrap();
710 assert_eq!(actual["above_fill"], serde_json::json!([0, 200, 0, 200]));
711 assert_eq!(
712 actual["above_fill_at_base"],
713 serde_json::json!([0, 40, 0, 40])
714 );
715 assert_eq!(actual["below_fill"], serde_json::json!([200, 0, 0, 200]));
716 assert_eq!(
717 actual["below_fill_at_base"],
718 serde_json::json!([40, 0, 0, 40])
719 );
720 } else {
721 assert_eq!(params.styles.baseline.above.color, Color32::BLUE);
722 assert_eq!(params.styles.baseline.below.color, Color32::RED);
723 assert_eq!(
724 params.styles.baseline.above_fill,
725 Color32::from_rgba_unmultiplied(0, 0, 255, 30)
726 );
727 assert_eq!(
728 params.styles.baseline.below_fill,
729 Color32::from_rgba_unmultiplied(255, 0, 0, 30)
730 );
731 }
732 assert_eq!(
733 serde_json::from_value::<SeriesOptions>(serde_json::to_value(&options).unwrap())
734 .unwrap(),
735 options
736 );
737 }
738 }
739}