1use crate::studies::{
4 ConfiguredStudy, Indicator, IndicatorFactory, InputValue, StudyScale, StudySettings,
5};
6
7#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
10pub struct StudyTemplate {
11 pub name: String,
13 pub inputs: Vec<(String, InputValue)>,
15 pub settings: StudySettings,
17 pub visible: bool,
19 #[serde(default)]
22 pub scale: Option<StudyScale>,
23}
24
25#[derive(Clone, Debug, Default, serde::Serialize)]
38#[non_exhaustive]
39pub struct StudyTemplateSet {
40 pub studies: Vec<StudyTemplate>,
42 pub price_pane: Vec<usize>,
44 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 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 pub fn create(&self) -> Result<Box<dyn Indicator>, crate::studies::StudyError> {
120 self.create_with(&IndicatorFactory::new())
121 }
122
123 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 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 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 "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 "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 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 "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 "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 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 #[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 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 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 Box::new(
296 MACombination::new((1..=14).map(|i| line(AverageType::Simple, 3 * i)).collect())
297 .unwrap(),
298 ),
299 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 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 #[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 #[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 (
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 (
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 #[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 let rows = [
570 (
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 (
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 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}