1use serde::{Deserialize, Serialize};
36
37use super::optimizer::OptimizeMetric;
38use super::result::BacktestResult;
39
40#[non_exhaustive]
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ComparisonRow {
46 pub label: String,
48
49 pub strategy_name: String,
51
52 pub symbol: String,
54
55 pub total_return_pct: f64,
57
58 pub annualized_return_pct: f64,
60
61 pub sharpe_ratio: f64,
63
64 pub sortino_ratio: f64,
66
67 pub calmar_ratio: f64,
69
70 pub max_drawdown_pct: f64,
74
75 pub win_rate: f64,
77
78 pub profit_factor: f64,
80
81 pub total_trades: usize,
83
84 pub kelly_criterion: f64,
86
87 pub sqn: f64,
89
90 pub expectancy: f64,
92
93 pub omega_ratio: f64,
95
96 pub time_in_market_pct: f64,
98
99 pub rank_score: f64,
101
102 pub rank: usize,
104}
105
106impl ComparisonRow {
107 fn from_result(label: &str, result: &BacktestResult, metric: OptimizeMetric) -> Self {
108 let m = &result.metrics;
109 let rank_score = metric.score(result);
110 ComparisonRow {
111 label: label.to_owned(),
112 strategy_name: result.strategy_name.clone(),
113 symbol: result.symbol.clone(),
114 total_return_pct: m.total_return_pct,
115 annualized_return_pct: m.annualized_return_pct,
116 sharpe_ratio: m.sharpe_ratio,
117 sortino_ratio: m.sortino_ratio,
118 calmar_ratio: m.calmar_ratio,
119 max_drawdown_pct: m.max_drawdown_pct,
120 win_rate: m.win_rate,
121 profit_factor: m.profit_factor,
122 total_trades: m.total_trades,
123 kelly_criterion: m.kelly_criterion,
124 sqn: m.sqn,
125 expectancy: m.expectancy,
126 omega_ratio: m.omega_ratio,
127 time_in_market_pct: m.time_in_market_pct,
128 rank_score,
129 rank: 0,
131 }
132 }
133}
134
135#[derive(Debug, Default)]
144pub struct BacktestComparison {
145 entries: Vec<(String, BacktestResult)>,
146}
147
148impl BacktestComparison {
149 pub fn new() -> Self {
151 Self::default()
152 }
153
154 pub fn add(mut self, label: impl Into<String>, result: BacktestResult) -> Self {
159 self.entries.push((label.into(), result));
160 self
161 }
162
163 pub fn ranked_by(self, metric: OptimizeMetric) -> ComparisonReport {
169 let mut rows: Vec<ComparisonRow> = self
170 .entries
171 .iter()
172 .map(|(label, result)| ComparisonRow::from_result(label, result, metric))
173 .collect();
174
175 rows.sort_by(|a, b| {
177 b.rank_score
178 .partial_cmp(&a.rank_score)
179 .unwrap_or(std::cmp::Ordering::Equal)
180 .then_with(|| {
181 b.total_return_pct
182 .partial_cmp(&a.total_return_pct)
183 .unwrap_or(std::cmp::Ordering::Equal)
184 })
185 });
186
187 for (idx, row) in rows.iter_mut().enumerate() {
189 row.rank = idx + 1;
190 }
191
192 ComparisonReport { rows, metric }
193 }
194}
195
196#[non_exhaustive]
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ComparisonReport {
203 pub rows: Vec<ComparisonRow>,
205 pub metric: OptimizeMetric,
207}
208
209impl ComparisonReport {
210 pub fn winner(&self) -> &str {
214 self.rows.first().map(|r| r.label.as_str()).unwrap_or("")
215 }
216
217 pub fn table(&self) -> &[ComparisonRow] {
219 &self.rows
220 }
221
222 pub fn winner_row(&self) -> Option<&ComparisonRow> {
224 self.rows.first()
225 }
226
227 pub fn len(&self) -> usize {
229 self.rows.len()
230 }
231
232 pub fn is_empty(&self) -> bool {
234 self.rows.is_empty()
235 }
236}
237
238#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::backtesting::{
244 BacktestConfig,
245 optimizer::OptimizeMetric,
246 result::{BacktestResult, EquityPoint, PerformanceMetrics},
247 };
248
249 fn metrics_with(
251 total_return_pct: f64,
252 sharpe_ratio: f64,
253 max_drawdown_pct: f64,
254 ) -> PerformanceMetrics {
255 PerformanceMetrics {
256 total_return_pct,
257 annualized_return_pct: total_return_pct,
258 sharpe_ratio,
259 sortino_ratio: 0.0,
260 calmar_ratio: 0.0,
261 max_drawdown_pct,
262 max_drawdown_duration: 0,
263 win_rate: 0.0,
264 profit_factor: 1.0,
265 avg_trade_return_pct: 0.0,
266 avg_win_pct: 0.0,
267 avg_loss_pct: 0.0,
268 avg_trade_duration: 0.0,
269 total_trades: 1,
270 winning_trades: 1,
271 losing_trades: 0,
272 largest_win: 0.0,
273 largest_loss: 0.0,
274 max_consecutive_wins: 0,
275 max_consecutive_losses: 0,
276 total_commission: 0.0,
277 total_financing_cost: 0.0,
278 long_trades: 1,
279 short_trades: 0,
280 total_signals: 1,
281 executed_signals: 1,
282 avg_win_duration: 0.0,
283 avg_loss_duration: 0.0,
284 time_in_market_pct: 0.5,
285 max_idle_period: 0,
286 total_dividend_income: 0.0,
287 kelly_criterion: 0.0,
288 sqn: 0.0,
289 expectancy: 0.0,
290 omega_ratio: 1.0,
291 tail_ratio: 1.0,
292 recovery_factor: 1.0,
293 ulcer_index: 0.0,
294 serenity_ratio: 0.0,
295 }
296 }
297
298 fn make_result(strategy_name: &str, total_return: f64, sharpe: f64, dd: f64) -> BacktestResult {
299 BacktestResult {
300 symbol: "TEST".to_owned(),
301 strategy_name: strategy_name.to_owned(),
302 config: BacktestConfig::default(),
303 start_timestamp: 0,
304 end_timestamp: 1_000_000,
305 initial_capital: 10_000.0,
306 final_equity: 10_000.0 * (1.0 + total_return / 100.0),
307 metrics: metrics_with(total_return, sharpe, dd),
308 trades: vec![],
309 equity_curve: vec![EquityPoint {
310 timestamp: 0,
311 equity: 10_000.0,
312 drawdown_pct: 0.0,
313 }],
314 signals: vec![],
315 open_position: None,
316 benchmark: None,
317 diagnostics: vec![],
318 max_leverage_used: 0.0,
319 }
320 }
321
322 #[test]
323 fn empty_comparison() {
324 let report = BacktestComparison::new().ranked_by(OptimizeMetric::SharpeRatio);
325 assert!(report.is_empty());
326 assert_eq!(report.winner(), "");
327 assert!(report.winner_row().is_none());
328 assert_eq!(report.table().len(), 0);
329 }
330
331 #[test]
332 fn single_entry_is_winner() {
333 let result = make_result("SMA", 10.0, 1.5, 0.05);
334 let report = BacktestComparison::new()
335 .add("SMA 10/50", result)
336 .ranked_by(OptimizeMetric::SharpeRatio);
337
338 assert_eq!(report.winner(), "SMA 10/50");
339 assert_eq!(report.len(), 1);
340 assert_eq!(report.table()[0].rank, 1);
341 }
342
343 #[test]
344 fn ranked_by_sharpe() {
345 let r1 = make_result("SMA", 10.0, 0.8, 0.10);
346 let r2 = make_result("MACD", 15.0, 1.5, 0.12);
347 let r3 = make_result("RSI", 5.0, 1.2, 0.08);
348
349 let report = BacktestComparison::new()
350 .add("SMA 10/50", r1)
351 .add("MACD Signal", r2)
352 .add("RSI Mean Rev", r3)
353 .ranked_by(OptimizeMetric::SharpeRatio);
354
355 assert_eq!(report.winner(), "MACD Signal");
356 let table = report.table();
357 assert_eq!(table[0].label, "MACD Signal");
358 assert_eq!(table[1].label, "RSI Mean Rev");
359 assert_eq!(table[2].label, "SMA 10/50");
360 assert_eq!(table[0].rank, 1);
361 assert_eq!(table[1].rank, 2);
362 assert_eq!(table[2].rank, 3);
363 }
364
365 #[test]
366 fn ranked_by_total_return() {
367 let r1 = make_result("SMA", 10.0, 0.8, 0.10);
368 let r2 = make_result("MACD", 25.0, 0.6, 0.20);
369
370 let report = BacktestComparison::new()
371 .add("SMA", r1)
372 .add("MACD", r2)
373 .ranked_by(OptimizeMetric::TotalReturn);
374
375 assert_eq!(report.winner(), "MACD");
376 }
377
378 #[test]
379 fn ranked_by_min_drawdown() {
380 let r1 = make_result("SMA", 10.0, 0.8, 0.20);
382 let r2 = make_result("MACD", 10.0, 0.8, 0.05);
383
384 let report = BacktestComparison::new()
385 .add("High DD", r1)
386 .add("Low DD", r2)
387 .ranked_by(OptimizeMetric::MinDrawdown);
388
389 assert_eq!(report.winner(), "Low DD");
390 }
391
392 #[test]
393 fn tie_broken_by_total_return() {
394 let r1 = make_result("A", 20.0, 1.0, 0.10);
396 let r2 = make_result("B", 5.0, 1.0, 0.10);
397
398 let report = BacktestComparison::new()
399 .add("A", r1)
400 .add("B", r2)
401 .ranked_by(OptimizeMetric::SharpeRatio);
402
403 assert_eq!(report.winner(), "A");
404 }
405
406 #[test]
407 fn table_returns_all_rows() {
408 let n = 5;
409 let mut comparison = BacktestComparison::new();
410 for i in 0..n {
411 comparison = comparison.add(
412 format!("Strategy {i}"),
413 make_result(&format!("S{i}"), i as f64 * 2.0, i as f64 * 0.5, 0.1),
414 );
415 }
416 let report = comparison.ranked_by(OptimizeMetric::SharpeRatio);
417 assert_eq!(report.table().len(), n);
418 assert_eq!(report.len(), n);
419 }
420
421 #[test]
422 fn row_fields_populated_correctly() {
423 let result = make_result("SMA", 12.0, 1.3, 0.07);
424 let report = BacktestComparison::new()
425 .add("My Strategy", result)
426 .ranked_by(OptimizeMetric::SharpeRatio);
427
428 let row = &report.table()[0];
429 assert_eq!(row.label, "My Strategy");
430 assert_eq!(row.strategy_name, "SMA");
431 assert_eq!(row.symbol, "TEST");
432 assert!((row.total_return_pct - 12.0).abs() < 1e-10);
433 assert!((row.sharpe_ratio - 1.3).abs() < 1e-10);
434 assert!((row.max_drawdown_pct - 0.07).abs() < 1e-10);
435 assert_eq!(row.rank, 1);
436 }
437}