1use 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#[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 timeframe: Option<Timeframe>,
36 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 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 if self.data_requests().is_empty() {
96 self.daily.clear();
97 }
98 }
99
100 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 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 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 #[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 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 #[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 (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}