Skip to main content

egui_charts/studies/builtin/
higher_interval_candles.rs

1//! Higher-interval candle bodies and wicks built from the chart's own bars.
2
3use crate::model::{Bar, BarClock, Instrument, Timeframe};
4use crate::studies::{Indicator, IndicatorValues, InputKind, InputValue, StudyCandle, StudyInput};
5use egui::Color32;
6
7/// Candles spanning the loaded bars of a higher interval, daily by default.
8///
9/// The chart supplies its input interval and instrument metadata. Direct callers set them through
10/// [`Indicator::set_timeframe`] and [`Indicator::set_instrument`] before calculating, and
11/// [`Indicator::set_bar_clock`] when their calendar differs from UTC. Input bars
12/// must be chronological. The last period is rebuilt on every update, including corrections to
13/// its opening bar. Incompatible intervals have no output. No external data is requested.
14///
15/// [`Indicator::candles`] exposes period OHLCV and source ranges. The two output columns contain
16/// each period's close on its source bars, in the up or down column; unchanged closes count as up.
17#[derive(Clone)]
18pub struct HigherIntervalCandles {
19    interval: Timeframe,
20    clock: BarClock,
21    instrument: Instrument,
22    extended_hours: bool,
23    candles: Vec<StudyCandle>,
24    values: IndicatorValues,
25    colors: [Color32; 2],
26    visible: bool,
27}
28
29impl HigherIntervalCandles {
30    /// Aggregate into `interval`. Count intervals cannot be derived from OHLCV bars.
31    pub fn new(interval: Timeframe) -> Self {
32        Self {
33            interval,
34            clock: BarClock::new(Timeframe::Min1),
35            instrument: Instrument::new(""),
36            extended_hours: true,
37            candles: Vec::new(),
38            values: IndicatorValues::new(),
39            colors: [
40                crate::studies::palette::DEEP_TEAL,
41                crate::studies::palette::BRIGHT_RED,
42            ],
43            visible: true,
44        }
45    }
46
47    fn intervals(&self) -> Vec<Timeframe> {
48        let mut intervals = Timeframe::all();
49        if !intervals.contains(&self.interval) {
50            intervals.push(self.interval);
51        }
52        intervals
53    }
54    fn calculate_from(&mut self, data: &[Bar], start: usize) {
55        self.values.truncate(start);
56        self.values
57            .resize(data.len(), crate::studies::IndicatorValue::None);
58        self.candles.truncate(
59            self.candles
60                .partition_point(|candle| candle.bars.end <= start),
61        );
62        if start == 0 && !data.is_empty() {
63            self.values.set_value(1, 0, f64::NAN);
64        }
65        if self.clock.timeframe().is_none_or(|source| {
66            source.duration_ms() == 0 || self.interval.duration_ms() <= source.duration_ms()
67        }) {
68            return;
69        }
70        // A bad direct-call input has no value and does not contaminate the rest of its period.
71        let indices: Vec<_> = data
72            .iter()
73            .enumerate()
74            .skip(start)
75            .filter(|(_, bar)| crate::data::merge::valid(bar))
76            .map(|(i, _)| i)
77            .collect();
78        let bars: Vec<_> = indices.iter().map(|&i| data[i].clone()).collect();
79        let Some(periods) = crate::data::resample_periods(
80            &bars,
81            self.clock.clone(),
82            self.clock.for_timeframe(self.interval),
83            &self.instrument,
84            self.extended_hours,
85        ) else {
86            if start > 0 {
87                self.calculate_from(data, 0);
88            }
89            return;
90        };
91        for (range, bar) in periods {
92            let plot = usize::from(bar.close < bar.open);
93            for &i in &indices[range.clone()] {
94                self.values.set_value(plot, i, bar.close);
95            }
96            self.candles.push(StudyCandle {
97                bars: indices[range.start]..indices[range.end - 1] + 1,
98                bar,
99                plot,
100            });
101        }
102    }
103}
104
105impl Default for HigherIntervalCandles {
106    fn default() -> Self {
107        Self::new(Timeframe::Day1)
108    }
109}
110
111impl Indicator for HigherIntervalCandles {
112    fn name(&self) -> &str {
113        "Higher Interval Candles"
114    }
115    fn desc(&self) -> &str {
116        "Higher-interval OHLC candles built from chart bars"
117    }
118    fn inputs(&self) -> Vec<StudyInput> {
119        let intervals = self.intervals();
120        vec![
121            StudyInput::new(
122                "interval",
123                "Interval",
124                InputValue::Choice(intervals.iter().position(|t| *t == self.interval).unwrap()),
125                InputKind::Choice(intervals.iter().map(ToString::to_string).collect()),
126            )
127            .with_default(InputValue::Choice(
128                intervals
129                    .iter()
130                    .position(|t| *t == Timeframe::Day1)
131                    .unwrap(),
132            )),
133            StudyInput::new(
134                "extended_hours",
135                "Include extended hours",
136                InputValue::Toggle(self.extended_hours),
137                InputKind::Toggle,
138            )
139            .with_default(InputValue::Toggle(true)),
140        ]
141    }
142    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
143        match (id, value) {
144            ("interval", InputValue::Choice(index)) => {
145                self.interval = *self
146                    .intervals()
147                    .get(index)
148                    .ok_or_else(|| crate::studies::StudyError::invalid_input(id))?
149            }
150            ("extended_hours", InputValue::Toggle(enabled)) => self.extended_hours = enabled,
151            _ => return Err(crate::studies::StudyError::rejected(&self.inputs(), id)),
152        }
153        self.candles.clear();
154        self.values.clear();
155        Ok(())
156    }
157    fn set_timeframe(&mut self, timeframe: Timeframe) {
158        self.clock = self.clock.for_timeframe(timeframe);
159    }
160    fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
161        if self.clock == *clock {
162            return false;
163        }
164        self.clock = clock.clone();
165        true
166    }
167    fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
168        let next = instrument.cloned().unwrap_or_else(|| Instrument::new(""));
169        let changed =
170            self.instrument.time_zone != next.time_zone || self.instrument.session != next.session;
171        self.instrument = next;
172        changed
173    }
174    fn calculate(&mut self, data: &[Bar]) {
175        self.calculate_from(data, 0);
176    }
177    fn update(&mut self, data: &[Bar], from: usize) {
178        let start = self
179            .candles
180            .last()
181            .filter(|candle| {
182                from > 0
183                    && from >= candle.bars.start
184                    && from <= self.values.len()
185                    && candle.bars.start < data.len()
186            })
187            .map_or(0, |candle| candle.bars.start);
188        self.calculate_from(data, start);
189    }
190    fn is_incremental(&self) -> bool {
191        !self.candles.is_empty()
192    }
193
194    fn values(&self) -> &IndicatorValues {
195        &self.values
196    }
197    fn candles(&self) -> &[StudyCandle] {
198        &self.candles
199    }
200    fn line_names(&self) -> Vec<String> {
201        vec!["Up".into(), "Down".into()]
202    }
203    fn colors(&self) -> Vec<Color32> {
204        self.colors.to_vec()
205    }
206    fn set_colors(&mut self, colors: Vec<Color32>) {
207        for (old, new) in self.colors.iter_mut().zip(colors) {
208            *old = new;
209        }
210    }
211    fn is_visible(&self) -> bool {
212        self.visible
213    }
214    fn set_visible(&mut self, visible: bool) {
215        self.visible = visible;
216    }
217    fn clone_box(&self) -> Box<dyn Indicator> {
218        Box::new(self.clone())
219    }
220}
221
222#[cfg(all(test, feature = "timezones"))]
223mod tests {
224    use super::*;
225    use crate::model::{Session, SessionWindow, TimezoneId};
226    use chrono::{NaiveTime, Weekday};
227
228    #[test]
229    fn daily_candles_cover_session_gaps_and_replace_the_forming_period() {
230        let clock = |h| NaiveTime::from_hms_opt(h, 0, 0).unwrap();
231        let instrument = Instrument::new("OVERNIGHT")
232            .with_time_zone(TimezoneId::named("America/New_York").unwrap())
233            .with_session(Session::new([SessionWindow::new(
234                [Weekday::Fri, Weekday::Mon, Weekday::Tue],
235                clock(18),
236                clock(17),
237            )]));
238        let bar =
239            |time: &str, [o, h, l, c]: [f64; 4]| Bar::new(time.parse().unwrap(), o, h, l, c, 1.0);
240        let mut data = vec![
241            bar("2024-03-07T23:00:00Z", [17.0, 18.0, 9.0, 12.0]),
242            bar("2024-03-08T02:00:00Z", [12.0, 15.0, 11.0, 14.0]),
243            bar("2024-03-08T21:00:00Z", [14.0, 16.0, 10.0, 11.0]),
244            // A weekend gap and the spring clock change precede the next session.
245            bar("2024-03-10T22:00:00Z", [11.0, 12.0, 7.0, 8.0]),
246            bar("2024-03-11T03:00:00Z", [8.0, 10.0, 6.0, 9.0]),
247            // Tuesday is still forming, with only its first hourly bar available.
248            bar("2024-03-11T22:00:00Z", [9.0, 11.0, 8.0, 10.0]),
249        ];
250        let mut study = crate::studies::IndicatorFactory::new()
251            .create("Higher Interval Candles")
252            .unwrap();
253        study.set_timeframe(Timeframe::Hour1);
254        study.set_instrument(Some(&instrument));
255        study.calculate(&data);
256        let check = |study: &dyn Indicator, expected: &[[f64; 4]]| {
257            assert_eq!(study.candles().len(), 3);
258            assert_eq!(study.values().len(), expected.len());
259            for (i, expected) in expected.iter().enumerate() {
260                let candle = study
261                    .candles()
262                    .iter()
263                    .find(|c| c.bars.contains(&i))
264                    .unwrap();
265                let bar = &candle.bar;
266                assert_eq!(
267                    [bar.open, bar.high, bar.low, bar.close],
268                    *expected,
269                    "source bar {i}"
270                );
271                assert_eq!(study.values().value(candle.plot, i), Some(bar.close));
272            }
273        };
274        let mut expected = vec![[17.0, 18.0, 9.0, 11.0]; 3];
275        expected.extend([[11.0, 12.0, 6.0, 9.0]; 2]);
276        expected.push([9.0, 11.0, 8.0, 10.0]);
277        check(study.as_ref(), &expected);
278        assert_eq!(
279            study
280                .candles()
281                .iter()
282                .map(|c| c.bar.time.to_rfc3339())
283                .collect::<Vec<_>>(),
284            [
285                "2024-03-08T00:00:00+00:00",
286                "2024-03-11T00:00:00+00:00",
287                "2024-03-12T00:00:00+00:00"
288            ]
289        );
290        // Correct the forming bar from up to down; neither an old high nor an up output survives.
291        data[5] = bar("2024-03-11T22:00:00Z", [9.0, 10.0, 5.0, 6.0]);
292        study.update(&data, 5);
293        expected[5] = [9.0, 10.0, 5.0, 6.0];
294        check(study.as_ref(), &expected);
295        assert_eq!(study.values().value(0, 5), None);
296        data.push(bar("2024-03-11T23:00:00Z", [6.0, 12.0, 4.0, 11.0]));
297        study.update(&data, 6);
298        expected[5] = [9.0, 12.0, 4.0, 11.0];
299        expected.push(expected[5]);
300        check(study.as_ref(), &expected);
301        assert_eq!(study.values().value(1, 5), None);
302        #[cfg(feature = "widget")]
303        {
304            use crate::chart::coords::ChartMapping;
305            use crate::chart::rendering::{IndicatorRenderer, RenderContext};
306            use crate::scales::{PriceMap, PriceRange, PriceScaleMode, PriceTransform};
307            use crate::studies::ConfiguredStudy;
308            use egui::{Pos2, Rect, Shape};
309            let mut options = crate::config::ChartOptions::default();
310            options.time_scale.timezone =
311                crate::scales::TimezoneMode::Timezone("Asia/Tokyo".parse().unwrap());
312            let mut chart = crate::widget::ChartBuilder::new()
313                .with_chart_options(options)
314                .build();
315            chart.set_timeframe(Timeframe::Day1);
316            chart.set_instrument(Some(
317                Instrument::new("TOKYO").with_time_zone(TimezoneId::named("Asia/Tokyo").unwrap()),
318            ));
319            let monday = Bar::new(
320                "2024-03-10T15:00:00Z".parse().unwrap(),
321                10.0,
322                13.0,
323                9.0,
324                12.0,
325                1.0,
326            );
327            chart.set_data(crate::widget::SeriesId::MAIN, vec![monday.clone()]);
328            let id = chart.add_indicator(Box::new(HigherIntervalCandles::new(Timeframe::Week1)));
329            assert_eq!(
330                chart
331                    .indicators()
332                    .find(|(candidate, _)| *candidate == id)
333                    .unwrap()
334                    .1
335                    .candles()[0]
336                    .bar
337                    .time,
338                monday.time
339            );
340            // Three hourly centres are 59, 99 and 139; the period covers x=41..157.
341            // A linear 0..20 map places O=10 at y=50 and C=15 at y=25.
342            let start: chrono::DateTime<chrono::Utc> = "2024-03-08T00:00:00Z".parse().unwrap();
343            let bars: Vec<_> = (0..3)
344                .map(|i| {
345                    Bar::new(
346                        start + chrono::TimeDelta::hours(i),
347                        10.0,
348                        18.0,
349                        2.0,
350                        15.0,
351                        1.0,
352                    )
353                })
354                .collect();
355            for (mode, inverted, y_open, y_close, y_high, y_low) in [
356                (
357                    PriceScaleMode::Normal,
358                    false,
359                    50.0_f32,
360                    25.0_f32,
361                    10.0,
362                    90.0,
363                ),
364                (PriceScaleMode::Normal, true, 50.0, 75.0, 90.0, 10.0),
365                // log10(1)..log10(100): O=10 is halfway, C=15 at 100-50*log10(15).
366                (
367                    PriceScaleMode::Logarithmic,
368                    false,
369                    50.0,
370                    41.195435,
371                    37.236374,
372                    84.9485,
373                ),
374            ] {
375                let mut study = ConfiguredStudy::new(Box::new(HigherIntervalCandles::default()));
376                study.set_timeframe(Timeframe::Hour1);
377                study.calculate(&bars);
378                let mut settings = study.settings();
379                settings.plots[0].color = Color32::BLUE;
380                study.apply_settings(&[], settings).unwrap();
381                let mut state =
382                    crate::studies::ChartState::new(crate::model::BarData::from_bars(bars.clone()));
383                state.time_scale_mut().set_width(40.0);
384                state.time_scale_mut().set_bar_spacing(40.0);
385                state.price_scale_mut().options_mut().mode = mode;
386                let (transform, _) = state.fit_price_scale_with_series(
387                    [&study as &dyn Indicator],
388                    None,
389                    false,
390                    None,
391                    None,
392                    std::iter::empty(),
393                    std::iter::empty(),
394                );
395                let fitted = state.price_scale().range();
396                assert!(
397                    transform.to_price(fitted.min) <= 2.0 && transform.to_price(fitted.max) >= 18.0,
398                    "the visible portion of a period must fit its complete wick"
399                );
400                let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(200.0, 100.0));
401                let range = if mode == PriceScaleMode::Normal {
402                    PriceRange::new(0.0, 20.0)
403                } else {
404                    PriceRange::new(0.0, 100.0_f64.ln())
405                };
406                let price = PriceMap::new(
407                    PriceTransform { mode, base: 1.0 },
408                    range,
409                    0.0,
410                    100.0,
411                    inverted,
412                );
413                let coords = ChartMapping::new(rect, 40.0, 0, 3, 0.0, price);
414                let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
415                    IndicatorRenderer::render_study(
416                        &RenderContext::new(ui.painter(), rect),
417                        &study,
418                        &bars,
419                        0,
420                        &price,
421                        &coords,
422                    );
423                });
424                let body = output.shapes.iter().find_map(|shape| match &shape.shape {
425                    Shape::Rect(body) if body.stroke.color == Color32::BLUE => Some(body.rect),
426                    _ => None,
427                });
428                let wick = output.shapes.iter().find_map(|shape| match &shape.shape {
429                    Shape::LineSegment { points, stroke } if stroke.color == Color32::BLUE => {
430                        Some(*points)
431                    }
432                    _ => None,
433                });
434                output.drop_without_applying_deltas();
435                let body = body.expect("period body must replace the close-price line");
436                let wick = wick.expect("period wick");
437                for (actual, expected) in [
438                    (body.left(), 41.0),
439                    (body.right(), 157.0),
440                    (body.top(), y_open.min(y_close)),
441                    (body.bottom(), y_open.max(y_close)),
442                    (wick[0].x, 99.0),
443                    (wick[0].y, y_high),
444                    (wick[1].y, y_low),
445                ] {
446                    assert!(
447                        (actual - expected).abs() < 0.001,
448                        "{mode:?}, inverted {inverted}: {actual} != {expected}"
449                    );
450                }
451            }
452        }
453    }
454}