Skip to main content

egui_charts/studies/
template.rs

1//! Serializable inputs and presentation of studies, alone or as a chart's set.
2
3use crate::studies::{
4    ConfiguredStudy, Indicator, IndicatorFactory, InputValue, StudyScale, StudySettings,
5};
6
7/// Serializable inputs and presentation of one study. Hosts may resolve `name` through their
8/// own study factory when they supply custom studies.
9#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
10pub struct StudyTemplate {
11    /// Study factory name.
12    pub name: String,
13    /// Typed inputs keyed by their stable study-local identifiers.
14    pub inputs: Vec<(String, InputValue)>,
15    /// Plot styles, precision and interval visibility.
16    pub settings: StudySettings,
17    /// Whether the study is visible.
18    pub visible: bool,
19    /// The value scale, or `None` to keep the study's own, as templates saved before the scale
20    /// was kept do.
21    #[serde(default)]
22    pub scale: Option<StudyScale>,
23}
24
25/// The studies of a chart, with the links between them and the panes they share, as
26/// `Chart::study_template_set` saves them and `Chart::apply_study_template_set` applies them.
27///
28/// An input that reads another study's plot (`StudySource::Study`) names that study by its
29/// position in [`studies`](Self::studies), and a position past the end names no study. A
30/// position appears at most once in [`price_pane`](Self::price_pane) and
31/// [`panes`](Self::panes) together; a study listed in neither is placed where
32/// `Chart::add_indicator` places a new study. Legend captions, the price auto-scale choice,
33/// pane heights and the place of the price pane among the panes are not kept.
34///
35/// A list of study templates also reads as a set: its sources name positions in the list and
36/// every study takes its default place.
37#[derive(Clone, Debug, Default, serde::Serialize)]
38#[non_exhaustive]
39pub struct StudyTemplateSet {
40    /// The studies, in the order they were added to the chart.
41    pub studies: Vec<StudyTemplate>,
42    /// Positions in `studies` of the studies drawn on the price pane.
43    pub price_pane: Vec<usize>,
44    /// The other panes that hold studies of the set, from top to bottom, each with the
45    /// positions in `studies` of its studies in legend order.
46    pub panes: Vec<Vec<usize>>,
47}
48
49impl<'de> serde::Deserialize<'de> for StudyTemplateSet {
50    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
51        #[derive(serde::Deserialize)]
52        struct Fields {
53            studies: Vec<StudyTemplate>,
54            #[serde(default)]
55            price_pane: Vec<usize>,
56            #[serde(default)]
57            panes: Vec<Vec<usize>>,
58        }
59        struct Visitor;
60        impl<'de> serde::de::Visitor<'de> for Visitor {
61            type Value = StudyTemplateSet;
62            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
63                formatter.write_str("a study template set or a list of study templates")
64            }
65            fn visit_seq<A: serde::de::SeqAccess<'de>>(
66                self,
67                seq: A,
68            ) -> Result<Self::Value, A::Error> {
69                let studies = serde::Deserialize::deserialize(
70                    serde::de::value::SeqAccessDeserializer::new(seq),
71                )?;
72                Ok(StudyTemplateSet {
73                    studies,
74                    ..StudyTemplateSet::default()
75                })
76            }
77            fn visit_map<A: serde::de::MapAccess<'de>>(
78                self,
79                map: A,
80            ) -> Result<Self::Value, A::Error> {
81                let Fields {
82                    studies,
83                    price_pane,
84                    panes,
85                } = serde::Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(
86                    map,
87                ))?;
88                Ok(StudyTemplateSet {
89                    studies,
90                    price_pane,
91                    panes,
92                })
93            }
94        }
95        deserializer.deserialize_any(Visitor)
96    }
97}
98
99impl StudyTemplate {
100    /// Capture the current calculation inputs, presentation and value scale, without computed
101    /// values. An input that reads another study's plot keeps that study's chart id;
102    /// `Chart::study_template_set` saves a chart's studies with the links between them.
103    pub fn from_study(study: &dyn Indicator) -> Self {
104        Self {
105            name: study.name().into(),
106            inputs: study
107                .inputs()
108                .into_iter()
109                .map(|input| (input.id, input.value))
110                .collect(),
111            settings: study.settings(),
112            visible: study.is_visible(),
113            scale: Some(study.scale()),
114        }
115    }
116
117    /// Reconstruct a built-in study. Unknown names or invalid inputs leave the host chart intact.
118    /// Hosts reconstruct the entire set successfully before replacing their current studies.
119    pub fn create(&self) -> Result<Box<dyn Indicator>, crate::studies::StudyError> {
120        self.create_with(&IndicatorFactory::new())
121    }
122
123    /// Reconstruct a study through `factory`, which resolves [`name`](Self::name) to a built-in
124    /// or host-registered study. Fails like [`create`](Self::create).
125    pub fn create_with(
126        &self,
127        factory: &IndicatorFactory,
128    ) -> Result<Box<dyn Indicator>, crate::studies::StudyError> {
129        let upgraded = self.upgraded();
130        let template = upgraded.as_ref().unwrap_or(self);
131        let study = factory
132            .create(&template.name)
133            .ok_or_else(|| crate::studies::StudyError::UnknownStudy(template.name.clone()))?;
134        // Automatic drawings saved before their swings could follow volatility used the fixed
135        // percentage they were saved with.
136        let saved = |id| template.inputs.iter().any(|(saved, _)| saved == id);
137        let percent_swings = (study.inputs().iter().any(|input| input.id == "swing_size")
138            && !saved("swing_size")
139            && saved("deviation_percent"))
140        .then(|| ("swing_size".to_owned(), InputValue::Choice(1)));
141        let inputs: Vec<_> = percent_swings
142            .into_iter()
143            .chain(template.inputs.iter().cloned())
144            .collect();
145        let mut study = ConfiguredStudy::new(study);
146        study.apply_settings(&inputs, template.settings.clone())?;
147        study.set_visible(template.visible);
148        if let Some(scale) = template.scale {
149            study.set_scale(scale);
150        }
151        Ok(Box::new(study))
152    }
153
154    /// This template under the current input ids of a built-in study whose inputs changed after
155    /// it was saved, with inputs added since set to draw what it drew, or `None` when it needs no
156    /// change.
157    fn upgraded(&self) -> Option<Self> {
158        let has = |id: &str| self.inputs.iter().any(|(input, _)| input == id);
159        let mut template = self.clone();
160        match self.name.as_str() {
161            // One multiplier set both bands before each had its own.
162            "LinReg Channel" if has("multiplier") => {
163                template.inputs = self
164                    .inputs
165                    .iter()
166                    .flat_map(|(id, value)| match id.as_str() {
167                        "multiplier" => vec![
168                            ("upper_multiplier".to_owned(), value.clone()),
169                            ("lower_multiplier".to_owned(), value.clone()),
170                        ],
171                        _ => vec![(id.clone(), value.clone())],
172                    })
173                    .collect();
174            }
175            // Before it averaged highs and lows, MA Channel drew the close average with percentage
176            // bands, which Envelopes draws with a simple average.
177            "MA Channel" if has("offset_percent") => {
178                template.name = "Envelopes".to_owned();
179                for (id, _) in &mut template.inputs {
180                    if id == "offset_percent" {
181                        *id = "percent".to_owned();
182                    }
183                }
184            }
185            // Pivot levels had no labels before they could be labelled.
186            "Pivot Points Standard" | "Intraday Pivot Points" | "Projected Pivot Points"
187                if !has("show_labels") =>
188            {
189                template
190                    .inputs
191                    .push(("show_labels".to_owned(), InputValue::Toggle(false)));
192            }
193            // Sessions were only highlighted before they could be boxed and named.
194            "Trading Sessions" if !has("boxes") => {
195                for id in ["boxes", "name_labels"] {
196                    template
197                        .inputs
198                        .push((id.to_owned(), InputValue::Toggle(false)));
199                }
200            }
201            // Profile endpoints were Unix milliseconds before they read as UTC dates and times.
202            "Vol Profile FR" | "Vol Profile Anchored"
203                if self.inputs.iter().any(|(id, value)| {
204                    matches!(id.as_str(), "start_time" | "end_time")
205                        && matches!(value, InputValue::Number(_))
206                }) =>
207            {
208                for (id, value) in &mut template.inputs {
209                    if let ("start_time" | "end_time", InputValue::Number(ms)) =
210                        (id.as_str(), &*value)
211                    {
212                        *value = InputValue::Text(super::builtin::time_text(*ms));
213                    }
214                }
215            }
216            _ => return None,
217        }
218        Some(template)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::model::{Bar, PriceSource, SeriesScale, Session, SessionWindow, TimezoneId};
226    use crate::studies::{
227        AverageLine, AverageType, CVD, CloseToCloseVol, EhlersFisher, HL2, HLC3, MACombination,
228        MARibbon, PriceChannel, RSI, TradingSessions, VolumeWeightedAvgPrice,
229    };
230    use chrono::{NaiveTime, TimeZone, Utc, Weekday};
231    use egui::Color32;
232
233    /// What a chart shows of a calculated study: its name, values, line names, crossing
234    /// markers, line styles, value scale and drawings, as text.
235    fn shown(study: &dyn Indicator) -> String {
236        let styles: Vec<_> = (0..study.line_count())
237            .map(|line| study.line_style(line))
238            .collect();
239        #[cfg(feature = "drawings")]
240        let drawings = format!("{:?}", study.drawings());
241        #[cfg(not(feature = "drawings"))]
242        let drawings = "";
243        format!(
244            "{} {:?} {:?} {:?} {:?} {:?} {drawings}",
245            study.name(),
246            study.values(),
247            study.line_names(),
248            study.markers(),
249            styles,
250            study.scale(),
251        )
252    }
253
254    /// A study written out as a template and read back is recreated as it was, with its
255    /// builder settings and scale, also when the catalogue does not list it.
256    #[test]
257    fn templates_recreate_studies_with_their_settings() {
258        let bars: Vec<Bar> = (0..400)
259            .map(|i| {
260                let c = 100.0 + 10.0 * (i as f64 / 9.0).sin();
261                let time = Utc.timestamp_opt(i * 3600, 0).unwrap();
262                Bar::new(time, c - 0.5, c + 1.0, c - 1.0, c, 1000.0 + i as f64)
263            })
264            .collect();
265        let line = |kind, period| AverageLine::new(kind, period, Color32::RED);
266        let hour = |hour| NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
267        let tokyo = TimezoneId::fixed_offset(9 * 3600).unwrap();
268        let weekdays = [
269            Weekday::Mon,
270            Weekday::Tue,
271            Weekday::Wed,
272            Weekday::Thu,
273            Weekday::Fri,
274        ];
275        let rows: Vec<Box<dyn Indicator>> = vec![
276            // Exported studies outside the catalogue.
277            Box::new(CVD::default()),
278            Box::new(CloseToCloseVol::default()),
279            Box::new(HL2::default()),
280            Box::new(HLC3::default()),
281            Box::new(PriceChannel::default()),
282            Box::new(EhlersFisher::default()),
283            // Fewer averages than the ribbon that the name creates, with a crossing pair.
284            Box::new(
285                MACombination::new(vec![
286                    line(AverageType::Simple, 5),
287                    line(AverageType::Hull, 9).with_source(PriceSource::HLC3),
288                    line(AverageType::Exponential, 21),
289                ])
290                .unwrap()
291                .with_crossing_pair(2, 0)
292                .unwrap(),
293            ),
294            // More averages than that ribbon.
295            Box::new(
296                MACombination::new((1..=14).map(|i| line(AverageType::Simple, 3 * i)).collect())
297                    .unwrap(),
298            ),
299            // Settings made through builder methods.
300            Box::new(TradingSessions::new().with_session(
301                1,
302                "Night",
303                hour(1),
304                hour(5),
305                tokyo.clone(),
306            )),
307            Box::new(
308                VolumeWeightedAvgPrice::new()
309                    .with_time_zone(tokyo)
310                    .with_session(Session::new([SessionWindow::new(
311                        weekdays,
312                        hour(17),
313                        hour(16),
314                    )])),
315            ),
316            Box::new(MARibbon::muted()),
317        ];
318        let mut studies: Vec<ConfiguredStudy> =
319            rows.into_iter().map(ConfiguredStudy::new).collect();
320        #[cfg(feature = "drawings")]
321        studies.push(ConfiguredStudy::new({
322            use crate::drawings::{FibonacciConfig, FibonacciLevel};
323            use crate::studies::{AutoDrawing, AutoDrawingKind};
324            let mut fibonacci = FibonacciConfig {
325                reverse: true,
326                show_prices: Some(false),
327                show_levels: Some(true),
328                levels_as_percent: false,
329                labels_on_left: true,
330                show_background: true,
331                ..FibonacciConfig::default()
332            };
333            fibonacci.levels.truncate(2);
334            fibonacci.levels[0].show_price = false;
335            fibonacci
336                .levels
337                .push(FibonacciLevel::new(1.5, "150%", [10, 20, 30, 255]));
338            Box::new(
339                AutoDrawing::new(AutoDrawingKind::FibonacciRetracement, 2.0, 5)
340                    .with_fibonacci_config(fibonacci),
341            )
342        }));
343        let mut scaled = ConfiguredStudy::new(Box::new(RSI::new(14)));
344        scaled.set_scale(crate::studies::StudyScale::Shared(SeriesScale::Left));
345        studies.push(scaled);
346
347        let mut differing = Vec::new();
348        for (row, mut study) in studies.into_iter().enumerate() {
349            let json = serde_json::to_string(&StudyTemplate::from_study(&study)).unwrap();
350            let template: StudyTemplate = serde_json::from_str(&json).unwrap();
351            match template.create() {
352                Ok(mut restored) => {
353                    study.calculate(&bars);
354                    restored.calculate(&bars);
355                    if shown(restored.as_ref()) != shown(&study) {
356                        differing.push(format!("{row} {}", study.name()));
357                    }
358                }
359                Err(error) => differing.push(format!("{row} {}: {error}", study.name())),
360            }
361        }
362        assert_eq!(differing, Vec::<String>::new());
363
364        // Default sessions saved with `timezones` name their zones, and load without it.
365        let mut sessions = StudyTemplate::from_study(&TradingSessions::new());
366        let zones = sessions
367            .inputs
368            .iter_mut()
369            .filter(|(id, _)| id.starts_with("zone "));
370        for ((_, value), name) in zones.zip(["Asia/Tokyo", "Europe/London", "America/New_York"]) {
371            *value = InputValue::Text(name.to_owned());
372        }
373        let mut restored = sessions.create().unwrap();
374        let mut study = TradingSessions::new();
375        restored.calculate(&bars);
376        study.calculate(&bars);
377        assert_eq!(shown(restored.as_ref()), shown(&study));
378
379        // Automatic drawings keep their swing inputs, and those saved before swings could be
380        // sized by volatility restore on the fixed percentage with their saved deviation.
381        #[cfg(feature = "drawings")]
382        {
383            let mut study =
384                ConfiguredStudy::new(IndicatorFactory::new().create("Auto Pitchfork").unwrap());
385            let edited = [
386                ("swing_size", InputValue::Choice(1)),
387                ("atr_multiple", InputValue::Number(2.5)),
388                ("atr_period", InputValue::Length(7)),
389                ("deviation_percent", InputValue::Number(1.5)),
390                ("depth", InputValue::Length(4)),
391            ];
392            for (id, value) in &edited {
393                study.set_input(id, value.clone()).unwrap();
394            }
395            let saved = StudyTemplate::from_study(&study);
396            let mut before_atr = saved.clone();
397            before_atr.inputs.retain(|(id, _)| {
398                !matches!(id.as_str(), "swing_size" | "atr_multiple" | "atr_period")
399            });
400            for (template, expected) in [
401                (saved, &edited[..]),
402                (
403                    before_atr,
404                    &[edited[0].clone(), edited[3].clone(), edited[4].clone()][..],
405                ),
406            ] {
407                let inputs = template.create().unwrap().inputs();
408                for (id, value) in expected {
409                    let restored = inputs.iter().find(|input| input.id == *id).unwrap();
410                    assert_eq!(&restored.value, value, "{id}");
411                }
412            }
413        }
414    }
415
416    /// A template restores the inputs it holds whatever the study's default has become, with every
417    /// line it showed, and an input it lacks takes the value the saved inputs had before the
418    /// study offered it.
419    #[test]
420    fn templates_saved_before_split_lengths_restore_their_values() {
421        use InputValue::{Choice, Length, Number, Text, Toggle};
422        let rows = [
423            (
424                "Vol Profile Session",
425                vec![
426                    (
427                        "calculation_timeframe",
428                        InputValue::Timeframe(Some(crate::model::Timeframe::Min5)),
429                    ),
430                    ("wait_for_close", Toggle(false)),
431                ],
432                vec![
433                    (
434                        "calculation_timeframe",
435                        InputValue::Timeframe(Some(crate::model::Timeframe::Min5)),
436                    ),
437                    ("wait_for_close", Toggle(false)),
438                ],
439            ),
440            (
441                "SMA",
442                vec![("period", Length(20))],
443                vec![("period", Length(20))],
444            ),
445            (
446                "ADX",
447                vec![("period", Length(20))],
448                vec![("period", Length(20)), ("adx_smoothing", Length(20))],
449            ),
450            (
451                "DMI",
452                vec![("period", Length(20))],
453                vec![("period", Length(20)), ("adx_smoothing", Length(20))],
454            ),
455            (
456                "Chandelier",
457                vec![("period", Length(10)), ("multiplier", Number(2.0))],
458                vec![
459                    ("period", Length(10)),
460                    ("atr_period", Length(10)),
461                    ("multiplier", Number(2.0)),
462                ],
463            ),
464            (
465                "MA Cross",
466                vec![("period_0", Length(5))],
467                vec![
468                    ("period_0", Length(5)),
469                    ("cross_first", Choice(1)),
470                    ("cross_second", Choice(2)),
471                ],
472            ),
473            (
474                "Multi MA",
475                vec![("period_0", Length(30))],
476                vec![("period_0", Length(30)), ("cross_first", Choice(0))],
477            ),
478            // Saved before levels were labelled and sessions boxed, both on by default since.
479            (
480                "Pivot Points Standard",
481                vec![("pivots_back", Length(2))],
482                vec![("pivots_back", Length(2)), ("show_labels", Toggle(false))],
483            ),
484            (
485                "Projected Pivot Points",
486                vec![("show_labels", Toggle(true))],
487                vec![("show_labels", Toggle(true))],
488            ),
489            (
490                "Trading Sessions",
491                vec![("show [0]", Toggle(true))],
492                vec![("boxes", Toggle(false)), ("name_labels", Toggle(false))],
493            ),
494            (
495                "Trading Sessions",
496                vec![("boxes", Toggle(true))],
497                vec![("boxes", Toggle(true)), ("name_labels", Toggle(true))],
498            ),
499            // Saved as Unix milliseconds before the endpoints read as UTC dates and times.
500            (
501                "Vol Profile FR",
502                vec![
503                    ("start_time", Number(1_772_838_000_500.0)),
504                    ("end_time", Number(8e15)),
505                ],
506                vec![
507                    ("start_time", Text("2026-03-06 23:00:00.500".into())),
508                    ("end_time", Text(String::new())),
509                ],
510            ),
511            (
512                "Vol Profile Anchored",
513                vec![("start_time", Number(-8e15))],
514                vec![("start_time", Text(String::new()))],
515            ),
516        ];
517        let factory = IndicatorFactory::new();
518        for (name, saved, restored) in rows {
519            let mut settings = factory.create(name).unwrap().settings();
520            for plot in &mut settings.plots {
521                plot.visible = true;
522            }
523            let template = StudyTemplate {
524                name: name.to_owned(),
525                inputs: saved
526                    .into_iter()
527                    .map(|(id, value)| (id.to_owned(), value))
528                    .collect(),
529                settings,
530                visible: true,
531                scale: None,
532            };
533            let study = template.create().unwrap();
534            let inputs = study.inputs();
535            for (id, value) in restored {
536                let input = inputs.iter().find(|input| input.id == id);
537                assert_eq!(input.map(|input| &input.value), Some(&value), "{name} {id}");
538            }
539            assert!(
540                (0..study.line_count()).all(|line| study.plot_style(line).visible),
541                "{name}"
542            );
543        }
544    }
545
546    /// Templates and layouts saved before the channel studies' inputs changed load and draw the
547    /// lines they drew.
548    #[test]
549    fn templates_saved_before_the_channel_inputs_changed_keep_their_lines() {
550        use crate::studies::{IndicatorValue, StudySource};
551
552        let bars: Vec<Bar> = [1.0, 4.0, 2.0, 4.0]
553            .into_iter()
554            .enumerate()
555            .map(|(i, c)| Bar::new(Utc.timestamp_opt(i as i64, 0).unwrap(), c, c, c, c, 1.0))
556            .collect();
557        let configured = [
558            (
559                "source",
560                InputValue::Source(StudySource::Price(PriceSource::Close)),
561            ),
562            ("plot_offset", InputValue::Integer(0)),
563            ("calculation_symbol", InputValue::Symbol(String::new())),
564            ("calculation_timeframe", InputValue::Timeframe(None)),
565            ("wait_for_close", InputValue::Toggle(true)),
566        ];
567        let deviation = 2.0 / 3.0_f64.sqrt();
568        // Upper, middle and lower line on bars 1 to 3; bar 0 has no value.
569        let rows = [
570            // A residual deviation of 2/sqrt(3) around the flat fit 10/3 of the last three closes.
571            (
572                "LinReg Channel",
573                vec![
574                    ("period", InputValue::Length(3)),
575                    ("multiplier", InputValue::Number(1.0)),
576                    ("extend", InputValue::Toggle(false)),
577                ],
578                "LinReg Channel",
579                [[10.0 / 3.0 + deviation, 10.0 / 3.0, 10.0 / 3.0 - deviation]; 3],
580            ),
581            // The two-bar simple average of the closes, 2.5, 3 and 3, times 1.5 and 0.5.
582            (
583                "MA Channel",
584                vec![
585                    ("period", InputValue::Length(2)),
586                    ("offset_percent", InputValue::Number(50.0)),
587                ],
588                "Envelopes",
589                [[3.75, 2.5, 1.25], [4.5, 3.0, 1.5], [4.5, 3.0, 1.5]],
590            ),
591        ];
592        let factory = IndicatorFactory::new();
593        for (name, inputs, restored_name, expected) in rows {
594            // Saved with the three plot styles of a study drawing the restored lines.
595            let settings = ConfiguredStudy::new(factory.create(restored_name).unwrap()).settings();
596            let template = StudyTemplate {
597                name: name.to_owned(),
598                inputs: inputs
599                    .iter()
600                    .chain(&configured)
601                    .map(|(id, value)| ((*id).to_owned(), value.clone()))
602                    .collect(),
603                settings,
604                visible: true,
605                scale: None,
606            };
607            let mut study = template
608                .create()
609                .unwrap_or_else(|error| panic!("{name}: {error}"));
610            study.calculate(&bars);
611            assert_eq!(study.name(), restored_name);
612            assert!(
613                matches!(study.values().to_rows()[0], IndicatorValue::None),
614                "{name}"
615            );
616            for (bar, lines) in (1..).zip(expected) {
617                for (line, want) in lines.into_iter().enumerate() {
618                    let got = study.values().value(line, bar).unwrap();
619                    assert!(
620                        (got - want).abs() < 1e-12,
621                        "{name} bar {bar} line {line}: {got}"
622                    );
623                }
624            }
625        }
626    }
627}