Skip to main content

egui_charts/studies/builtin/
trading_sessions.rs

1//! Trading Sessions
2//!
3//! Highlights the bars that open within each of up to three trading sessions, by default the
4//! regular hours of the Tokyo, London and New York stock exchanges, the Asian, European and
5//! North American sessions, each in its own exchange time zone so daylight saving shifts the
6//! highlight with the session.
7//!
8//! # Calculation
9//! A bar belongs to a session when its open time, read in the session's time zone, lies in
10//! `[start, end)`. A session whose end is at or before its start runs overnight, from the
11//! start to the end on the next day; one whose end equals its start lasts the whole day.
12//! Each session is a background plot, 1 on the bars in the session and without a value
13//! elsewhere. Sessions apply on every day; markets without weekend bars have no weekend
14//! highlight. Like any session highlight it needs intraday bars: on intervals of a day or
15//! longer the study has no value.
16//!
17//! Each session can be drawn as a box from its high to its low, labelled with its name, range and
18//! average price (the mean of its closes), and crossed by lines at its open, close and average
19//! price. An overnight session is one session across midnight, and one lasting the whole day
20//! ends at its start time. Bars with a price that is not finite do not count.
21//!
22//! # Example
23//! ```
24//! use chrono::{TimeZone, Utc};
25//! use egui_charts::model::Bar;
26//! use egui_charts::studies::{Indicator, TradingSessions};
27//!
28//! // 00:00 UTC is 09:00 in Tokyo, in its session; 09:00 UTC is 09:00 in London in January.
29//! let bars: Vec<Bar> = [0, 9]
30//!     .into_iter()
31//!     .map(|h| Bar::new(Utc.with_ymd_and_hms(2024, 1, 15, h, 0, 0).unwrap(), 1.0, 1.0, 1.0, 1.0, 1.0))
32//!     .collect();
33//! let mut sessions = TradingSessions::new();
34//! sessions.calculate(&bars);
35//! assert_eq!(sessions.values().value(0, 0), Some(1.0));
36//! assert_eq!(sessions.values().value(1, 0), None);
37//! assert_eq!(sessions.values().value(1, 1), Some(1.0));
38//! ```
39
40use crate::model::local_time;
41use crate::model::{Bar, BarClock, TimezoneId};
42use crate::studies::palette;
43use crate::studies::stepper::update_window;
44use crate::studies::{Indicator, IndicatorValues, LabelPlacement, StudyLabel, StudyZone};
45use chrono::{NaiveDate, NaiveTime, TimeDelta, Timelike};
46use egui::Color32;
47use std::ops::Range;
48
49/// Background highlight of up to three trading sessions on intraday charts.
50///
51/// The chart supplies its interval through [`Indicator::set_bar_clock`]; without it every
52/// bar is treated as intraday.
53#[derive(Clone)]
54pub struct TradingSessions {
55    names: [String; 3],
56    show: [bool; 3],
57    /// Session start, in minutes after midnight in the session's time zone.
58    start: [usize; 3],
59    /// Session end, in minutes after midnight in the session's time zone.
60    end: [usize; 3],
61    zones: [TimezoneId; 3],
62    /// Whether the chart interval is a day or longer.
63    daily: bool,
64    values: IndicatorValues,
65    colors: [Color32; 3],
66    visible: bool,
67    boxes: bool,
68    name_labels: bool,
69    range_labels: bool,
70    average_labels: bool,
71    open_lines: bool,
72    close_lines: bool,
73    average_lines: bool,
74    /// The instrument's decimals and those of the study's presentation settings.
75    precision: Option<usize>,
76    label_precision: Option<usize>,
77    /// Sorted by first bar, then slot, as `study_zones` and `labels` are.
78    sessions: Vec<SessionSummary>,
79    study_zones: Vec<StudyZone>,
80    labels: Vec<StudyLabel>,
81    /// The session each slot was building before the last of `source_len` bars, when known.
82    before_last: Option<[Option<SessionSummary>; 3]>,
83    source_len: usize,
84}
85
86/// One session of one slot on one day.
87#[derive(Clone)]
88struct SessionSummary {
89    slot: usize,
90    day: NaiveDate,
91    bars: Range<usize>,
92    open: f64,
93    high: f64,
94    low: f64,
95    close: f64,
96    close_sum: f64,
97    closes: usize,
98}
99
100/// The exchange time zones of the default sessions, each with the standard-time offset in hours
101/// that stands in for it without `timezones`.
102const ZONES: [(&str, i32); 3] = [
103    ("Asia/Tokyo", 9),
104    ("Europe/London", 0),
105    ("America/New_York", -5),
106];
107
108/// The time zone named `name` or, without `timezones`, the offset that stands in for the zone
109/// of a default session, so that a template saved with `timezones` loads without it.
110fn zone(name: &str) -> Option<TimezoneId> {
111    TimezoneId::named(name).or_else(|| {
112        let (_, hours) = ZONES.into_iter().find(|(zone, _)| *zone == name)?;
113        TimezoneId::fixed_offset(hours * 3600)
114    })
115}
116
117impl TradingSessions {
118    /// Tokyo 09:00 to 15:00, London 08:30 to 16:30 and New York 09:30 to 16:00, each in
119    /// its exchange time zone. Without `timezones`, these use fixed standard-time offsets
120    /// of +09:00, +00:00 and -05:00. Override them with [`with_session`](Self::with_session).
121    pub fn new() -> Self {
122        let tint =
123            |color: Color32| Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 38);
124        Self {
125            names: ["Tokyo".into(), "London".into(), "New York".into()],
126            show: [true; 3],
127            start: [9 * 60, 8 * 60 + 30, 9 * 60 + 30],
128            end: [15 * 60, 16 * 60 + 30, 16 * 60],
129            zones: ZONES.map(|(name, _)| zone(name).unwrap()),
130            daily: false,
131            values: IndicatorValues::new(),
132            colors: [
133                tint(palette::BLUE),
134                tint(palette::WARM_ORANGE),
135                tint(palette::TEAL),
136            ],
137            visible: true,
138            boxes: true,
139            name_labels: true,
140            range_labels: false,
141            average_labels: false,
142            open_lines: false,
143            close_lines: false,
144            average_lines: false,
145            precision: None,
146            label_precision: None,
147            sessions: Vec::new(),
148            study_zones: Vec::new(),
149            labels: Vec::new(),
150            before_last: None,
151            source_len: 0,
152        }
153    }
154
155    /// Replaces session `slot` (0, 1 or 2; others are ignored) with `name`, open from `start`
156    /// to `end` in `zone`. Seconds are dropped, and the name is kept on one line, without control
157    /// characters or surrounding spaces, as the study inputs hold it.
158    pub fn with_session(
159        mut self,
160        slot: usize,
161        name: impl Into<String>,
162        start: NaiveTime,
163        end: NaiveTime,
164        zone: TimezoneId,
165    ) -> Self {
166        if slot < 3 {
167            let minutes = |time: NaiveTime| (time.hour() * 60 + time.minute()) as usize;
168            let name: String = name.into();
169            self.names[slot] = name.replace(char::is_control, " ").trim().to_owned();
170            self.start[slot] = minutes(start);
171            self.end[slot] = minutes(end);
172            self.zones[slot] = zone;
173        }
174        self
175    }
176
177    /// Shows or hides session `slot`.
178    pub fn with_shown(mut self, slot: usize, show: bool) -> Self {
179        if let Some(shown) = self.show.get_mut(slot) {
180            *shown = show;
181        }
182        self
183    }
184
185    /// Whether each session is drawn as a box from its high to its low over its bars. On by
186    /// default.
187    pub fn with_boxes(mut self, boxes: bool) -> Self {
188        self.boxes = boxes;
189        self
190    }
191
192    /// Which labels each session carries above its box: its name (on by default), its range
193    /// (high minus low) and its average price, the mean of its closes.
194    pub fn with_labels(mut self, names: bool, ranges: bool, averages: bool) -> Self {
195        self.name_labels = names;
196        self.range_labels = ranges;
197        self.average_labels = averages;
198        self
199    }
200
201    /// Which lines cross each session: at its open, its close and its average price. Off by
202    /// default.
203    pub fn with_lines(mut self, opens: bool, closes: bool, averages: bool) -> Self {
204        self.open_lines = opens;
205        self.close_lines = closes;
206        self.average_lines = averages;
207        self
208    }
209}
210
211impl TradingSessions {
212    /// Whether a time of day, in seconds after midnight, lies in session `slot`.
213    fn inside(&self, slot: usize, second: u32) -> bool {
214        let (start, end) = (self.start[slot] as u32 * 60, self.end[slot] as u32 * 60);
215        match start.cmp(&end) {
216            std::cmp::Ordering::Less => (start..end).contains(&second),
217            std::cmp::Ordering::Greater => second >= start || second < end,
218            std::cmp::Ordering::Equal => true,
219        }
220    }
221
222    /// The sessions of `slot` in `data` from bar `first` on, each day's from its start time,
223    /// continuing `current`, the session the bars before `first` were building. Returns the
224    /// session the bars before the last were building.
225    fn scan(
226        &self,
227        slot: usize,
228        data: &[Bar],
229        first: usize,
230        mut current: Option<SessionSummary>,
231        out: &mut Vec<SessionSummary>,
232    ) -> Option<SessionSummary> {
233        let mut before_last = None;
234        let start = TimeDelta::minutes(self.start[slot] as i64);
235        for (at, bar) in data.iter().enumerate().skip(first) {
236            if at + 1 == data.len() {
237                before_last = current.clone();
238            }
239            if ![bar.open, bar.high, bar.low, bar.close]
240                .iter()
241                .all(|price| price.is_finite())
242            {
243                continue;
244            }
245            let local = local_time(self.zones[slot].clone(), bar.time);
246            if !self.inside(slot, local.num_seconds_from_midnight()) {
247                continue;
248            }
249            let day = local
250                .checked_sub_signed(start)
251                .map_or(local.date(), |time| time.date());
252            match &mut current {
253                Some(session) if session.day == day => {
254                    session.bars.end = at + 1;
255                    session.high = session.high.max(bar.high);
256                    session.low = session.low.min(bar.low);
257                    session.close = bar.close;
258                    session.close_sum += bar.close;
259                    session.closes += 1;
260                }
261                _ => {
262                    out.extend(current.take());
263                    current = Some(SessionSummary {
264                        slot,
265                        day,
266                        bars: at..at + 1,
267                        open: bar.open,
268                        high: bar.high,
269                        low: bar.low,
270                        close: bar.close,
271                        close_sum: bar.close,
272                        closes: 1,
273                    });
274                }
275            }
276        }
277        out.extend(current);
278        before_last
279    }
280
281    /// Writes the zones and label of `session`.
282    fn emit(&mut self, session: &SessionSummary) {
283        let average = session.close_sum / session.closes as f64;
284        let plot = 3 + session.slot;
285        let line = |price: f64, plot: usize| StudyZone {
286            bars: session.bars.clone(),
287            top: price,
288            bottom: price,
289            plot,
290        };
291        if self.boxes {
292            self.study_zones.push(StudyZone {
293                bars: session.bars.clone(),
294                top: session.high,
295                bottom: session.low,
296                plot,
297            });
298        }
299        if self.open_lines {
300            self.study_zones.push(line(session.open, plot));
301        }
302        if self.close_lines {
303            self.study_zones.push(line(session.close, plot));
304        }
305        if self.average_lines {
306            self.study_zones.push(line(average, 6 + session.slot));
307        }
308        let precision = self.label_precision.or(self.precision);
309        let price = |value: f64| crate::scales::StudyFormat::Price.format(value, precision);
310        let mut lines = Vec::new();
311        if self.name_labels && !self.names[session.slot].is_empty() {
312            lines.push(self.names[session.slot].clone());
313        }
314        if self.range_labels {
315            lines.push(format!("Range: {}", price(session.high - session.low)));
316        }
317        if self.average_labels {
318            lines.push(format!("Avg: {}", price(average)));
319        }
320        if !lines.is_empty() {
321            self.labels.push(StudyLabel {
322                bar: session.bars.start,
323                price: session.high,
324                text: lines.join("\n"),
325                placement: LabelPlacement::AboveRight,
326                plot: session.slot,
327            });
328        }
329    }
330
331    /// Brings the sessions up to date. Each slot resumes the session its bars before `from` were
332    /// building: the one kept before the last bar when only that bar changed, else its latest
333    /// session that ends by `from`, after reading again the bars of one that reaches past it.
334    fn update_sessions(&mut self, data: &[Bar], from: usize) {
335        let from = from.min(self.source_len).min(data.len());
336        // The chart follows an update with `update(data, len)`, which must keep `before_last`.
337        if from == data.len() && from == self.source_len {
338            return;
339        }
340        let before_last = self
341            .before_last
342            .take()
343            .filter(|_| from + 1 == self.source_len);
344        self.source_len = data.len();
345        if self.daily {
346            self.sessions.clear();
347            self.study_zones.clear();
348            self.labels.clear();
349            return;
350        }
351        // Per slot, the bar to read from and the session the bars before it were building.
352        let started = self
353            .sessions
354            .partition_point(|session| session.bars.start < from);
355        let resume: [_; 3] = std::array::from_fn(|slot| {
356            if let Some(before_last) = &before_last {
357                return (from, before_last[slot].clone());
358            }
359            if !self.show[slot] {
360                return (from, None);
361            }
362            let mut latest = self.sessions[..started]
363                .iter()
364                .rev()
365                .filter(|session| session.slot == slot);
366            match (latest.next(), latest.next()) {
367                (Some(last), previous) if last.bars.end > from => {
368                    (last.bars.start, previous.cloned())
369                }
370                (last, _) => (from, last.cloned()),
371            }
372        });
373        // Sessions of a slot from the one it resumes on are written again.
374        let cut = |slot: usize| {
375            let (first, current) = &resume[slot];
376            current
377                .as_ref()
378                .map_or(*first, |session| session.bars.start)
379        };
380        let first = cut(0).min(cut(1)).min(cut(2));
381        let keep = self
382            .sessions
383            .partition_point(|session| session.bars.start < first);
384        let mut tail = self.sessions.split_off(keep);
385        tail.retain(|session| session.bars.start < cut(session.slot));
386        let kept = self.labels.partition_point(|label| label.bar < first);
387        self.labels.truncate(kept);
388        let per_session = [
389            self.boxes,
390            self.open_lines,
391            self.close_lines,
392            self.average_lines,
393        ]
394        .iter()
395        .filter(|shown| **shown)
396        .count();
397        self.study_zones.truncate(keep * per_session);
398        let mut before_last: [_; 3] = Default::default();
399        for (slot, (first, current)) in resume.into_iter().enumerate() {
400            if self.show[slot] {
401                before_last[slot] = self.scan(slot, data, first, current, &mut tail);
402            }
403        }
404        self.before_last = (from < data.len()).then_some(before_last);
405        tail.sort_by_key(|session| (session.bars.start, session.slot));
406        for session in &tail {
407            self.emit(session);
408        }
409        self.sessions.append(&mut tail);
410    }
411
412    fn compute(&self, data: &[Bar]) -> IndicatorValues {
413        let mut values = IndicatorValues::missing(data.len());
414        if !data.is_empty() {
415            // Every bar holds a value for each of the three plots, even a hidden one.
416            values.set_value(2, 0, f64::NAN);
417        }
418        if self.daily {
419            return values;
420        }
421        for slot in (0..3).filter(|&slot| self.show[slot]) {
422            let zone = &self.zones[slot];
423            for (i, bar) in data.iter().enumerate() {
424                let second = local_time(zone.clone(), bar.time).num_seconds_from_midnight();
425                if self.inside(slot, second) {
426                    values.set_value(slot, i, 1.0);
427                }
428            }
429        }
430        values
431    }
432}
433
434impl Default for TradingSessions {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440/// The ordinal of each session in input labels, in lower and upper case.
441const SLOTS: [(&str, &str); 3] = [("first", "First"), ("second", "Second"), ("third", "Third")];
442
443impl Indicator for TradingSessions {
444    /// Each session shows or not, and has a name, a start and an end in minutes after midnight,
445    /// and a time zone that [`TimezoneId::named`] reads. The toggles after them apply to every
446    /// session; its average price is the mean of its closes.
447    fn inputs(&self) -> Vec<crate::studies::StudyInput> {
448        use crate::studies::{InputKind, InputValue, StudyInput};
449        let defaults = Self::default();
450        let mut inputs = Vec::with_capacity(22);
451        for (slot, (lower, upper)) in SLOTS.into_iter().enumerate() {
452            let input = |field: &str, label: String, value, default, kind| {
453                StudyInput::new(format!("{field} [{slot}]"), label, value, kind)
454                    .with_default(default)
455            };
456            let minutes = InputKind::Length(0, 1439);
457            inputs.extend([
458                input(
459                    "show",
460                    format!("Show {lower} session"),
461                    InputValue::Toggle(self.show[slot]),
462                    InputValue::Toggle(defaults.show[slot]),
463                    InputKind::Toggle,
464                ),
465                input(
466                    "name",
467                    format!("{upper} session name"),
468                    InputValue::Text(self.names[slot].clone()),
469                    InputValue::Text(defaults.names[slot].clone()),
470                    InputKind::Text,
471                ),
472                input(
473                    "start",
474                    format!("{upper} session start, minutes after midnight"),
475                    InputValue::Length(self.start[slot]),
476                    InputValue::Length(defaults.start[slot]),
477                    minutes.clone(),
478                ),
479                input(
480                    "end",
481                    format!("{upper} session end, minutes after midnight"),
482                    InputValue::Length(self.end[slot]),
483                    InputValue::Length(defaults.end[slot]),
484                    minutes,
485                ),
486                input(
487                    "zone",
488                    format!("{upper} session time zone"),
489                    InputValue::Text(self.zones[slot].name().to_owned()),
490                    InputValue::Text(defaults.zones[slot].name().to_owned()),
491                    InputKind::Text,
492                ),
493            ]);
494        }
495        for (id, label, value, default) in [
496            ("boxes", "Show session boxes", self.boxes, defaults.boxes),
497            (
498                "name_labels",
499                "Show session names",
500                self.name_labels,
501                defaults.name_labels,
502            ),
503            (
504                "range_labels",
505                "Show session ranges",
506                self.range_labels,
507                defaults.range_labels,
508            ),
509            (
510                "average_labels",
511                "Show session average prices",
512                self.average_labels,
513                defaults.average_labels,
514            ),
515            (
516                "open_lines",
517                "Show session open lines",
518                self.open_lines,
519                defaults.open_lines,
520            ),
521            (
522                "close_lines",
523                "Show session close lines",
524                self.close_lines,
525                defaults.close_lines,
526            ),
527            (
528                "average_lines",
529                "Show session average lines",
530                self.average_lines,
531                defaults.average_lines,
532            ),
533        ] {
534            inputs.push(
535                StudyInput::new(id, label, InputValue::Toggle(value), InputKind::Toggle)
536                    .with_default(InputValue::Toggle(default)),
537            );
538        }
539        inputs
540    }
541
542    fn set_input(
543        &mut self,
544        id: &str,
545        value: crate::studies::InputValue,
546    ) -> Result<(), crate::studies::StudyError> {
547        use crate::studies::InputValue;
548        let schema = self
549            .inputs()
550            .into_iter()
551            .find(|input| input.id == id)
552            .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
553        if !schema.accepts(&value) {
554            return Err(crate::studies::StudyError::invalid_input(id));
555        }
556        let option = match id {
557            "boxes" => Some(&mut self.boxes),
558            "name_labels" => Some(&mut self.name_labels),
559            "range_labels" => Some(&mut self.range_labels),
560            "average_labels" => Some(&mut self.average_labels),
561            "open_lines" => Some(&mut self.open_lines),
562            "close_lines" => Some(&mut self.close_lines),
563            "average_lines" => Some(&mut self.average_lines),
564            _ => None,
565        };
566        if let (Some(option), InputValue::Toggle(on)) = (option, &value) {
567            *option = *on;
568            self.calculate(&[]);
569            return Ok(());
570        }
571        // Every other id is `field [slot]`, as the schema lookup confirmed.
572        let (field, slot) = id
573            .split_once(" [")
574            .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
575        let slot: usize = slot
576            .trim_end_matches(']')
577            .parse()
578            .map_err(|_| crate::studies::StudyError::UnknownInput(id.into()))?;
579        match (field, value) {
580            ("show", InputValue::Toggle(show)) => self.show[slot] = show,
581            ("name", InputValue::Text(name)) => self.names[slot] = name,
582            ("start", InputValue::Length(minutes)) => self.start[slot] = minutes,
583            ("end", InputValue::Length(minutes)) => self.end[slot] = minutes,
584            ("zone", InputValue::Text(name)) => {
585                self.zones[slot] =
586                    zone(&name).ok_or_else(|| crate::studies::StudyError::InvalidInput {
587                        id: id.into(),
588                        reason: "names no known time zone".into(),
589                    })?;
590            }
591            _ => return Err(crate::studies::StudyError::UnknownInput(id.into())),
592        }
593        self.calculate(&[]);
594        Ok(())
595    }
596
597    fn name(&self) -> &str {
598        "Trading Sessions"
599    }
600
601    fn desc(&self) -> &str {
602        "Trading Sessions - Background highlight of the Tokyo, London and New York sessions"
603    }
604
605    /// Boxes and session lines use plots 3 to 8, past the three outputs and in their colours at
606    /// full opacity: the highlight's tint would leave a box edge at 15% and its fill near 2%.
607    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
608        if (3..9).contains(&line) {
609            return crate::studies::PlotStyle {
610                color: self.colors[line % 3].to_opaque(),
611                width: if line < 6 { 1.0 } else { 2.0 },
612                pattern: crate::model::LineStyle::Solid,
613                plot_type: crate::studies::PlotType::Line,
614                visible: true,
615            };
616        }
617        crate::studies::PlotStyle {
618            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
619            width: 1.0,
620            pattern: crate::model::LineStyle::Solid,
621            plot_type: crate::studies::PlotType::BackgroundColor,
622            visible: true,
623        }
624    }
625
626    /// A session's box and lines take the visibility of its output.
627    fn plot_output(&self, line: usize) -> usize {
628        line % 3
629    }
630
631    fn value_format(&self) -> crate::scales::StudyFormat {
632        crate::scales::StudyFormat::Fixed(0)
633    }
634
635    fn calculate(&mut self, data: &[Bar]) {
636        self.update(data, 0);
637    }
638
639    fn update(&mut self, data: &[Bar], from: usize) {
640        let mut values = std::mem::take(&mut self.values);
641        update_window(
642            &mut values,
643            data,
644            from,
645            |_, from| from,
646            |data| self.compute(data),
647        );
648        self.values = values;
649        self.update_sessions(data, from);
650    }
651
652    fn is_incremental(&self) -> bool {
653        true
654    }
655
656    fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
657        let daily = clock.timeframe().is_some_and(|timeframe| {
658            timeframe.duration_ms() >= crate::model::Timeframe::Day1.duration_ms()
659        });
660        let changed = daily != self.daily;
661        self.daily = daily;
662        changed
663    }
664
665    fn set_instrument(&mut self, instrument: Option<&crate::model::Instrument>) -> bool {
666        let precision = instrument
667            .and_then(crate::scales::instrument_price_precision)
668            .map(usize::from);
669        let changed = precision != self.precision;
670        self.precision = precision;
671        changed
672    }
673
674    fn zones(&self) -> &[StudyZone] {
675        &self.study_zones
676    }
677
678    fn zones_cover_whole_bars(&self) -> bool {
679        true
680    }
681
682    fn labels(&self) -> &[StudyLabel] {
683        &self.labels
684    }
685
686    /// The study's own values are whole numbers, so its presentation settings hold 0 decimals by
687    /// default, which says nothing about prices. Any other setting applies.
688    fn set_label_precision(&mut self, precision: Option<usize>) {
689        self.label_precision = precision.filter(|&decimals| decimals != 0);
690    }
691
692    fn values(&self) -> &IndicatorValues {
693        &self.values
694    }
695
696    fn colors(&self) -> Vec<Color32> {
697        self.colors.to_vec()
698    }
699
700    fn set_colors(&mut self, colors: Vec<Color32>) {
701        for (old, new) in self.colors.iter_mut().zip(colors) {
702            *old = new;
703        }
704    }
705
706    fn is_visible(&self) -> bool {
707        self.visible
708    }
709
710    fn set_visible(&mut self, visible: bool) {
711        self.visible = visible;
712    }
713
714    fn clone_box(&self) -> Box<dyn Indicator> {
715        Box::new(self.clone())
716    }
717
718    fn line_names(&self) -> Vec<String> {
719        self.names.to_vec()
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use chrono::{TimeZone, Utc};
727
728    type Row = (u32, u32, f64, f64, f64, f64);
729    type ZoneRow = (Range<usize>, f64, f64, usize);
730    // (name, study, zones, text of the label)
731    type Case<'a> = (&'a str, Box<dyn Indicator>, Vec<ZoneRow>, Option<&'a str>);
732
733    /// The one session "Name" from `start` to `end` hours, UTC, over bars of (day, hour, open,
734    /// high, low, close) of January 2024.
735    fn study(name: &str, start: u32, end: u32) -> TradingSessions {
736        let time = |hour| NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
737        TradingSessions::new()
738            .with_session(0, name, time(start), time(end), TimezoneId::utc())
739            .with_shown(1, false)
740            .with_shown(2, false)
741    }
742
743    fn bars(rows: &[Row]) -> Vec<Bar> {
744        rows.iter()
745            .map(|&(day, hour, open, high, low, close)| {
746                let time = Utc.with_ymd_and_hms(2024, 1, day, hour, 0, 0).unwrap();
747                Bar::new(time, open, high, low, close, 1.0)
748            })
749            .collect()
750    }
751
752    fn zones(study: &dyn Indicator) -> Vec<ZoneRow> {
753        let zones = study.zones().iter();
754        zones
755            .map(|zone| (zone.bars.clone(), zone.top, zone.bottom, zone.plot))
756            .collect()
757    }
758
759    /// Session bars 1, 2 and 3, 12:00 being past its end: high 13, low 8.5, range 4.5, open 10,
760    /// close 9 and average (11 + 12.5 + 9) / 3 = 10.8333.
761    const DAY: &[Row] = &[
762        (15, 8, 10.0, 11.0, 9.0, 10.0),
763        (15, 9, 10.0, 12.0, 9.5, 11.0),
764        (15, 10, 11.0, 13.0, 10.0, 12.5),
765        (15, 11, 12.5, 12.8, 8.5, 9.0),
766        (15, 12, 9.0, 10.0, 8.0, 9.5),
767    ];
768
769    #[test]
770    fn a_session_draws_its_box_labels_and_lines_each_with_its_own_toggle() {
771        let average = 32.5 / 3.0;
772        let square = (1..4, 13.0, 8.5, 3);
773        let open = (1..4, 10.0, 10.0, 3);
774        let close = (1..4, 9.0, 9.0, 3);
775        let mean = (1..4, average, average, 6);
776        let all = || {
777            study("Test", 9, 12)
778                .with_labels(true, true, true)
779                .with_lines(true, true, true)
780        };
781        let mut tick = all();
782        let instrument = crate::model::Instrument::new("X").with_tick_size(0.001);
783        tick.set_instrument(Some(&instrument));
784        // The style tab's default of 0 decimals for the study's own values keeps the prices.
785        let mut styled = crate::studies::ConfiguredStudy::new(Box::new(all()));
786        styled.apply_settings(&[], styled.settings()).unwrap();
787        let mut set = crate::studies::ConfiguredStudy::new(Box::new(all()));
788        let mut settings = set.settings();
789        settings.precision = Some(3);
790        set.apply_settings(&[], settings).unwrap();
791        let mut unboxed = study("Test", 9, 12);
792        unboxed
793            .set_input("boxes", crate::studies::InputValue::Toggle(false))
794            .unwrap();
795        let all_text = "Test\nRange: 4.50\nAvg: 10.83";
796        let all_zones = vec![square.clone(), open.clone(), close.clone(), mean.clone()];
797        let rows: Vec<Case> = vec![
798            (
799                "defaults",
800                Box::new(study("Test", 9, 12)),
801                vec![square.clone()],
802                Some("Test"),
803            ),
804            ("all", Box::new(all()), all_zones.clone(), Some(all_text)),
805            (
806                "no box",
807                Box::new(all().with_boxes(false)),
808                vec![open.clone(), close.clone(), mean.clone()],
809                Some(all_text),
810            ),
811            (
812                "no box, through the input",
813                Box::new(unboxed),
814                Vec::new(),
815                Some("Test"),
816            ),
817            (
818                "no labels",
819                Box::new(all().with_labels(false, false, false)),
820                all_zones.clone(),
821                None,
822            ),
823            (
824                "range only",
825                Box::new(all().with_labels(false, true, false)),
826                all_zones.clone(),
827                Some("Range: 4.50"),
828            ),
829            (
830                "no lines",
831                Box::new(all().with_lines(false, false, false)),
832                vec![square.clone()],
833                Some(all_text),
834            ),
835            (
836                "open line only",
837                Box::new(all().with_boxes(false).with_lines(true, false, false)),
838                vec![open.clone()],
839                Some(all_text),
840            ),
841            (
842                "instrument decimals",
843                Box::new(tick),
844                all_zones.clone(),
845                Some("Test\nRange: 4.500\nAvg: 10.833"),
846            ),
847            (
848                "default style precision",
849                Box::new(styled),
850                all_zones.clone(),
851                Some(all_text),
852            ),
853            (
854                "style precision",
855                Box::new(set),
856                all_zones.clone(),
857                Some("Test\nRange: 4.500\nAvg: 10.833"),
858            ),
859        ];
860        let data = bars(DAY);
861        for (name, mut study, want_zones, want_text) in rows {
862            study.calculate(&data);
863            assert_eq!(zones(study.as_ref()), want_zones, "{name}");
864            let labels = study.labels();
865            assert_eq!(labels.first().map(|l| l.text.as_str()), want_text, "{name}");
866            if let Some(label) = labels.first() {
867                assert_eq!(labels.len(), 1, "{name}");
868                assert_eq!(
869                    (label.bar, label.price, label.placement, label.plot),
870                    (1, 13.0, LabelPlacement::AboveRight, 0),
871                    "{name}"
872                );
873            }
874        }
875
876        let mut daily = all();
877        daily.set_bar_clock(&BarClock::new(crate::model::Timeframe::Day1));
878        daily.calculate(&data);
879        assert!(daily.zones().is_empty() && daily.labels().is_empty());
880    }
881
882    /// Session bars 1 to 4 across midnight: high 13, low 9 and average 45.6 / 4 = 11.4. The two
883    /// bars after 02:00 are outside it, and the next night's bar opens another session.
884    #[test]
885    fn an_overnight_session_is_one_session_across_midnight() {
886        let data = bars(&[
887            (15, 21, 1.0, 50.0, 1.0, 1.0),
888            (15, 22, 10.0, 11.0, 9.0, 10.5),
889            (15, 23, 10.5, 12.0, 10.0, 11.5),
890            (16, 0, 11.5, 11.8, 10.2, 11.0),
891            (16, 1, 11.0, 13.0, 10.8, 12.6),
892            (16, 2, 1.0, 50.0, 1.0, 1.0),
893            (16, 3, 1.0, 50.0, 1.0, 1.0),
894            (16, 22, 20.0, 21.0, 19.0, 20.0),
895        ]);
896        let mut night = study("Night", 22, 2).with_labels(true, true, true);
897        night.calculate(&data);
898        assert_eq!(zones(&night), [(1..5, 13.0, 9.0, 3), (7..8, 21.0, 19.0, 3)]);
899        assert_eq!(night.labels()[0].text, "Night\nRange: 4.00\nAvg: 11.40");
900    }
901
902    /// Hiding a session's output in the style tab hides its box and lines with its highlight.
903    #[test]
904    fn a_hidden_output_hides_its_box_and_lines() {
905        let mut study = crate::studies::ConfiguredStudy::new(Box::new(TradingSessions::new()));
906        let mut settings = study.settings();
907        settings.plots[0].visible = false;
908        study.apply_settings(&[], settings).unwrap();
909        let visible: Vec<_> = (3..9).map(|line| study.plot_style(line).visible).collect();
910        assert_eq!(visible, [false, true, true, false, true, true]);
911    }
912
913    /// Hourly bars from 08:00 on the 15th to 10:00 on the 16th, sessions 08:00 to 16:00 and 14:00
914    /// to 21:00. The latest second session, bars 6 to 12, overlaps the finished first session,
915    /// bars 0 to 7, which a new bar must not start again from bar 6.
916    #[test]
917    fn a_new_bar_leaves_a_finished_session_that_overlaps_another() {
918        let time = |hour| NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
919        let data: Vec<Bar> = (0..27)
920            .map(|hour| {
921                let time =
922                    Utc.with_ymd_and_hms(2024, 1, 15, 8, 0, 0).unwrap() + TimeDelta::hours(hour);
923                Bar::new(time, 1.5, 2.0, 1.0, 1.5, 1.0)
924            })
925            .collect();
926        let mut sessions = study("First", 8, 16)
927            .with_session(1, "Second", time(14), time(21), TimezoneId::utc())
928            .with_shown(1, true);
929        sessions.calculate(&data[..26]);
930        sessions.update(&data, 26);
931        assert_eq!(
932            zones(&sessions),
933            [
934                (0..8, 2.0, 1.0, 3),
935                (6..13, 2.0, 1.0, 4),
936                (24..27, 2.0, 1.0, 3)
937            ]
938        );
939    }
940
941    /// A bar that does not count does not split a session, even when it is the newest when the
942    /// next bar arrives, and joins its session once corrected, even after the session's last bar
943    /// and before the next session.
944    #[test]
945    fn a_session_continues_past_a_bar_without_prices() {
946        let mut data = bars(DAY);
947        let next = data.pop().unwrap();
948        let mut sessions = study("Test", 9, 13);
949        data[3] = Bar::whitespace(data[3].time);
950        sessions.calculate(&data);
951        assert_eq!(zones(&sessions), [(1..3, 13.0, 9.5, 3)]);
952        data.push(next);
953        sessions.update(&data, 4);
954        assert_eq!(zones(&sessions), [(1..5, 13.0, 8.0, 3)]);
955
956        let mut data = bars(&[
957            (15, 9, 10.0, 11.0, 9.0, 10.0),
958            (15, 10, 10.0, 11.0, 9.0, 10.0),
959            (15, 11, 10.0, 11.0, 9.0, 10.0),
960            (16, 9, 10.0, 11.0, 9.0, 10.0),
961        ]);
962        let corrected = Bar::new(data[2].time, 10.0, 20.0, 9.0, 10.0, 1.0);
963        for at in [1, 2] {
964            data[at] = Bar::whitespace(data[at].time);
965        }
966        let mut sessions = study("Test", 9, 12);
967        sessions.calculate(&data);
968        assert_eq!(
969            zones(&sessions),
970            [(0..1, 11.0, 9.0, 3), (3..4, 11.0, 9.0, 3)]
971        );
972        data[2] = corrected;
973        sessions.update(&data, 2);
974        assert_eq!(
975            zones(&sessions),
976            [(0..3, 20.0, 9.0, 3), (3..4, 11.0, 9.0, 3)]
977        );
978    }
979
980    /// An update of the last bar reads that bar, not every bar since the oldest session still
981    /// open, so 30 of them on 22 hours of 5-second bars take less than one calculation.
982    #[test]
983    fn a_last_bar_update_reads_only_that_bar() {
984        let start = Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap();
985        let mut data: Vec<Bar> = (0..15_840)
986            .map(|i| {
987                let close = 100.0 + (i as f64 / 50.0).sin();
988                let time = start + TimeDelta::seconds(5 * i);
989                Bar::new(time, close, close + 0.5, close - 0.5, close, 1.0)
990            })
991            .collect();
992        let mut sessions = TradingSessions::new();
993        let calculation = std::time::Instant::now();
994        sessions.calculate(&data);
995        let calculation = calculation.elapsed();
996        let last = data.len() - 1;
997        let updates = std::time::Instant::now();
998        for tick in 0..30 {
999            data[last].close = 100.0 + tick as f64 / 100.0;
1000            sessions.update(&data, last);
1001        }
1002        assert!(updates.elapsed() < calculation, "{:?}", updates.elapsed());
1003    }
1004}