Skip to main content

egui_charts/ui/strategy_report/
mod.rs

1//! A replaceable strategy report, independent of chart navigation and host storage.
2
3pub(crate) mod inputs;
4#[cfg(feature = "strategies")]
5mod optimisation;
6mod plot;
7#[cfg(feature = "strategies")]
8mod properties;
9#[cfg(feature = "strategies")]
10mod tester;
11#[cfg(feature = "strategies")]
12pub use tester::{StrategyHistoryRequest, StrategyTester, StrategyTesterAction};
13#[cfg(test)]
14mod tests;
15
16use crate::localization as l10n;
17use crate::model::{
18    Bar, OrderSide, StrategyComparison, StrategyPerformance, StrategyReport, Timeframe,
19};
20use crate::theme::ThemeExt;
21use crate::ui_kit::{
22    EmptyState,
23    buttons::Button,
24    tab_bar::{TabBar, TabLabel},
25};
26use chrono::{DateTime, Utc};
27use egui::{RichText, Ui};
28use egui_extras::{Column, TableBuilder};
29
30/// A trade to reveal on the chart. The host switches symbol and interval, scrolls to the entry and
31/// highlights the span; an open trade has no exit boundary.
32#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct StrategyTradeSelection {
35    /// Index into the report's original `trades`, independent of display sorting or invalid rows.
36    pub trade_index: usize,
37    /// Instrument of the report.
38    pub symbol: String,
39    /// Source interval of the report.
40    pub timeframe: Timeframe,
41    /// Start of the trade's span.
42    pub entry_time: DateTime<Utc>,
43    /// End of the trade's span, or `None` while open.
44    pub exit_time: Option<DateTime<Utc>>,
45}
46
47/// An action for the host that mounts a [`StrategyReportPanel`].
48#[derive(Clone, Debug, Default, PartialEq)]
49#[non_exhaustive]
50pub enum StrategyReportAction {
51    /// No host action this frame.
52    #[default]
53    None,
54    /// Reveal this trade on the chart.
55    SelectTrade(StrategyTradeSelection),
56    /// Save these valid trades as CSV.
57    ExportCsv(String),
58    /// Save the summary metrics and run properties shown, as [`StrategyReport::summary_csv`]
59    /// writes them, under the suggested file name.
60    ExportSummaryCsv {
61        /// File name derived from the run's name and symbol, ending in `.csv`.
62        filename: String,
63        /// The CSV text.
64        content: String,
65    },
66}
67
68#[derive(Clone, Copy, Default, PartialEq)]
69enum Tab {
70    #[default]
71    Summary,
72    Trades,
73    Properties,
74}
75
76impl TabLabel for Tab {
77    fn tab_label(&self) -> &str {
78        match self {
79            Self::Summary => "Summary",
80            Self::Trades => "List of trades",
81            Self::Properties => "Properties",
82        }
83    }
84}
85
86struct ReportData {
87    report: StrategyReport,
88    performance: StrategyPerformance,
89    comparison: StrategyComparison,
90    equity: Vec<(DateTime<Utc>, f64)>,
91    drawdown: Vec<(DateTime<Utc>, f64)>,
92    trades: Vec<usize>,
93}
94
95/// Standalone summary, equity/drawdown charts, trade list and run properties.
96///
97/// Call [`Self::set_report`] once when the host supplies or replaces a run. Metrics and display
98/// ordering are cached there. Mount [`Self::show`] in the host's panel below the chart; without a
99/// report it draws no controls and takes no space. Give each instance its own egui id scope.
100#[derive(Default)]
101pub struct StrategyReportPanel {
102    data: Option<ReportData>,
103    tab: Tab,
104    selected: Option<usize>,
105    compare: bool,
106}
107
108impl StrategyReportPanel {
109    /// Replace or clear the report in one call, resetting its tab and selection. `bars` must be the
110    /// complete source bars for this run's symbol and interval; pass an empty slice if unavailable.
111    pub fn set_report(&mut self, report: Option<StrategyReport>, bars: &[Bar]) {
112        self.tab = Tab::Summary;
113        self.selected = None;
114        self.compare = false;
115        self.data = report.map(|report| {
116            let performance = report.performance();
117            let comparison = report.comparison(bars);
118            let equity = report.equity_curve();
119            let mut peak = equity.first().map_or(0.0, |p| p.1);
120            let drawdown = equity
121                .iter()
122                .map(|&(t, e)| {
123                    peak = peak.max(e);
124                    (t, e - peak)
125                })
126                .filter(|(_, d)| d.is_finite())
127                .collect();
128            let mut trades: Vec<_> = report
129                .trades
130                .iter()
131                .enumerate()
132                .filter(|(_, t)| t.is_valid())
133                .map(|(i, _)| i)
134                .collect();
135            trades.sort_by_key(|&i| report.trades[i].entry_time);
136            ReportData {
137                report,
138                performance,
139                comparison,
140                equity,
141                drawdown,
142                trades,
143            }
144        });
145    }
146
147    /// The currently supplied report, or `None` when cleared.
148    pub fn report(&self) -> Option<&StrategyReport> {
149        self.data.as_ref().map(|d| &d.report)
150    }
151
152    /// Draw the report and return this frame's host intent. No report draws nothing.
153    pub fn show(&mut self, ui: &mut Ui) -> StrategyReportAction {
154        let Some(data) = &self.data else {
155            return StrategyReportAction::None;
156        };
157        let mut action = StrategyReportAction::None;
158        let theme = ui.active_theme();
159        ui.horizontal_wrapped(|ui| {
160            ui.label(
161                RichText::new(l10n::text(ui.ctx(), "Strategy report"))
162                    .strong()
163                    .size(theme.text.heading),
164            );
165            ui.label(RichText::new(&data.report.name).strong());
166            ui.label(format!(
167                "{} · {}",
168                data.report.symbol, data.report.timeframe
169            ));
170            if Button::new("Export trades CSV").show(ui).clicked() {
171                action = StrategyReportAction::ExportCsv(data.report.trades_csv());
172            }
173            if Button::new("Export summary CSV").show(ui).clicked() {
174                action = StrategyReportAction::ExportSummaryCsv {
175                    filename: summary_filename(&data.report),
176                    content: data.report.summary_csv(&data.performance, &data.comparison),
177                };
178            }
179        });
180        TabBar::new(&[Tab::Summary, Tab::Trades, Tab::Properties], &mut self.tab).show(ui);
181        if data.performance.skipped_trades
182            + data.performance.skipped_equity_points
183            + data.comparison.skipped_bars
184            > 0
185        {
186            ui.colored_label(
187                theme.ui.warning,
188                l10n::format_text(
189                    ui.ctx(),
190                    "Skipped input: {trades} trades, {equity} equity points, {bars} source bars",
191                    &[
192                        ("trades", data.performance.skipped_trades.to_string()),
193                        ("equity", data.performance.skipped_equity_points.to_string()),
194                        ("bars", data.comparison.skipped_bars.to_string()),
195                    ],
196                ),
197            );
198        }
199        match self.tab {
200            Tab::Summary => {
201                egui::ScrollArea::vertical()
202                    .id_salt("summary")
203                    .show(ui, |ui| {
204                        summary(ui, data, &mut self.compare);
205                    })
206                    .inner
207            }
208            Tab::Trades => {
209                if let Some(index) = trades(ui, data, &mut self.selected) {
210                    let trade = &data.report.trades[index];
211                    action = StrategyReportAction::SelectTrade(StrategyTradeSelection {
212                        trade_index: index,
213                        symbol: data.report.symbol.clone(),
214                        timeframe: data.report.timeframe,
215                        entry_time: trade.entry_time,
216                        exit_time: trade.exit.as_ref().map(|e| e.time),
217                    });
218                }
219            }
220            Tab::Properties => {
221                egui::ScrollArea::both()
222                    .id_salt("properties")
223                    .show(ui, |ui| {
224                        egui::Grid::new("run_properties")
225                            .spacing([theme.spacing.xxl, theme.spacing.sm])
226                            .striped(true)
227                            .show(ui, |ui| {
228                                property(ui, "Name", &data.report.name);
229                                property(ui, "Symbol", &data.report.symbol);
230                                property(ui, "Interval", &data.report.timeframe.to_string());
231                                property(
232                                    ui,
233                                    "Initial capital",
234                                    &money(Some(data.report.initial_capital), &data.report),
235                                );
236                                property(
237                                    ui,
238                                    "Currency",
239                                    data.report.currency.as_deref().unwrap_or("N/A"),
240                                );
241                                property(
242                                    ui,
243                                    "Risk-free rate",
244                                    &percent(Some(data.report.risk_free_rate)),
245                                );
246                                for (name, value) in &data.report.properties {
247                                    property(ui, name, value);
248                                }
249                            });
250                    });
251            }
252        }
253        action
254    }
255}
256
257/// `name-symbol-summary.csv`, with runs of characters other than letters and digits as one `-`.
258fn summary_filename(report: &StrategyReport) -> String {
259    let name = format!("{} {}", report.name, report.symbol);
260    let mut words: Vec<_> = name
261        .split(|c: char| !c.is_alphanumeric())
262        .filter(|word| !word.is_empty())
263        .collect();
264    if words.is_empty() {
265        words.push("strategy");
266    }
267    format!("{}-summary.csv", words.join("-"))
268}
269
270fn number(value: Option<f64>) -> String {
271    value
272        .filter(|v| v.is_finite())
273        .map_or_else(|| "N/A".into(), |v| format!("{v:.2}"))
274}
275
276fn percent(value: Option<f64>) -> String {
277    value
278        .map(|v| v * 100.0)
279        .filter(|v| v.is_finite())
280        .map_or_else(|| "N/A".into(), |v| format!("{v:.2}%"))
281}
282
283fn money(value: Option<f64>, report: &StrategyReport) -> String {
284    let text = number(value);
285    match &report.currency {
286        Some(currency) if text != "N/A" => format!("{text} {currency}"),
287        _ => text,
288    }
289}
290
291fn property(ui: &mut Ui, label: &str, value: &str) {
292    ui.label(l10n::text(ui.ctx(), label));
293    ui.label(value);
294    ui.end_row();
295}
296
297fn summary(ui: &mut Ui, data: &ReportData, compare: &mut bool) {
298    let p = &data.performance;
299    let report = &data.report;
300    let theme = ui.active_theme();
301    let metrics = [
302        ("Net profit", money(Some(p.net_profit), report)),
303        ("Net return", percent(p.net_profit_fraction)),
304        ("Gross profit", money(Some(p.gross_profit), report)),
305        ("Gross loss", money(Some(p.gross_loss), report)),
306        ("Profit factor", number(p.profit_factor)),
307        ("Win rate", percent(p.win_rate)),
308        ("Average trade", money(p.average_trade, report)),
309        ("Fees", money(Some(p.total_fees), report)),
310        ("Maximum drawdown", money(p.max_drawdown, report)),
311        ("Maximum drawdown %", percent(p.max_drawdown_fraction)),
312        ("Sharpe ratio", number(p.sharpe_ratio)),
313        ("Sortino ratio", number(p.sortino_ratio)),
314        (
315            "Buy-and-hold return",
316            percent(data.comparison.buy_and_hold_return),
317        ),
318        (
319            "Largest winning trade",
320            money(p.largest_winning_trade, report),
321        ),
322        (
323            "Largest losing trade",
324            money(p.largest_losing_trade, report),
325        ),
326        ("Consecutive wins", p.max_consecutive_wins.to_string()),
327        ("Consecutive losses", p.max_consecutive_losses.to_string()),
328        (
329            "Closed / open trades",
330            format!("{} / {}", p.closed_trades, p.open_trades),
331        ),
332    ];
333    let pairs = if ui.available_width() >= 760.0 { 2 } else { 1 };
334    egui::Grid::new("performance")
335        .striped(true)
336        .spacing([theme.spacing.xxl, theme.spacing.sm])
337        .show(ui, |ui| {
338            for (i, (label, value)) in metrics.iter().enumerate() {
339                ui.label(RichText::new(l10n::text(ui.ctx(), label)).color(theme.ui.text_secondary));
340                ui.label(RichText::new(value).strong());
341                if (i + 1) % pairs == 0 {
342                    ui.end_row();
343                }
344            }
345        });
346    ui.label(
347        RichText::new(l10n::text(
348            ui.ctx(),
349            "N/A means unavailable. Ratios require at least 13 weeks of equity.",
350        ))
351        .color(theme.ui.text_muted)
352        .size(theme.text.small),
353    );
354    ui.add_space(theme.spacing.md);
355    ui.horizontal_wrapped(|ui| {
356        ui.label(RichText::new(l10n::text(ui.ctx(), "Equity")).strong());
357        if !data.comparison.buy_and_hold_equity.is_empty() {
358            ui.checkbox(compare, l10n::text(ui.ctx(), "Compare buy-and-hold"));
359            if *compare {
360                ui.colored_label(theme.ui.warning, l10n::text(ui.ctx(), "Buy-and-hold"));
361            }
362        }
363        if let Some(currency) = &report.currency {
364            ui.label(currency);
365        }
366    });
367    plot::show(
368        ui,
369        &data.equity,
370        if *compare {
371            &data.comparison.buy_and_hold_equity
372        } else {
373            &[]
374        },
375        theme.ui.accent,
376        175.0,
377    );
378    ui.label(RichText::new(l10n::text(ui.ctx(), "Drawdown from peak")).strong());
379    plot::show(ui, &data.drawdown, &[], theme.ui.error, 125.0);
380}
381
382fn trades(ui: &mut Ui, data: &ReportData, selected: &mut Option<usize>) -> Option<usize> {
383    if data.trades.is_empty() {
384        EmptyState::new("No valid trades").show(ui);
385        return None;
386    }
387    ui.label(l10n::text(ui.ctx(), "Select a trade to reveal it on the chart. Times are UTC; MAE/MFE are before fees in price × quantity units."));
388    let mut clicked = None;
389    let ctx = ui.ctx().clone();
390    egui::ScrollArea::horizontal()
391        .id_salt("trade_columns")
392        .show(ui, |ui| {
393            ui.style_mut().interaction.selectable_labels = false;
394            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
395            TableBuilder::new(ui)
396                .striped(true)
397                .resizable(true)
398                .sense(egui::Sense::click())
399                .column(Column::initial(65.0))
400                .column(Column::initial(65.0))
401                .column(Column::initial(80.0))
402                .column(Column::initial(150.0))
403                .column(Column::initial(80.0))
404                .column(Column::initial(150.0))
405                .columns(Column::initial(85.0), 5)
406                .column(Column::remainder().at_least(100.0))
407                .header(28.0, |mut header| {
408                    for label in [
409                        "Trade",
410                        "Side",
411                        "Quantity",
412                        "Entry (UTC)",
413                        "Entry price",
414                        "Exit (UTC)",
415                        "Exit price",
416                        "Net profit",
417                        "Fees",
418                        "MAE",
419                        "MFE",
420                        "Label",
421                    ] {
422                        header.col(|ui| {
423                            ui.strong(l10n::text(ui.ctx(), label));
424                        });
425                    }
426                })
427                .body(|body| {
428                    body.rows(28.0, data.trades.len(), |mut row| {
429                        let ordinal = row.index();
430                        let index = data.trades[ordinal];
431                        let t = &data.report.trades[index];
432                        let excursion = data.comparison.excursions[index].as_ref();
433                        row.set_selected(*selected == Some(index));
434                        row.col(|ui| {
435                            if ui
436                                .selectable_label(
437                                    *selected == Some(index),
438                                    (ordinal + 1).to_string(),
439                                )
440                                .clicked()
441                            {
442                                clicked = Some(index);
443                            }
444                        });
445                        let time = |t: DateTime<Utc>| t.format("%Y-%m-%d %H:%M:%S%.f").to_string();
446                        for value in [
447                            l10n::text(
448                                &ctx,
449                                match t.side {
450                                    OrderSide::Buy => "Long",
451                                    OrderSide::Sell => "Short",
452                                },
453                            )
454                            .to_string(),
455                            t.quantity.to_string(),
456                            time(t.entry_time),
457                            t.entry_price.to_string(),
458                            t.exit.as_ref().map_or_else(
459                                || l10n::text(&ctx, "Open").to_string(),
460                                |e| time(e.time),
461                            ),
462                            t.exit
463                                .as_ref()
464                                .map_or_else(|| "N/A".into(), |e| e.price.to_string()),
465                            money(t.exit.as_ref().map(|e| e.profit), &data.report),
466                            money(Some(t.fees), &data.report),
467                            number(excursion.map(|e| e.adverse)),
468                            number(excursion.map(|e| e.favourable)),
469                            t.label.clone().unwrap_or_default(),
470                        ] {
471                            row.col(|ui| {
472                                ui.label(value);
473                            });
474                        }
475                        if row.response().clicked() {
476                            clicked = Some(index);
477                        }
478                    });
479                });
480        });
481    if let Some(index) = clicked {
482        *selected = Some(index);
483    }
484    clicked
485}