Skip to main content

egui_charts/studies/builtin/
performance.rs

1//! Percentage change over standard calendar periods, using the chart's own history.
2//!
3//! Each reference is the last finite close on or before the same trading date one week,
4//! one, three or six months, or one year earlier. Month subtraction clamps to the last day
5//! of a shorter month. Year to date uses the last close of the previous calendar year.
6//! Dates follow the instrument's trading calendar, as VWAP does. On an interval shorter than a
7//! day the study requests the symbol's daily bars and takes the later of the daily close and the
8//! chart's own close on or before each date, the daily close on the same date. Missing history
9//! and a zero reference close have no value.
10
11use super::anchor_period::ExchangeDays;
12use crate::model::{Bar, Instrument, Timeframe};
13use crate::scales::StudyFormat;
14use crate::studies::palette;
15use crate::studies::stepper::Stepper;
16use crate::studies::{
17    Indicator, IndicatorValue, IndicatorValues, PlotStyle, StudyCell, StudyData, StudyDataRequest,
18    StudyTable,
19};
20use chrono::{Datelike, Days, Months, NaiveDate};
21use egui::Color32;
22
23const PERIODS: [&str; 6] = ["1W", "1M", "3M", "6M", "YTD", "1Y"];
24
25/// Latest close's percentage change over six calendar periods, shown as a table.
26#[derive(Clone)]
27pub struct Performance {
28    days: ExchangeDays,
29    values: IndicatorValues,
30    stepper: Stepper<State>,
31    history: Vec<(NaiveDate, f64)>,
32    before_last_history: usize,
33    intraday: bool,
34    /// The chart interval, which decides whether daily bars are requested.
35    timeframe: Option<Timeframe>,
36    /// Trading date and finite close of each requested daily bar, in time order.
37    daily: Vec<(NaiveDate, f64)>,
38    table: StudyTable,
39    colors: [Color32; 6],
40    visible: bool,
41}
42
43#[derive(Clone, Default)]
44struct State {
45    source: usize,
46    previous_date: Option<NaiveDate>,
47    intraday: bool,
48    latest: [Option<f64>; 6],
49}
50
51impl Performance {
52    /// Performance over the chart's loaded bars, and on intraday charts over the daily bars the
53    /// host supplies for [`Indicator::data_requests`], in UTC until an instrument is supplied.
54    pub fn new() -> Self {
55        Self {
56            days: ExchangeDays::default(),
57            values: IndicatorValues::new(),
58            stepper: Stepper::default(),
59            history: Vec::new(),
60            before_last_history: 0,
61            intraday: false,
62            timeframe: None,
63            daily: Vec::new(),
64            table: StudyTable {
65                title: "Performance".into(),
66                columns: vec!["Period".into(), "Change".into()],
67                rows: Vec::new(),
68            },
69            colors: [palette::BLUE; 6],
70            visible: true,
71        }
72    }
73}
74
75impl Default for Performance {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl Indicator for Performance {
82    fn name(&self) -> &str {
83        "Performance"
84    }
85    fn desc(&self) -> &str {
86        "Percentage change over standard calendar periods"
87    }
88    fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
89        self.days.set(instrument)
90    }
91
92    fn set_timeframe(&mut self, timeframe: Timeframe) {
93        self.timeframe = Some(timeframe);
94        // Daily and longer charts read their own bars; drop daily bars of an earlier interval.
95        if self.data_requests().is_empty() {
96            self.daily.clear();
97        }
98    }
99
100    /// The chart symbol's daily bars, on intervals shorter than a day.
101    fn data_requests(&self) -> Vec<StudyDataRequest> {
102        self.timeframe
103            .filter(|timeframe| timeframe.duration_ms() < Timeframe::Day1.duration_ms())
104            .map(|_| StudyDataRequest {
105                symbol: String::new(),
106                timeframe: Some(Timeframe::Day1),
107                metric: None,
108            })
109            .into_iter()
110            .collect()
111    }
112
113    fn update_data(&mut self, data: &[Bar], from: usize, sources: &[StudyData<'_>]) {
114        if from == 0 {
115            let request = self.data_requests().pop();
116            self.daily = sources
117                .iter()
118                .find(|source| Some(&source.request) == request.as_ref())
119                .map_or_else(Vec::new, |source| {
120                    source
121                        .bars
122                        .iter()
123                        .filter(|bar| bar.close.is_finite())
124                        .map(|bar| (bar.time.date_naive(), bar.close))
125                        .collect()
126                });
127            // The lookup is a binary search, so bars out of time order count as none supplied.
128            if !self.daily.is_sorted_by_key(|(day, _)| *day) {
129                self.daily.clear();
130            }
131        }
132        self.update(data, from);
133    }
134
135    fn calculate(&mut self, data: &[Bar]) {
136        self.update_data(data, 0, &[]);
137    }
138
139    fn update(&mut self, data: &[Bar], mut from: usize) {
140        let len = self.values.len();
141        let start = if len > 0 && from >= len - 1 && data.len() >= len {
142            from.min(len)
143        } else {
144            0
145        };
146        if start == 0 {
147            self.history.clear();
148        } else if start < len {
149            self.history.truncate(self.before_last_history);
150        }
151        loop {
152            self.stepper
153                .run(data, from, &mut self.values, |state, bar| {
154                    let source = state.source;
155                    state.source += 1;
156                    if source + 1 == data.len() {
157                        self.before_last_history = self.history.len();
158                    }
159                    let date = bar.time.date_naive();
160                    state.intraday |= state.previous_date == Some(date);
161                    state.previous_date = Some(date);
162                    if !bar.close.is_finite() {
163                        return IndicatorValue::None;
164                    }
165                    let date = self.days.day(bar, !self.intraday);
166                    let targets = [
167                        date.checked_sub_days(Days::new(7)),
168                        date.checked_sub_months(Months::new(1)),
169                        date.checked_sub_months(Months::new(3)),
170                        date.checked_sub_months(Months::new(6)),
171                        NaiveDate::from_ymd_opt(date.year(), 1, 1).and_then(|day| day.pred_opt()),
172                        date.checked_sub_months(Months::new(12)),
173                    ];
174                    let before = |history: &[(NaiveDate, f64)], target| {
175                        let at = history.partition_point(|(day, _)| *day <= target);
176                        at.checked_sub(1).map(|at| history[at])
177                    };
178                    for (line, target) in targets.into_iter().enumerate() {
179                        state.latest[line] = target.and_then(|target| {
180                            // The later of the two closes; on the same date the daily one,
181                            // since `max_by_key` keeps the last of equal keys.
182                            let (_, reference) =
183                                [before(&self.history, target), before(&self.daily, target)]
184                                    .into_iter()
185                                    .flatten()
186                                    .max_by_key(|(day, _)| *day)?;
187                            let change = 100.0 * (bar.close - reference) / reference;
188                            (reference != 0.0 && change.is_finite()).then_some(change)
189                        });
190                    }
191                    self.history.push((date, bar.close));
192                    IndicatorValue::Multiple(
193                        state.latest.map(|value| value.unwrap_or(f64::NAN)).to_vec(),
194                    )
195                });
196            if self.intraday == self.stepper.state().intraday {
197                break;
198            }
199            self.intraday = self.stepper.state().intraday;
200            self.history.clear();
201            from = 0;
202        }
203        self.table.rows = PERIODS
204            .into_iter()
205            .zip(self.stepper.state().latest)
206            .map(|(period, value)| vec![StudyCell::Text(period.into()), StudyCell::Percent(value)])
207            .collect();
208    }
209
210    fn is_incremental(&self) -> bool {
211        true
212    }
213
214    fn table(&self) -> Option<&StudyTable> {
215        Some(&self.table)
216    }
217    fn plot_style(&self, line: usize) -> PlotStyle {
218        PlotStyle {
219            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
220            width: 1.0,
221            pattern: crate::model::LineStyle::Solid,
222            plot_type: crate::studies::PlotType::Line,
223            visible: false,
224        }
225    }
226    fn value_format(&self) -> StudyFormat {
227        StudyFormat::Percent
228    }
229    fn values(&self) -> &IndicatorValues {
230        &self.values
231    }
232    fn colors(&self) -> Vec<Color32> {
233        self.colors.to_vec()
234    }
235    fn set_colors(&mut self, colors: Vec<Color32>) {
236        for (old, new) in self.colors.iter_mut().zip(colors) {
237            *old = new;
238        }
239    }
240    fn is_visible(&self) -> bool {
241        self.visible
242    }
243    fn set_visible(&mut self, visible: bool) {
244        self.visible = visible;
245    }
246    fn clone_box(&self) -> Box<dyn Indicator> {
247        Box::new(self.clone())
248    }
249    fn line_names(&self) -> Vec<String> {
250        PERIODS.map(String::from).to_vec()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use crate::model::Bar;
257    use crate::studies::*;
258    use chrono::{TimeZone, Utc};
259
260    /// Calendar lookbacks use the last close on or before the target date, not bar counts.
261    #[test]
262    fn performance_uses_calendar_dates_and_missing_history() {
263        let bars: Vec<_> = [
264            (2023, 9, 29, 80.0),
265            (2023, 12, 29, 100.0),
266            (2024, 2, 28, 120.0),
267            (2024, 2, 29, 125.0),
268            (2024, 3, 22, 150.0),
269            (2024, 3, 31, 180.0),
270        ]
271        .map(|(y, m, d, c)| {
272            Bar::new(
273                Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap(),
274                c,
275                c,
276                c,
277                c,
278                1.0,
279            )
280        })
281        .to_vec();
282        let mut study = IndicatorFactory::new()
283            .create("Performance")
284            .expect("Performance study");
285        study.calculate(&bars);
286        // March 24 -> March 22: 180/150-1 = 20%; February 29: 44%;
287        // December 31 -> December 29: 80%; September 30 -> September 29: 125%.
288        for (line, expected) in [
289            Some(20.0),
290            Some(44.0),
291            Some(80.0),
292            Some(125.0),
293            Some(80.0),
294            None,
295        ]
296        .into_iter()
297        .enumerate()
298        {
299            let got = study.values().value(line, bars.len() - 1);
300            match expected {
301                Some(expected) => assert!(
302                    (got.unwrap() - expected).abs() < 1e-10,
303                    "line {line}: {got:?}"
304                ),
305                None => assert_eq!(got, None, "line {line}"),
306            }
307        }
308        #[cfg(feature = "widget")]
309        {
310            use crate::widget::{Chart, test_frames::shapes};
311            let ctx = egui::Context::default();
312            let mut chart = Chart::new(crate::model::BarData::from_bars(bars));
313            let id = chart.add_indicator(study);
314            let painted = shapes(&ctx, &mut chart, 0.0, Vec::new());
315            for expected in ["Change", "1W", "+44.00%", "N/A"] {
316                assert!(painted.iter().any(|shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == expected)), "missing table cell {expected}");
317            }
318            chart.indicator_mut(id).unwrap().set_visible(false);
319            let painted = shapes(&ctx, &mut chart, 1.0, Vec::new());
320            assert!(!painted.iter().any(
321                |shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == "Change")
322            ));
323        }
324    }
325
326    /// On an intraday chart each period starts from the requested daily bars, the daily close
327    /// winning on the same date, and from the chart's own bars when none are supplied or they
328    /// are out of time order.
329    #[test]
330    fn performance_takes_period_starts_from_daily_bars() {
331        use crate::model::{BarClock, Timeframe};
332        let bar = |(y, m, d, h, min, c)| {
333            Bar::new(
334                Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap(),
335                c,
336                c,
337                c,
338                c,
339                1.0,
340            )
341        };
342        let daily = [
343            (2023, 3, 15, 0, 0, 50.0),
344            (2023, 9, 15, 0, 0, 80.0),
345            (2023, 12, 14, 0, 0, 90.0),
346            (2023, 12, 29, 0, 0, 100.0),
347            (2024, 2, 15, 0, 0, 125.0),
348            (2024, 3, 8, 0, 0, 150.0),
349            // Today's developing bar is never a period start.
350            (2024, 3, 15, 0, 0, 999.0),
351        ]
352        .map(bar);
353        let today = [
354            (2024, 3, 15, 14, 30, 200.0),
355            (2024, 3, 15, 14, 31, 210.0),
356            (2024, 3, 15, 14, 32, 180.0),
357        ]
358        .map(bar);
359        let week_before = [&[bar((2024, 3, 8, 20, 0, 160.0))][..], &today].concat();
360        let request = StudyDataRequest {
361            symbol: String::new(),
362            timeframe: Some(Timeframe::Day1),
363            metric: None,
364        };
365        let from_daily = [20.0, 44.0, 100.0, 125.0, 80.0, 260.0].map(Some);
366        let without_daily = [Some(12.5), None, None, None, None, None];
367        let reversed: Vec<_> = daily.iter().rev().cloned().collect();
368        for (chart, supplied, expected) in [
369            (today.to_vec(), &daily[..], from_daily),
370            (week_before.clone(), &daily[..], from_daily),
371            (week_before.clone(), &[][..], without_daily),
372            (week_before, &reversed[..], without_daily),
373        ] {
374            let mut study = Performance::new();
375            study.set_timeframe(Timeframe::Min1);
376            assert_eq!(study.data_requests(), vec![request.clone()]);
377            let source = StudyData {
378                request: request.clone(),
379                bars: supplied,
380                clock: BarClock::new(Timeframe::Day1),
381                instrument: None,
382                timeframe: Timeframe::Day1,
383            };
384            study.update_data(&chart, 0, &[source]);
385            for (line, expected) in expected.into_iter().enumerate() {
386                let got = study.values().value(line, chart.len() - 1);
387                match expected {
388                    Some(expected) => assert!(
389                        (got.unwrap() - expected).abs() < 1e-10,
390                        "line {line}: {got:?}"
391                    ),
392                    None => assert_eq!(got, None, "line {line}"),
393                }
394            }
395            study.set_timeframe(Timeframe::Day1);
396            assert!(study.data_requests().is_empty());
397        }
398    }
399}