1use 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#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct StrategyHistoryRequest {
33 pub id: u64,
35 pub symbol: String,
37 pub timeframe: Timeframe,
39 pub start: DateTime<Utc>,
41 pub lower_timeframe: Option<Timeframe>,
43}
44
45#[derive(Clone, Debug, PartialEq)]
47#[non_exhaustive]
48pub enum StrategyTesterAction {
49 RequestHistory(StrategyHistoryRequest),
51 Signals {
53 symbol: String,
55 timeframe: Timeframe,
57 signals: Vec<Signal>,
59 },
60 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
99pub 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 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 pub fn strategy(&self) -> &str {
172 &self.name
173 }
174
175 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 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 pub fn settings(&self) -> &BacktestSettings {
196 &self.settings
197 }
198
199 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 pub fn is_stale(&self) -> bool {
214 self.stale
215 }
216
217 pub fn report(&self) -> Option<&StrategyReport> {
219 self.report.report()
220 }
221
222 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 pub fn show_report(&mut self, ui: &mut Ui) -> StrategyReportAction {
232 self.report.show(ui)
233 }
234
235 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 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 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;