1pub(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#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct StrategyTradeSelection {
35 pub trade_index: usize,
37 pub symbol: String,
39 pub timeframe: Timeframe,
41 pub entry_time: DateTime<Utc>,
43 pub exit_time: Option<DateTime<Utc>>,
45}
46
47#[derive(Clone, Debug, Default, PartialEq)]
49#[non_exhaustive]
50pub enum StrategyReportAction {
51 #[default]
53 None,
54 SelectTrade(StrategyTradeSelection),
56 ExportCsv(String),
58 ExportSummaryCsv {
61 filename: String,
63 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#[derive(Default)]
101pub struct StrategyReportPanel {
102 data: Option<ReportData>,
103 tab: Tab,
104 selected: Option<usize>,
105 compare: bool,
106}
107
108impl StrategyReportPanel {
109 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 pub fn report(&self) -> Option<&StrategyReport> {
149 self.data.as_ref().map(|d| &d.report)
150 }
151
152 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
257fn 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}