Skip to main content

egui_charts/studies/builtin/
auto_drawing.rs

1//! Drawing studies anchored on the shared Zig Zag swings.
2
3use super::{ATR, ZigZag};
4use crate::drawings::{ChartPoint, Drawing, DrawingToolType, FibonacciConfig, FibonacciLevel};
5use crate::model::Bar;
6use crate::studies::{
7    Indicator, IndicatorValue, IndicatorValues, InputKind, InputValue, StudyInput,
8};
9use egui::Color32;
10
11/// The study catalogue's swing size: a multiple of the average true range over a period.
12const ATR_MULTIPLE: f64 = 3.0;
13const ATR_PERIOD: usize = 10;
14
15/// The most Fibonacci levels that the study inputs can hold.
16const MAX_FIB_LEVELS: usize = 24;
17
18/// The fields of each Fibonacci level among the inputs, as in `fib_ratio_2`.
19const LEVEL_FIELDS: [&str; 5] = [
20    "fib_visible",
21    "fib_ratio",
22    "fib_label",
23    "fib_color",
24    "fib_price",
25];
26
27/// The level index of a per-level input id such as `fib_ratio_2`.
28fn level_slot(id: &str) -> Option<usize> {
29    let (field, index) = id.rsplit_once('_')?;
30    if LEVEL_FIELDS.contains(&field) {
31        index.parse().ok()
32    } else {
33        None
34    }
35}
36
37/// Construction to place on the latest Zig Zag pivots.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum AutoDrawingKind {
40    /// Last two pivots of the same kind, including the moving last extreme.
41    TrendLine,
42    /// Last three pivots, with the base through the first and third and the parallel through the second.
43    ParallelChannel,
44    /// Last two confirmed pivots; the current, unfinished swing is excluded.
45    FibonacciRetracement,
46    /// Latest completed swing projected from the current leg's moving extreme.
47    FibonacciExtension,
48    /// Last three pivots, with the median from the first to the midpoint of the other two.
49    Pitchfork,
50}
51
52impl AutoDrawingKind {
53    pub(crate) const ALL: [Self; 5] = [
54        Self::TrendLine,
55        Self::ParallelChannel,
56        Self::FibonacciRetracement,
57        Self::FibonacciExtension,
58        Self::Pitchfork,
59    ];
60
61    /// Whether the construction is a Fibonacci one, set up by a [`FibonacciConfig`].
62    fn fibonacci(self) -> bool {
63        matches!(self, Self::FibonacciRetracement | Self::FibonacciExtension)
64    }
65
66    /// The Fibonacci levels and options a study of this kind starts with.
67    fn default_fibonacci(self) -> FibonacciConfig {
68        let mut fibonacci = if self == Self::FibonacciExtension {
69            FibonacciConfig::extension_default()
70        } else {
71            FibonacciConfig::default()
72        };
73        for level in &mut fibonacci.levels {
74            let hex = match level.value {
75                0.0 | 1.0 => [120, 123, 134, 255],
76                0.236 | 2.618 => [244, 67, 54, 255],
77                0.382 => [129, 199, 132, 255],
78                0.5 => [76, 175, 80, 255],
79                0.618 => [0, 150, 136, 255],
80                0.786 => [100, 181, 246, 255],
81                1.618 => [41, 98, 255, 255],
82                _ => level.color,
83            };
84            level.color = hex;
85            if level.value == 1.272 || level.value == 2.0 {
86                level.visible = false;
87            }
88        }
89        fibonacci.show_background = true;
90        fibonacci.background_opacity = 38;
91        fibonacci.extend_right = true;
92        fibonacci
93    }
94
95    fn tool(self) -> DrawingToolType {
96        match self {
97            Self::TrendLine => DrawingToolType::TrendLine,
98            Self::ParallelChannel => DrawingToolType::ParallelChannel,
99            Self::FibonacciRetracement => DrawingToolType::FibonacciRetracement,
100            Self::FibonacciExtension => DrawingToolType::FibonacciExtension,
101            Self::Pitchfork => DrawingToolType::Pitchfork,
102        }
103    }
104}
105
106/// A drawing recalculated from host bars with the same pivots as [`ZigZag`]. Swings use the
107/// fixed Zig Zag percentage unless [`with_atr`](Self::with_atr) sizes them by the average true
108/// range.
109///
110/// Add through the normal study API or calculate directly and read [`Indicator::drawings`].
111/// No drawing is emitted until enough pivots exist. A trend line and channel include the moving
112/// last extreme; a retracement uses the latest completed swing, an extension projects that
113/// swing from the current leg's extreme. A pitchfork follows the latest three pivots. Updating or replacing bars
114/// rebuilds the anchors; generated drawings belong to the study, not the editable drawing list.
115/// Output 0 contains anchor prices for scaling and readouts; the drawing renderer paints the
116/// construction instead of joining these values. Its colour, width and visibility use plot 0.
117#[derive(Clone)]
118pub struct AutoDrawing {
119    kind: AutoDrawingKind,
120    zigzag: ZigZag,
121    /// Size swings by the average true range instead of the Zig Zag's fixed percentage.
122    atr_swings: bool,
123    atr_multiple: f64,
124    atr_period: usize,
125    atr: ATR,
126    fibonacci: FibonacciConfig,
127    fork_colors: [Color32; 2],
128    drawings: Vec<Drawing>,
129    values: IndicatorValues,
130}
131
132impl AutoDrawing {
133    /// Construct with a fixed Zig Zag deviation in percent and confirmation depth in bars.
134    /// Parameters have the same bounds and skipped-bar policy as [`ZigZag::new`].
135    /// [`with_atr`](Self::with_atr) sizes swings by volatility instead, as the study
136    /// catalogue's automatic drawings do.
137    pub fn new(kind: AutoDrawingKind, deviation_percent: f64, depth: usize) -> Self {
138        Self {
139            kind,
140            zigzag: ZigZag::new(deviation_percent, depth).with_color(
141                if kind == AutoDrawingKind::Pitchfork {
142                    crate::studies::palette::BRIGHT_RED
143                } else {
144                    crate::studies::palette::BLUE
145                },
146            ),
147            atr_swings: false,
148            atr_multiple: ATR_MULTIPLE,
149            atr_period: ATR_PERIOD,
150            atr: ATR::new(ATR_PERIOD),
151            fibonacci: kind.default_fibonacci(),
152            fork_colors: [
153                crate::studies::palette::GREEN,
154                crate::studies::palette::BLUE,
155            ],
156            drawings: Vec::new(),
157            values: IndicatorValues::new(),
158        }
159    }
160
161    /// Size swings by recent volatility instead of the fixed percentage: a reversal counts when
162    /// it moves from the last extreme by at least `multiple` times the average true range over
163    /// `period` bars ([`ATR`]), taken as a percentage of the confirming bar's close. Bars before
164    /// the average is defined, and bars without a finite positive close, confirm no reversal.
165    /// `multiple` is clamped into 0 to `f64::MAX`, NaN to 0, where no swing is found; `period`
166    /// is at least 1.
167    pub fn with_atr(mut self, multiple: f64, period: usize) -> Self {
168        self.atr_swings = true;
169        self.atr_multiple = super::clamp_param(multiple, 0.0, f64::MAX);
170        self.atr_period = period.max(1);
171        self.atr = ATR::new(self.atr_period);
172        self
173    }
174
175    fn fork_config(&self) -> crate::drawings::PitchforkConfig {
176        let mut config = crate::drawings::PitchforkConfig::legacy();
177        let colors = [
178            self.zigzag.colors()[0],
179            self.fork_colors[0],
180            self.fork_colors[1],
181        ];
182        for level in &mut config.levels {
183            level.visible = level.value <= 1.0;
184            level.color = colors[if level.value == 0.0 {
185                0
186            } else if level.value == 0.5 {
187                1
188            } else {
189                2
190            }]
191            .to_srgba_unmultiplied();
192        }
193        config.background_opacity = 38;
194        config
195    }
196
197    /// Set the levels, orientation and price measurement of a Fibonacci construction. As the
198    /// study inputs hold them, levels whose ratio is not finite are dropped, at most 24 are kept,
199    /// and each label is kept on one line, without control characters or surrounding spaces.
200    pub fn with_fibonacci_config(mut self, mut config: FibonacciConfig) -> Self {
201        config.levels.retain(|level| level.value.is_finite());
202        config.levels.truncate(MAX_FIB_LEVELS);
203        for level in &mut config.levels {
204            level.label = level.label.replace(char::is_control, " ").trim().to_owned();
205        }
206        self.fibonacci = config;
207        self
208    }
209}
210
211impl Indicator for AutoDrawing {
212    fn name(&self) -> &str {
213        match self.kind {
214            AutoDrawingKind::TrendLine => "Auto Trend Line",
215            AutoDrawingKind::ParallelChannel => "Auto Parallel Channel",
216            AutoDrawingKind::FibonacciRetracement => "Auto Fibonacci Retracement",
217            AutoDrawingKind::FibonacciExtension => "Auto Fibonacci Extension",
218            AutoDrawingKind::Pitchfork => "Auto Pitchfork",
219        }
220    }
221
222    fn desc(&self) -> &str {
223        "Drawing anchored on Zig Zag swing extremes"
224    }
225
226    /// The swing size, ATR multiple and period, the Zig Zag deviation and depth and, for a
227    /// Fibonacci construction, its options, its number of levels and each level's visibility,
228    /// ratio, label, colour and price label.
229    fn inputs(&self) -> Vec<StudyInput> {
230        let mut inputs = vec![
231            StudyInput::new(
232                "swing_size",
233                "Swing size",
234                InputValue::Choice(usize::from(!self.atr_swings)),
235                InputKind::Choice(["ATR multiple", "Percent"].map(str::to_owned).to_vec()),
236            )
237            .with_default(InputValue::Choice(0)),
238            StudyInput::new(
239                "atr_multiple",
240                "ATR multiple",
241                InputValue::Number(self.atr_multiple),
242                InputKind::Number(0.0, f64::MAX),
243            )
244            .with_default(InputValue::Number(ATR_MULTIPLE)),
245            StudyInput::new(
246                "atr_period",
247                "ATR period",
248                InputValue::Length(self.atr_period),
249                InputKind::Length(1, usize::MAX),
250            )
251            .with_default(InputValue::Length(ATR_PERIOD)),
252        ];
253        inputs.extend(
254            self.zigzag
255                .inputs()
256                .into_iter()
257                .filter(|input| matches!(input.id.as_str(), "deviation_percent" | "depth")),
258        );
259        if !self.kind.fibonacci() {
260            return inputs;
261        }
262        let config = &self.fibonacci;
263        let defaults = self.kind.default_fibonacci();
264        let toggle = |id: String, label: String, value: bool, default: bool| {
265            StudyInput::new(id, label, InputValue::Toggle(value), InputKind::Toggle)
266                .with_default(InputValue::Toggle(default))
267        };
268        let colour = |[r, g, b, a]: [u8; 4]| Color32::from_rgba_unmultiplied(r, g, b, a);
269        let optional = |id, label, value: Option<bool>, default: Option<bool>| {
270            let index = |value| match value {
271                None => 0,
272                Some(false) => 1,
273                Some(true) => 2,
274            };
275            StudyInput::new(
276                id,
277                label,
278                InputValue::Choice(index(value)),
279                InputKind::Choice(["Default", "Hidden", "Shown"].map(str::to_owned).to_vec()),
280            )
281            .with_default(InputValue::Choice(index(default)))
282        };
283        inputs.extend([
284            optional(
285                "fib_show_prices",
286                "Price labels",
287                config.show_prices,
288                defaults.show_prices,
289            ),
290            optional(
291                "fib_show_levels",
292                "Level labels",
293                config.show_levels,
294                defaults.show_levels,
295            ),
296            toggle(
297                "fib_levels_as_percent".into(),
298                "Levels as percentages".into(),
299                config.levels_as_percent,
300                defaults.levels_as_percent,
301            ),
302            toggle(
303                "fib_labels_on_left".into(),
304                "Labels on left".into(),
305                config.labels_on_left,
306                defaults.labels_on_left,
307            ),
308            toggle(
309                "fib_reverse".into(),
310                "Reverse".into(),
311                config.reverse,
312                defaults.reverse,
313            ),
314            toggle(
315                "fib_extend_left".into(),
316                "Extend left".into(),
317                config.extend_left,
318                defaults.extend_left,
319            ),
320            toggle(
321                "fib_extend_right".into(),
322                "Extend right".into(),
323                config.extend_right,
324                defaults.extend_right,
325            ),
326            toggle(
327                "fib_background".into(),
328                "Background".into(),
329                config.show_background,
330                defaults.show_background,
331            ),
332            StudyInput::new(
333                "fib_background_opacity",
334                "Background opacity",
335                InputValue::Length(config.background_opacity.into()),
336                InputKind::Length(0, 255),
337            )
338            .with_default(InputValue::Length(defaults.background_opacity.into())),
339            toggle(
340                "fib_logarithmic".into(),
341                "Logarithmic levels".into(),
342                config.logarithmic,
343                defaults.logarithmic,
344            ),
345            StudyInput::new(
346                "fib_level_count",
347                "Levels",
348                InputValue::Length(config.levels.len()),
349                InputKind::Length(0, MAX_FIB_LEVELS),
350            )
351            .with_default(InputValue::Length(defaults.levels.len())),
352        ]);
353        for (i, level) in config.levels.iter().enumerate() {
354            // A level added after construction has no construction default; it is its own.
355            let default = defaults.levels.get(i).unwrap_or(level);
356            let n = i + 1;
357            inputs.extend([
358                toggle(
359                    format!("fib_visible_{i}"),
360                    format!("Level {n} visible"),
361                    level.visible,
362                    default.visible,
363                ),
364                StudyInput::new(
365                    format!("fib_ratio_{i}"),
366                    format!("Level {n} ratio"),
367                    InputValue::Number(level.value.into()),
368                    InputKind::Number((-f32::MAX).into(), f32::MAX.into()),
369                )
370                .with_default(InputValue::Number(default.value.into())),
371                StudyInput::new(
372                    format!("fib_label_{i}"),
373                    format!("Level {n} label"),
374                    InputValue::Text(level.label.clone()),
375                    InputKind::Text,
376                )
377                .with_default(InputValue::Text(default.label.clone())),
378                StudyInput::new(
379                    format!("fib_color_{i}"),
380                    format!("Level {n} colour"),
381                    InputValue::Color(colour(level.color)),
382                    InputKind::Color,
383                )
384                .with_default(InputValue::Color(colour(default.color))),
385                toggle(
386                    format!("fib_price_{i}"),
387                    format!("Level {n} price visible"),
388                    level.show_price,
389                    default.show_price,
390                ),
391            ]);
392        }
393        inputs
394    }
395
396    /// `fib_level_count` resizes the Fibonacci levels, so it comes first among the level inputs of
397    /// an applied set; an input of a level past the last has nothing to edit and is accepted
398    /// unchanged. Levels added take the kind's default level at their place, or 100% in the
399    /// study's colour, as the drawing properties add one.
400    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
401        if matches!(id, "deviation_percent" | "depth") {
402            self.zigzag.set_input(id, value)?;
403            self.calculate(&[]);
404            return Ok(());
405        }
406        if self.kind.fibonacci()
407            && level_slot(id)
408                .is_some_and(|i| (self.fibonacci.levels.len()..MAX_FIB_LEVELS).contains(&i))
409        {
410            return Ok(());
411        }
412        let schema = self
413            .inputs()
414            .into_iter()
415            .find(|input| input.id == id)
416            .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
417        if !schema.accepts(&value) {
418            return Err(crate::studies::StudyError::invalid_input(id));
419        }
420        let config = &mut self.fibonacci;
421        match (id, value) {
422            ("swing_size", InputValue::Choice(index)) => self.atr_swings = index == 0,
423            ("atr_multiple", InputValue::Number(multiple)) => self.atr_multiple = multiple,
424            ("atr_period", InputValue::Length(period)) => {
425                self.atr_period = period;
426                self.atr = ATR::new(period);
427            }
428            ("fib_show_prices", InputValue::Choice(index)) => {
429                config.show_prices = [None, Some(false), Some(true)][index]
430            }
431            ("fib_show_levels", InputValue::Choice(index)) => {
432                config.show_levels = [None, Some(false), Some(true)][index]
433            }
434            ("fib_levels_as_percent", InputValue::Toggle(on)) => config.levels_as_percent = on,
435            ("fib_labels_on_left", InputValue::Toggle(on)) => config.labels_on_left = on,
436            ("fib_reverse", InputValue::Toggle(on)) => config.reverse = on,
437            ("fib_extend_left", InputValue::Toggle(on)) => config.extend_left = on,
438            ("fib_extend_right", InputValue::Toggle(on)) => config.extend_right = on,
439            ("fib_background", InputValue::Toggle(on)) => config.show_background = on,
440            // The input bounds the opacity to 0..=255.
441            ("fib_background_opacity", InputValue::Length(opacity)) => {
442                config.background_opacity = opacity as u8;
443            }
444            ("fib_logarithmic", InputValue::Toggle(on)) => config.logarithmic = on,
445            ("fib_level_count", InputValue::Length(count)) => {
446                let defaults = self.kind.default_fibonacci().levels;
447                let color = self.zigzag.colors()[0].to_srgba_unmultiplied();
448                config.levels.truncate(count);
449                while config.levels.len() < count {
450                    let level = defaults
451                        .get(config.levels.len())
452                        .cloned()
453                        .unwrap_or_else(|| FibonacciLevel::new(1.0, "100%", color));
454                    config.levels.push(level);
455                }
456            }
457            (id, value) => {
458                let level = &mut config.levels[level_slot(id)
459                    .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?];
460                match (id.rsplit_once('_').map(|(field, _)| field), value) {
461                    (Some("fib_visible"), InputValue::Toggle(on)) => level.visible = on,
462                    (Some("fib_ratio"), InputValue::Number(ratio)) => level.value = ratio as f32,
463                    (Some("fib_label"), InputValue::Text(label)) => level.label = label,
464                    (Some("fib_color"), InputValue::Color(color)) => {
465                        level.color = color.to_srgba_unmultiplied();
466                    }
467                    (Some("fib_price"), InputValue::Toggle(on)) => level.show_price = on,
468                    _ => return Err(crate::studies::StudyError::UnknownInput(id.into())),
469                }
470            }
471        }
472        self.calculate(&[]);
473        Ok(())
474    }
475
476    fn calculate(&mut self, data: &[Bar]) {
477        self.update(data, 0);
478    }
479
480    fn update(&mut self, data: &[Bar], from: usize) {
481        if from == 0 {
482            self.values = IndicatorValues::missing(data.len());
483        } else {
484            // Clear the old anchors by their exact source indices before the swings move.
485            for pivot in self.zigzag.pivots().iter().rev().take(4) {
486                if pivot.bar_index < self.values.len() {
487                    self.values.set(pivot.bar_index, IndicatorValue::None);
488                }
489            }
490            self.values.resize(data.len(), IndicatorValue::None);
491        }
492        if self.atr_swings {
493            self.atr.update(data, from);
494        }
495        let (atr, multiple) = (&self.atr, self.atr_multiple);
496        let by_atr = |source: usize| {
497            atr.values()
498                .value(0, source)
499                .map_or(f64::NAN, |atr| 100.0 * multiple * atr / data[source].close)
500        };
501        self.zigzag.update_swings(
502            data,
503            from,
504            self.atr_swings.then_some(&by_atr as &dyn Fn(usize) -> f64),
505        );
506        self.drawings.clear();
507        let pivots = self.zigzag.pivots();
508        let pivots = if self.kind == AutoDrawingKind::FibonacciRetracement {
509            &pivots[..pivots.len().saturating_sub(1)]
510        } else {
511            pivots
512        };
513        let needed = if self.kind == AutoDrawingKind::FibonacciRetracement {
514            2
515        } else {
516            3
517        };
518        let Some(start) = pivots.len().checked_sub(needed) else {
519            return;
520        };
521        let recent = &pivots[start..];
522        let order: &[usize] = match self.kind {
523            AutoDrawingKind::TrendLine => &[0, 2],
524            AutoDrawingKind::ParallelChannel => &[0, 2, 1],
525            AutoDrawingKind::FibonacciRetracement => &[0, 1],
526            AutoDrawingKind::FibonacciExtension | AutoDrawingKind::Pitchfork => &[0, 1, 2],
527        };
528        let mut drawing = Drawing::new(0, self.kind.tool());
529        drawing.completed = true;
530        drawing.color = self.zigzag.colors()[0].to_srgba_unmultiplied();
531        drawing.chart_points = order
532            .iter()
533            .map(|&i| {
534                let pivot = recent[i];
535                self.values.set_value(0, pivot.bar_index, pivot.price);
536                ChartPoint::new(pivot.bar_index as f32, pivot.price)
537            })
538            .collect();
539        if self.kind.fibonacci() {
540            drawing.fib_config = Some(self.fibonacci.clone());
541        }
542        if self.kind == AutoDrawingKind::Pitchfork {
543            drawing.pitchfork = Some(self.fork_config());
544        }
545        self.drawings.push(drawing);
546    }
547
548    fn is_incremental(&self) -> bool {
549        true
550    }
551
552    fn values(&self) -> &IndicatorValues {
553        &self.values
554    }
555    fn drawings(&self) -> &[Drawing] {
556        &self.drawings
557    }
558    fn colors(&self) -> Vec<Color32> {
559        let mut colors = self.zigzag.colors();
560        if self.kind == AutoDrawingKind::Pitchfork {
561            colors.extend(self.fork_colors);
562        }
563        colors
564    }
565    fn set_colors(&mut self, colors: Vec<Color32>) {
566        if self.kind == AutoDrawingKind::Pitchfork
567            && let Some(&color) = colors.first()
568        {
569            self.fork_colors = [
570                colors.get(1).copied().unwrap_or(color),
571                colors.get(2).copied().unwrap_or(color),
572            ];
573        }
574        self.zigzag.set_colors(colors);
575        if self.kind == AutoDrawingKind::Pitchfork {
576            let config = self.fork_config();
577            for drawing in &mut self.drawings {
578                drawing.pitchfork = Some(config.clone());
579            }
580        }
581    }
582    fn set_color(&mut self, line: usize, color: Color32) {
583        if line == 0 {
584            self.set_colors(vec![color]);
585        }
586    }
587    fn is_overlay(&self) -> bool {
588        true
589    }
590    fn is_visible(&self) -> bool {
591        self.zigzag.is_visible()
592    }
593    fn set_visible(&mut self, visible: bool) {
594        self.zigzag.set_visible(visible);
595    }
596    fn clone_box(&self) -> Box<dyn Indicator> {
597        Box::new(self.clone())
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::studies::{ConfiguredStudy, IndicatorFactory};
605
606    fn bars() -> Vec<Bar> {
607        // At deviation 5%, depth 3: confirmed lows 100 at 0 and 5, high 113 at 3;
608        // the high 118 at 9 is still moving. The reversal confirms bar 5 on bar 6.
609        [
610            (101.0, 100.0),
611            (105.0, 104.0),
612            (108.0, 107.0),
613            (113.0, 112.0),
614            (110.0, 104.0),
615            (103.0, 100.0),
616            (107.0, 106.0),
617            (110.0, 109.0),
618            (114.0, 113.0),
619            (118.0, 117.0),
620        ]
621        .into_iter()
622        .enumerate()
623        .map(|(i, (h, l))| {
624            Bar::new(
625                chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap(),
626                l,
627                h,
628                l,
629                l,
630                1.0,
631            )
632        })
633        .collect()
634    }
635
636    /// Correct anchors, completed swings and replacement on live changes at the study API.
637    #[test]
638    fn automatic_drawings_follow_shared_swings() {
639        for (kind, tool, initial, updated) in [
640            (
641                AutoDrawingKind::TrendLine,
642                DrawingToolType::TrendLine,
643                vec![(3, 113.0), (9, 118.0)],
644                vec![(5, 100.0), (12, 93.0)],
645            ),
646            (
647                AutoDrawingKind::ParallelChannel,
648                DrawingToolType::ParallelChannel,
649                vec![(3, 113.0), (9, 118.0), (5, 100.0)],
650                vec![(5, 100.0), (12, 93.0), (9, 122.0)],
651            ),
652            (
653                AutoDrawingKind::FibonacciRetracement,
654                DrawingToolType::FibonacciRetracement,
655                vec![(3, 113.0), (5, 100.0)],
656                vec![(5, 100.0), (9, 122.0)],
657            ),
658            (
659                AutoDrawingKind::FibonacciExtension,
660                DrawingToolType::FibonacciExtension,
661                vec![(3, 113.0), (5, 100.0), (9, 118.0)],
662                vec![(5, 100.0), (9, 122.0), (12, 93.0)],
663            ),
664            (
665                AutoDrawingKind::Pitchfork,
666                DrawingToolType::Pitchfork,
667                vec![(3, 113.0), (5, 100.0), (9, 118.0)],
668                vec![(5, 100.0), (9, 122.0), (12, 93.0)],
669            ),
670        ] {
671            let mut data = bars();
672            let name = AutoDrawing::new(kind, 5.0, 3).name().to_owned();
673            let mut study = ConfiguredStudy::new(IndicatorFactory::new().create(&name).unwrap());
674            study
675                .set_input("swing_size", InputValue::Choice(1))
676                .unwrap();
677            study.set_input("depth", InputValue::Length(3)).unwrap();
678            let check = |study: &ConfiguredStudy, expected: &[(usize, f64)]| {
679                assert_eq!(study.drawings().len(), 1, "{name}");
680                let drawing = &study.drawings()[0];
681                assert_eq!(drawing.tool_type, tool);
682                let points: Vec<_> = drawing
683                    .chart_points
684                    .iter()
685                    .map(|p| (p.bar_idx as usize, p.price))
686                    .collect();
687                assert_eq!(points, expected, "{name}");
688                if kind == AutoDrawingKind::FibonacciRetracement {
689                    let mut drawing = drawing.clone();
690                    drawing.update_screen_coords(|b| b, |p| p as f32);
691                    // 0 at the end, 50% at the arithmetic midpoint, 100% at the start.
692                    assert_eq!(drawing.level_ys[0], expected[1].1 as f32);
693                    assert_eq!(
694                        drawing.level_ys[3],
695                        ((expected[0].1 + expected[1].1) / 2.0) as f32
696                    );
697                    assert_eq!(drawing.level_ys[6], expected[0].1 as f32);
698                }
699            };
700            study.calculate(&data[..1]);
701            assert!(study.drawings().is_empty());
702            study.calculate(&data);
703            check(&study, &initial);
704            data[9].high = 122.0;
705            study.update(&data, 9);
706            let moved: Vec<_> = initial
707                .iter()
708                .map(|&(bar, p)| (bar, if bar == 9 { 122.0 } else { p }))
709                .collect();
710            check(&study, &moved);
711            for (i, low) in [95.0, 94.0, 93.0].into_iter().enumerate() {
712                data.push(Bar::new(
713                    chrono::DateTime::from_timestamp((10 + i) as i64 * 60, 0).unwrap(),
714                    low,
715                    100.0,
716                    low,
717                    low,
718                    1.0,
719                ));
720            }
721            study.update(&data, 10);
722            check(&study, &updated);
723            study
724                .set_input("deviation_percent", InputValue::Number(50.0))
725                .unwrap();
726            study.calculate(&data);
727            assert!(study.drawings().is_empty());
728            study
729                .set_input("deviation_percent", InputValue::Number(5.0))
730                .unwrap();
731            study.set_input("depth", InputValue::Length(100)).unwrap();
732            study.calculate(&data);
733            assert!(study.drawings().is_empty());
734            study.calculate(&[]);
735            assert!(study.drawings().is_empty() && study.values().is_empty());
736        }
737    }
738
739    /// The catalogue sizes swings by 3 times the 10-bar average true range as a percentage of
740    /// the confirming close, so 0.3% minute swings draw and daily anchors match a hand count,
741    /// calculated at once or bar by bar.
742    #[test]
743    fn automatic_drawings_size_swings_by_average_true_range() {
744        let series = |count: usize, seconds: i64, h: f64, mid: &dyn Fn(usize) -> f64| {
745            (0..count)
746                .map(|i| {
747                    let mid = mid(i);
748                    let time = chrono::DateTime::from_timestamp(i as i64 * seconds, 0).unwrap();
749                    Bar::new(time, mid, mid + h, mid - h, mid, 1.0)
750                })
751                .collect::<Vec<_>>()
752        };
753        // Lows of 99.984375 at bars 0, 20 and 40, highs of 100.328125 at 10 and 30: 0.34%
754        // swings against a threshold near 300 * 0.0469 / 100.2 = 0.14%.
755        let minute = series(50, 60, 1.0 / 64.0, &|i| {
756            let t = if i % 20 < 10 { i % 20 } else { 20 - i % 20 };
757            100.0 + t as f64 / 32.0
758        });
759        // Every true range is 2, so a reversal needs 600 / close percent: the fall from 111 at
760        // bar 40 reaches 5.518% at bar 51 against 5.667%, and the last pivot stays provisional.
761        let daily = series(52, 86_400, 1.0, &|i| match i {
762            0..=11 => 100.0 + i as f64,
763            12..=26 => 111.0 - (i - 11) as f64,
764            27..=40 => 96.0 + (i - 26) as f64,
765            _ => 110.0 - 0.375 * (i - 40) as f64,
766        });
767        let (low, high) = (99.984375, 100.328125);
768        for (label, bars, split, expected) in [
769            (
770                "minute",
771                minute,
772                25,
773                [
774                    vec![(20, low), (40, low)],
775                    vec![(20, low), (40, low), (30, high)],
776                    vec![(20, low), (30, high)],
777                    vec![(20, low), (30, high), (40, low)],
778                    vec![(20, low), (30, high), (40, low)],
779                ],
780            ),
781            (
782                "daily",
783                daily,
784                45,
785                [
786                    vec![(11, 112.0), (40, 111.0)],
787                    vec![(11, 112.0), (40, 111.0), (26, 95.0)],
788                    vec![(11, 112.0), (26, 95.0)],
789                    vec![(11, 112.0), (26, 95.0), (40, 111.0)],
790                    vec![(11, 112.0), (26, 95.0), (40, 111.0)],
791                ],
792            ),
793        ] {
794            for (kind, expected) in AutoDrawingKind::ALL.into_iter().zip(expected) {
795                let name = AutoDrawing::new(kind, 5.0, 10).name().to_owned();
796                let mut study = IndicatorFactory::new().create(&name).unwrap();
797                let points = |study: &dyn Indicator| -> Vec<(usize, f64)> {
798                    study.drawings()[0]
799                        .chart_points
800                        .iter()
801                        .map(|p| (p.bar_idx as usize, p.price))
802                        .collect()
803                };
804                study.calculate(&bars);
805                assert_eq!(points(study.as_ref()), expected, "{label} {name}");
806                study.calculate(&bars[..split]);
807                study.update(&bars, split);
808                assert_eq!(points(study.as_ref()), expected, "{label} {name} updated");
809            }
810        }
811    }
812
813    /// A configured automatic line is painted and selectable through the ordinary study path.
814    #[test]
815    fn automatic_drawing_uses_the_study_renderer_and_hit_test() {
816        use crate::chart::coords::ChartMapping;
817        use crate::chart::indicators::hit_test_indicator;
818        use crate::chart::rendering::{IndicatorRenderer, RenderContext};
819        use crate::scales::{PriceMap, PriceRange, PriceTransform};
820        use egui::{Pos2, Rect};
821        let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(400.0, 300.0));
822        let price = PriceMap::new(
823            PriceTransform::default(),
824            PriceRange::new(90.0, 130.0),
825            0.0,
826            300.0,
827            false,
828        );
829        let coords = ChartMapping::new(rect, 30.0, 0, 9, 0.0, price);
830        let data = bars();
831        let mut automatic = AutoDrawing::new(AutoDrawingKind::TrendLine, 5.0, 3);
832        automatic.calculate(&data);
833        let a = Pos2::new(coords.idx_to_x(3), price.price_to_y(113.0));
834        let b = Pos2::new(coords.idx_to_x(9), price.price_to_y(118.0));
835        let midpoint = a.lerp(b, 0.5);
836        for (drawing_visible, plot_visible) in [(true, true), (true, false), (false, true)] {
837            automatic.drawings[0].visible = drawing_visible;
838            let mut study = ConfiguredStudy::new(Box::new(automatic.clone()));
839            let mut settings = study.settings();
840            settings.plots[0].visible = plot_visible;
841            study.apply_settings(&[], settings).unwrap();
842            let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
843                IndicatorRenderer::render_study(
844                    &RenderContext::new(ui.painter(), rect),
845                    &study,
846                    &data,
847                    0,
848                    &price,
849                    &coords,
850                );
851            });
852            let painted = output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment{points,..} if points[0].distance(a)<0.01 && points[1].distance(b)<0.01));
853            output.drop_without_applying_deltas();
854            let visible = drawing_visible && plot_visible;
855            assert_eq!(painted, visible);
856            assert_eq!(
857                hit_test_indicator(midpoint, &study, 0, 0..10, &coords, |p| price.price_to_y(p))
858                    .is_some(),
859                visible
860            );
861            assert_eq!(
862                crate::studies::plots::drawing_prices(&study, 0..10)
863                    .next()
864                    .is_some(),
865                visible
866            );
867        }
868
869        // Zoom between anchors: the construction still supplies the independent scale's range.
870        automatic.drawings[0].visible = true;
871        let mut study = ConfiguredStudy::new(Box::new(automatic));
872        study.set_scale(crate::studies::StudyScale::Independent);
873        let zoomed = ChartMapping::new(rect, 400.0, 6, 7, 0.0, price);
874        let mapped = crate::chart::rendering::study_mapping(&study, &zoomed);
875        for value in [113.0, 118.0] {
876            assert!((0.0..=300.0).contains(&mapped.price_to_y(value)));
877        }
878
879        // Projected levels must fit even when none of the swing anchors are in view.
880        let mut extension = AutoDrawing::new(AutoDrawingKind::FibonacciExtension, 5.0, 3);
881        extension.calculate(&data);
882        let prices: Vec<_> = crate::studies::plots::drawing_prices(&extension, 10..12).collect();
883        // C + 2.618*(B-A) = 118 + 2.618*(100-113) = 83.966.
884        assert!(prices.iter().any(|p| (p - 83.966).abs() < 1e-4));
885        let mut state = crate::studies::ChartState::new(crate::model::BarData::from_bars(data));
886        state.time_scale_mut().set_width(400.0);
887        let transform = state.fit_price_scale([&extension as &dyn Indicator]);
888        let range = state.price_scale().range();
889        assert!(transform.to_price(range.min) <= 83.966);
890        assert!(transform.to_price(range.max) >= 118.0);
891        let mut extension = ConfiguredStudy::new(Box::new(extension));
892        let mut hidden = extension.settings();
893        hidden.plots[0].visible = false;
894        extension.apply_settings(&[], hidden).unwrap();
895        assert!(
896            crate::studies::plots::drawing_prices(&extension, 10..12)
897                .next()
898                .is_none()
899        );
900
901        // Without bands or extensions, only A..B is painted; C is the projection price.
902        let mut extension = AutoDrawing::new(AutoDrawingKind::FibonacciExtension, 5.0, 3)
903            .with_fibonacci_config(FibonacciConfig {
904                show_background: false,
905                extend_right: false,
906                ..FibonacciConfig::extension_default()
907            });
908        extension.calculate(&bars());
909        for (visible, reaches) in [
910            (0..3, false),
911            (3..5, true),
912            (5..6, true),
913            (6..9, false),
914            (9..10, false),
915        ] {
916            assert_eq!(
917                crate::studies::plots::drawing_prices(&extension, visible.clone())
918                    .next()
919                    .is_some(),
920                reaches,
921                "{visible:?}"
922            );
923        }
924    }
925}