Skip to main content

egui_charts/ui/strategy_report/
tester.rs

1//! Strategy selection, execution and optimisation on host-supplied history.
2
3use std::{
4    cell::Cell,
5    future::Future,
6    pin::Pin,
7    rc::Rc,
8    sync::Arc,
9    task::{Context, Poll, Waker},
10};
11
12use chrono::{DateTime, NaiveDate, Utc};
13use egui::Ui;
14
15use super::{
16    StrategyReportAction, StrategyReportPanel,
17    optimisation::{self, Range},
18};
19use crate::{
20    localization as l10n,
21    model::{Bar, Signal, StrategyReport, Timeframe},
22    strategies::{Backtest, BacktestSettings, Strategy, StrategyFactory},
23    studies::{InputValue, StudyInput},
24    theme::ThemeExt,
25    ui_kit::tab_bar::{TabBar, TabLabel},
26};
27
28/// History needed for one run. Answer with [`StrategyTester::set_history`] or
29/// [`StrategyTester::history_failed`]; the library never fetches it itself.
30#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct StrategyHistoryRequest {
33    /// Identifies this request within the tester, including otherwise identical reruns.
34    pub id: u64,
35    /// Instrument to request.
36    pub symbol: String,
37    /// Decision interval.
38    pub timeframe: Timeframe,
39    /// Requested beginning. Include earlier bars when needed to warm the strategy.
40    pub start: DateTime<Utc>,
41    /// Finer fill interval, or `None` for chart-bar fills.
42    pub lower_timeframe: Option<Timeframe>,
43}
44
45/// Host intents from the tester. Replace the previous run's markers when a run completes.
46#[derive(Clone, Debug, PartialEq)]
47#[non_exhaustive]
48pub enum StrategyTesterAction {
49    /// Fetch chart history and, when requested, finer fill bars for this run.
50    RequestHistory(StrategyHistoryRequest),
51    /// Replace markers for the given scope with these entries and exits, including an empty list.
52    Signals {
53        /// Instrument of the completed run.
54        symbol: String,
55        /// Decision interval of the completed run.
56        timeframe: Timeframe,
57        /// Labelled fills from [`StrategyReport::signals`].
58        signals: Vec<Signal>,
59    },
60    /// Navigation or export intent from the report view.
61    Report(StrategyReportAction),
62}
63
64#[derive(Clone, Copy, Default, PartialEq)]
65enum Tab {
66    Inputs,
67    Properties,
68    Optimisation,
69    #[default]
70    Report,
71}
72
73impl TabLabel for Tab {
74    fn tab_label(&self) -> &str {
75        match self {
76            Self::Inputs => "Inputs",
77            Self::Properties => "Backtest settings",
78            Self::Optimisation => "Optimisation",
79            Self::Report => "Report",
80        }
81    }
82}
83
84struct Trial {
85    inputs: Vec<StudyInput>,
86    label: String,
87    profit: f64,
88    drawdown: Option<f64>,
89    trades: usize,
90}
91
92enum JobOutput {
93    Report(StrategyReport, Vec<Bar>),
94    Trials(Vec<Trial>),
95}
96
97type Job = Pin<Box<dyn Future<Output = Result<JobOutput, &'static str>>>>;
98
99/// A factory-backed strategy tester, available with `ui` and `strategies`.
100///
101/// Call [`Self::show`] with completed bars of the chart's current symbol and interval. Run and
102/// optimisation use a snapshot of those bars and yield between execution batches on native and
103/// web builds. Host strategy preparation and callbacks must be bounded, as documented on
104/// [`Backtest::run_async`]. Edits cancel pending work and retain the previous report as stale.
105/// History replies are tied to a request id and discarded after an edit or scope change.
106/// Give each tester its own egui id scope.
107pub struct StrategyTester {
108    factory: Arc<StrategyFactory>,
109    name: String,
110    inputs: Vec<StudyInput>,
111    settings: BacktestSettings,
112    start: String,
113    lower: bool,
114    lower_timeframe: Timeframe,
115    tab: Tab,
116    report: StrategyReportPanel,
117    stale: bool,
118    error: Option<String>,
119    scope: Option<(String, Timeframe)>,
120    revision: u64,
121    pending: Option<(StrategyHistoryRequest, bool)>,
122    job: Option<Job>,
123    completed: Rc<Cell<usize>>,
124    total: usize,
125    ranges: Vec<Range>,
126    trials: Vec<Trial>,
127    sort: usize,
128    ascending: bool,
129}
130
131impl Default for StrategyTester {
132    fn default() -> Self {
133        Self::new(StrategyFactory::new())
134    }
135}
136
137impl StrategyTester {
138    /// Use the built-in catalogue and any constructors the host registered in `factory`.
139    pub fn new(factory: StrategyFactory) -> Self {
140        let name = factory.list().first().map_or("", |name| *name).to_owned();
141        let inputs = factory
142            .create(&name)
143            .map_or_else(Vec::new, |strategy| strategy.study_inputs());
144        let ranges = inputs.iter().filter_map(Range::new).collect();
145        Self {
146            factory: Arc::new(factory),
147            name,
148            inputs,
149            ranges,
150            settings: BacktestSettings::default(),
151            start: String::new(),
152            lower: false,
153            lower_timeframe: Timeframe::Sec1,
154            tab: Tab::Report,
155            report: StrategyReportPanel::default(),
156            stale: true,
157            error: None,
158            scope: None,
159            revision: 0,
160            pending: None,
161            job: None,
162            completed: Rc::new(Cell::new(0)),
163            total: 0,
164            trials: Vec::new(),
165            sort: 1,
166            ascending: false,
167        }
168    }
169
170    /// Current strategy name in the factory.
171    pub fn strategy(&self) -> &str {
172        &self.name
173    }
174
175    /// Select a strategy and restore its declared defaults. Unknown names leave the form intact.
176    pub fn select_strategy(&mut self, name: &str) -> Result<(), &'static str> {
177        let strategy = self.factory.create(name).ok_or("Unknown strategy")?;
178        self.name = name.into();
179        self.inputs = strategy.study_inputs();
180        self.ranges = self.inputs.iter().filter_map(Range::new).collect();
181        self.changed();
182        Ok(())
183    }
184
185    /// Edit an input through the strategy's own validation.
186    pub fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), &'static str> {
187        let mut strategy = self.configured_strategy()?;
188        strategy.set_input(id, value)?;
189        self.inputs = strategy.study_inputs();
190        self.changed();
191        Ok(())
192    }
193
194    /// Current account and execution assumptions.
195    pub fn settings(&self) -> &BacktestSettings {
196        &self.settings
197    }
198
199    /// Replace assumptions, cancelling pending work and marking the report stale.
200    pub fn set_settings(&mut self, settings: BacktestSettings) -> Result<(), &'static str> {
201        super::properties::validate(&settings)?;
202        if self.settings != settings {
203            self.start = settings
204                .start_time
205                .map_or_else(String::new, |time| time.to_rfc3339());
206            self.settings = settings;
207            self.changed();
208        }
209        Ok(())
210    }
211
212    /// Whether the report no longer describes the current form or chart scope.
213    pub fn is_stale(&self) -> bool {
214        self.stale
215    }
216
217    /// The last completed or host-supplied report.
218    pub fn report(&self) -> Option<&StrategyReport> {
219        self.report.report()
220    }
221
222    /// Replace or clear a host report, cancelling pending work. A host report is not a run of the
223    /// current form, so it stays stale until Run completes.
224    pub fn set_report(&mut self, report: Option<StrategyReport>, bars: &[Bar]) {
225        self.changed();
226        self.tab = Tab::Report;
227        self.report.set_report(report, bars);
228    }
229
230    /// Show only the report, without the tester form, while a host-managed run supplies updates.
231    pub fn show_report(&mut self, ui: &mut Ui) -> StrategyReportAction {
232        self.report.show(ui)
233    }
234
235    /// Supply both histories for the exact pending request. A superseded reply is rejected.
236    /// Empty finer history is refused rather than silently using chart-bar fills.
237    pub fn set_history(
238        &mut self,
239        request: &StrategyHistoryRequest,
240        bars: Vec<Bar>,
241        lower: Vec<Bar>,
242    ) -> Result<(), &'static str> {
243        let Some((pending, optimise)) = &self.pending else {
244            return Err("History request is no longer current");
245        };
246        if pending != request {
247            return Err("History request is no longer current");
248        }
249        if bars.is_empty() || (request.lower_timeframe.is_some() && lower.is_empty()) {
250            return Err("Requested history is empty");
251        }
252        let optimise = *optimise;
253        self.pending = None;
254        self.start_job(&request.symbol, request.timeframe, bars, lower, optimise)
255    }
256
257    /// Show the host's failure for the exact pending request. Returns false for a late failure.
258    pub fn history_failed(
259        &mut self,
260        request: &StrategyHistoryRequest,
261        message: impl Into<String>,
262    ) -> bool {
263        if self
264            .pending
265            .as_ref()
266            .is_none_or(|(pending, _)| pending != request)
267        {
268            return false;
269        }
270        self.pending = None;
271        self.error = Some(message.into());
272        true
273    }
274
275    fn changed(&mut self) {
276        self.stale = true;
277        self.revision = self.revision.wrapping_add(1);
278        self.pending = None;
279        self.job = None;
280        self.trials.clear();
281        self.error = None;
282    }
283
284    fn configured_strategy(&self) -> Result<Box<dyn Strategy>, &'static str> {
285        let mut strategy = self.factory.create(&self.name).ok_or("Unknown strategy")?;
286        for input in &self.inputs {
287            strategy.set_input(&input.id, input.value.clone())?;
288        }
289        Ok(strategy)
290    }
291
292    fn start_job(
293        &mut self,
294        symbol: &str,
295        timeframe: Timeframe,
296        bars: Vec<Bar>,
297        lower: Vec<Bar>,
298        optimise: bool,
299    ) -> Result<(), &'static str> {
300        super::properties::validate(&self.settings)?;
301        self.configured_strategy()?;
302        let (count, axes) = if optimise {
303            optimisation::grid(&self.ranges)?
304        } else {
305            (1, Vec::new())
306        };
307        let factory = self.factory.clone();
308        let name = self.name.clone();
309        let inputs = self.inputs.clone();
310        let mut engine = Backtest::new(&name, symbol, timeframe, self.settings.initial_capital);
311        engine.settings = self.settings.clone();
312        self.total = count;
313        self.completed = Rc::new(Cell::new(0));
314        let completed = self.completed.clone();
315        self.error = None;
316        self.trials.clear();
317        self.job = Some(Box::pin(async move {
318            let mut trials = Vec::new();
319            for ordinal in 0..count {
320                let mut inputs = inputs.clone();
321                let mut index = ordinal;
322                for (id, values) in &axes {
323                    let input = inputs
324                        .iter_mut()
325                        .find(|input| input.id == *id)
326                        .ok_or("Unknown optimisation input")?;
327                    input.value = values[index % values.len()].clone();
328                    index /= values.len();
329                }
330                let mut strategy = factory.create(&name).ok_or("Unknown strategy")?;
331                for input in &inputs {
332                    strategy.set_input(&input.id, input.value.clone())?;
333                }
334                let label = strategy
335                    .inputs()
336                    .iter()
337                    .map(|(name, value)| format!("{name}: {value}"))
338                    .collect::<Vec<_>>()
339                    .join(", ");
340                let report = engine.run_async(strategy.as_mut(), &bars, &lower).await;
341                completed.set(ordinal + 1);
342                if !optimise {
343                    return Ok(JobOutput::Report(report, bars));
344                }
345                let performance = report.performance();
346                trials.push(Trial {
347                    inputs,
348                    label,
349                    profit: performance.net_profit,
350                    drawdown: performance.max_drawdown,
351                    trades: performance.closed_trades,
352                });
353            }
354            Ok(JobOutput::Trials(trials))
355        }));
356        Ok(())
357    }
358
359    fn request_run(
360        &mut self,
361        symbol: &str,
362        timeframe: Timeframe,
363        bars: &[Bar],
364        optimise: bool,
365    ) -> Result<Option<StrategyTesterAction>, &'static str> {
366        if bars.is_empty() && self.settings.start_time.is_none() {
367            return Err("Supply completed chart bars or a start date before running");
368        }
369        if self.lower
370            && (timeframe.is_trade_based()
371                || self.lower_timeframe.is_trade_based()
372                || self.lower_timeframe.duration() >= timeframe.duration())
373        {
374            return Err("Choose a fill interval shorter than the chart interval");
375        }
376        super::properties::validate(&self.settings)?;
377        self.configured_strategy()?;
378        if optimise {
379            optimisation::grid(&self.ranges)?;
380        }
381        let stale = self.stale;
382        self.changed();
383        if optimise {
384            self.stale = stale;
385        }
386        if self.settings.start_time.is_some() || self.lower {
387            let request = StrategyHistoryRequest {
388                id: self.revision,
389                symbol: symbol.into(),
390                timeframe,
391                start: self
392                    .settings
393                    .start_time
394                    .or_else(|| bars.first().map(|bar| bar.time))
395                    .ok_or("Supply a start date")?,
396                lower_timeframe: self.lower.then_some(self.lower_timeframe),
397            };
398            self.pending = Some((request.clone(), optimise));
399            Ok(Some(StrategyTesterAction::RequestHistory(request)))
400        } else {
401            self.start_job(symbol, timeframe, bars.to_vec(), Vec::new(), optimise)?;
402            Ok(None)
403        }
404    }
405
406    /// Draw the form, advance pending execution by one batch and return all host intents.
407    /// Bars must belong to `symbol` and `timeframe`; do not include the live, unfinished bar.
408    pub fn show(
409        &mut self,
410        ui: &mut Ui,
411        symbol: &str,
412        timeframe: Timeframe,
413        bars: &[Bar],
414    ) -> Vec<StrategyTesterAction> {
415        let mut actions = Vec::new();
416        if self
417            .scope
418            .as_ref()
419            .is_none_or(|scope| scope.0 != symbol || scope.1 != timeframe)
420        {
421            self.changed();
422            self.scope = Some((symbol.into(), timeframe));
423        }
424        let mut run = false;
425        let mut optimise = false;
426        ui.horizontal_wrapped(|ui| {
427            ui.strong(l10n::text(ui.ctx(), "Strategy tester"));
428            let mut name = self.name.clone();
429            egui::ComboBox::from_id_salt("strategy")
430                .selected_text(&name)
431                .show_ui(ui, |ui| {
432                    for choice in self.factory.list() {
433                        ui.selectable_value(&mut name, choice.to_owned(), choice);
434                    }
435                })
436                .response
437                .accessible_name(l10n::text(ui.ctx(), "Strategy"));
438            if name != self.name
439                && let Err(error) = self.select_strategy(&name)
440            {
441                self.error = Some(error.into());
442            }
443            run = ui
444                .add_enabled(
445                    self.job.is_none() && self.pending.is_none(),
446                    egui::Button::new(l10n::text(ui.ctx(), "Run")),
447                )
448                .clicked();
449            if self.stale && self.report().is_some() {
450                ui.colored_label(
451                    ui.active_theme().ui.warning,
452                    l10n::text(ui.ctx(), "Result is stale. Run again to update."),
453                );
454            }
455            if self.pending.is_some() {
456                ui.label(l10n::text(ui.ctx(), "Waiting for host history"));
457            }
458            if self.job.is_some() || self.pending.is_some() {
459                ui.label(format!("{} / {} trials", self.completed.get(), self.total));
460                if ui.button(l10n::text(ui.ctx(), "Cancel run")).clicked() {
461                    self.changed();
462                }
463            }
464        });
465        if let Some(error) = &self.error {
466            ui.colored_label(ui.active_theme().ui.error, l10n::text(ui.ctx(), error));
467        }
468        egui::ScrollArea::horizontal()
469            .id_salt("tester_tabs")
470            .show(ui, |ui| {
471                TabBar::new(
472                    &[Tab::Inputs, Tab::Properties, Tab::Optimisation, Tab::Report],
473                    &mut self.tab,
474                )
475                .show(ui);
476            });
477        let mut edited = false;
478        match self.tab {
479            Tab::Report => {
480                let action = self.show_report(ui);
481                if action != StrategyReportAction::None {
482                    actions.push(StrategyTesterAction::Report(action));
483                }
484            }
485            _ => {
486                egui::ScrollArea::both()
487                    .id_salt("tester_form")
488                    .show(ui, |ui| match self.tab {
489                        Tab::Inputs => {
490                            crate::ui_kit::FormGrid::new("strategy_inputs").show(ui, |ui| {
491                                for input in &mut self.inputs {
492                                    edited |= super::inputs::show_input(ui, input, &[]);
493                                }
494                            });
495                            if ui.button(l10n::text(ui.ctx(), "Reset inputs")).clicked() {
496                                for input in &mut self.inputs {
497                                    input.value = input.default.clone();
498                                }
499                                edited = true;
500                            }
501                        }
502                        Tab::Properties => {
503                            edited |= super::properties::show(
504                                ui,
505                                &mut self.settings,
506                                &mut self.start,
507                                &mut self.lower,
508                                &mut self.lower_timeframe,
509                            );
510                        }
511                        Tab::Optimisation => {
512                            if optimisation::show(ui, &mut self.ranges) {
513                                let stale = self.stale;
514                                self.changed();
515                                self.stale = stale;
516                            }
517                            match optimisation::grid(&self.ranges) {
518                                Ok((count, _)) => {
519                                    ui.label(format!("{count} trials"));
520                                    optimise = ui
521                                        .add_enabled(
522                                            self.job.is_none() && self.pending.is_none(),
523                                            egui::Button::new(l10n::text(
524                                                ui.ctx(),
525                                                "Run optimisation",
526                                            )),
527                                        )
528                                        .clicked();
529                                }
530                                Err(error) => {
531                                    ui.label(l10n::text(ui.ctx(), error));
532                                }
533                            }
534                            edited |= self.show_trials(ui);
535                        }
536                        Tab::Report => {}
537                    });
538            }
539        }
540        if edited {
541            self.changed();
542        }
543        let valid_start = if self.start.trim().is_empty() {
544            Ok(None)
545        } else {
546            DateTime::parse_from_rfc3339(&self.start)
547                .map(|time| Some(time.with_timezone(&Utc)))
548                .or_else(|_| {
549                    NaiveDate::parse_from_str(&self.start, "%Y-%m-%d")
550                        .map(|date| Some(date.and_hms_opt(0, 0, 0).unwrap().and_utc()))
551                })
552                .map_err(|_| "Enter a UTC date (YYYY-MM-DD) or an RFC 3339 timestamp")
553        };
554        match valid_start {
555            Ok(start) => {
556                self.settings.start_time = start;
557                if run || optimise {
558                    match self.request_run(symbol, timeframe, bars, optimise) {
559                        Ok(Some(action)) => actions.push(action),
560                        Ok(None) => {}
561                        Err(error) => self.error = Some(error.into()),
562                    }
563                }
564            }
565            Err(error) => {
566                self.error = Some(error.into());
567            }
568        }
569        if let Some(job) = &mut self.job {
570            match job.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
571                Poll::Pending => ui.ctx().request_repaint(),
572                Poll::Ready(result) => {
573                    self.job = None;
574                    match result {
575                        Ok(JobOutput::Report(report, bars)) => {
576                            actions.push(StrategyTesterAction::Signals {
577                                symbol: report.symbol.clone(),
578                                timeframe: report.timeframe,
579                                signals: report.signals(),
580                            });
581                            let from = bars.partition_point(|bar| {
582                                self.settings
583                                    .start_time
584                                    .is_some_and(|start| bar.time < start)
585                            });
586                            self.report.set_report(Some(report), &bars[from..]);
587                            self.stale = false;
588                            self.tab = Tab::Report;
589                        }
590                        Ok(JobOutput::Trials(trials)) => {
591                            self.trials = trials;
592                            self.sort_trials();
593                        }
594                        Err(error) => self.error = Some(error.into()),
595                    }
596                    ui.ctx().request_repaint();
597                }
598            }
599        }
600        actions
601    }
602
603    fn sort_trials(&mut self) {
604        self.trials.sort_by(|a, b| {
605            let order = match self.sort {
606                1 => a.profit.total_cmp(&b.profit),
607                2 => a
608                    .drawdown
609                    .partial_cmp(&b.drawdown)
610                    .unwrap_or(std::cmp::Ordering::Equal),
611                3 => a.trades.cmp(&b.trades),
612                _ => a.label.cmp(&b.label),
613            };
614            if self.ascending {
615                order
616            } else {
617                order.reverse()
618            }
619        });
620    }
621
622    fn show_trials(&mut self, ui: &mut Ui) -> bool {
623        let mut sort = None;
624        let mut apply = None;
625        use egui_extras::{Column, TableBuilder};
626        TableBuilder::new(ui)
627            .striped(true)
628            .resizable(true)
629            .column(Column::initial(260.0))
630            .columns(Column::initial(100.0), 3)
631            .column(Column::remainder().at_least(100.0))
632            .header(28.0, |mut header| {
633                for (index, label) in ["Inputs", "Net profit", "Drawdown", "Closed trades"]
634                    .iter()
635                    .enumerate()
636                {
637                    header.col(|ui| {
638                        if ui.button(l10n::text(ui.ctx(), *label)).clicked() {
639                            sort = Some(index);
640                        }
641                    });
642                }
643                header.col(|_| {});
644            })
645            .body(|body| {
646                body.rows(28.0, self.trials.len(), |mut row| {
647                    let index = row.index();
648                    let trial = &self.trials[index];
649                    row.col(|ui| {
650                        ui.label(&trial.label);
651                    });
652                    row.col(|ui| {
653                        ui.label(super::number(Some(trial.profit)));
654                    });
655                    row.col(|ui| {
656                        ui.label(super::number(trial.drawdown));
657                    });
658                    row.col(|ui| {
659                        ui.label(trial.trades.to_string());
660                    });
661                    row.col(|ui| {
662                        if ui.button(l10n::text(ui.ctx(), "Apply inputs")).clicked() {
663                            apply = Some(index);
664                        }
665                    });
666                });
667            });
668        if let Some(index) = sort {
669            if self.sort == index {
670                self.ascending = !self.ascending;
671            } else {
672                self.sort = index;
673                self.ascending = false;
674            }
675            self.sort_trials();
676        }
677        if let Some(index) = apply {
678            self.inputs = self.trials[index].inputs.clone();
679            self.tab = Tab::Inputs;
680            return true;
681        }
682        false
683    }
684}
685
686#[cfg(test)]
687#[path = "tester_tests.rs"]
688mod tests;