Skip to main content

finance_query/backtesting/
result.rs

1//! Backtest results and performance metrics.
2
3use serde::{Deserialize, Serialize};
4
5use super::config::BacktestConfig;
6use super::position::{Position, Trade};
7use super::signal::SignalDirection;
8
9/// Point on the equity curve
10#[non_exhaustive]
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct EquityPoint {
13    /// Timestamp
14    pub timestamp: i64,
15    /// Portfolio equity at this point
16    pub equity: f64,
17    /// Current drawdown from peak (as percentage, 0.0-1.0)
18    pub drawdown_pct: f64,
19}
20
21/// Record of a generated signal (for analysis)
22#[non_exhaustive]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SignalRecord {
25    /// Timestamp when signal was generated
26    pub timestamp: i64,
27    /// Price at signal time
28    pub price: f64,
29    /// Signal direction
30    pub direction: SignalDirection,
31    /// Signal strength (0.0-1.0)
32    pub strength: f64,
33    /// Signal reason/description
34    pub reason: Option<String>,
35    /// Whether the signal was executed
36    pub executed: bool,
37}
38
39/// Performance metrics summary
40#[non_exhaustive]
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PerformanceMetrics {
43    /// Total return percentage
44    pub total_return_pct: f64,
45
46    /// Annualized return percentage (assumes 252 trading days)
47    pub annualized_return_pct: f64,
48
49    /// Sharpe ratio (risk-free rate = 0)
50    pub sharpe_ratio: f64,
51
52    /// Sortino ratio (downside deviation)
53    pub sortino_ratio: f64,
54
55    /// Maximum drawdown percentage (0.0-1.0)
56    pub max_drawdown_pct: f64,
57
58    /// Maximum drawdown duration in bars
59    pub max_drawdown_duration: i64,
60
61    /// Win rate (profitable trades / total trades)
62    pub win_rate: f64,
63
64    /// Profit factor (gross profit / gross loss)
65    pub profit_factor: f64,
66
67    /// Average trade return percentage
68    pub avg_trade_return_pct: f64,
69
70    /// Average winning trade return percentage
71    pub avg_win_pct: f64,
72
73    /// Average losing trade return percentage
74    pub avg_loss_pct: f64,
75
76    /// Average trade duration in bars
77    pub avg_trade_duration: f64,
78
79    /// Total number of trades
80    pub total_trades: usize,
81
82    /// Number of winning trades
83    pub winning_trades: usize,
84
85    /// Number of losing trades
86    pub losing_trades: usize,
87
88    /// Largest winning trade P&L
89    pub largest_win: f64,
90
91    /// Largest losing trade P&L
92    pub largest_loss: f64,
93
94    /// Maximum consecutive wins
95    pub max_consecutive_wins: usize,
96
97    /// Maximum consecutive losses
98    pub max_consecutive_losses: usize,
99
100    /// Calmar ratio (annualized return / max drawdown)
101    pub calmar_ratio: f64,
102
103    /// Total commission paid
104    pub total_commission: f64,
105
106    /// Number of long trades
107    pub long_trades: usize,
108
109    /// Number of short trades
110    pub short_trades: usize,
111
112    /// Total signals generated
113    pub total_signals: usize,
114
115    /// Signals that were executed
116    pub executed_signals: usize,
117}
118
119impl PerformanceMetrics {
120    /// Calculate performance metrics from trades and equity curve
121    pub fn calculate(
122        trades: &[Trade],
123        equity_curve: &[EquityPoint],
124        initial_capital: f64,
125        total_signals: usize,
126        executed_signals: usize,
127    ) -> Self {
128        let total_trades = trades.len();
129
130        if total_trades == 0 {
131            let final_equity = equity_curve
132                .last()
133                .map(|e| e.equity)
134                .unwrap_or(initial_capital);
135            let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
136
137            return Self {
138                total_return_pct,
139                annualized_return_pct: 0.0,
140                sharpe_ratio: 0.0,
141                sortino_ratio: 0.0,
142                max_drawdown_pct: 0.0,
143                max_drawdown_duration: 0,
144                win_rate: 0.0,
145                profit_factor: 0.0,
146                avg_trade_return_pct: 0.0,
147                avg_win_pct: 0.0,
148                avg_loss_pct: 0.0,
149                avg_trade_duration: 0.0,
150                total_trades: 0,
151                winning_trades: 0,
152                losing_trades: 0,
153                largest_win: 0.0,
154                largest_loss: 0.0,
155                max_consecutive_wins: 0,
156                max_consecutive_losses: 0,
157                calmar_ratio: 0.0,
158                total_commission: 0.0,
159                long_trades: 0,
160                short_trades: 0,
161                total_signals,
162                executed_signals,
163            };
164        }
165
166        // Basic trade stats
167        let winning_trades = trades.iter().filter(|t| t.is_profitable()).count();
168        let losing_trades = trades.iter().filter(|t| t.is_loss()).count();
169        let long_trades = trades.iter().filter(|t| t.is_long()).count();
170        let short_trades = trades.iter().filter(|t| t.is_short()).count();
171
172        let win_rate = winning_trades as f64 / total_trades as f64;
173
174        // P&L calculations
175        let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
176        let gross_loss: f64 = trades
177            .iter()
178            .filter(|t| t.pnl < 0.0)
179            .map(|t| t.pnl.abs())
180            .sum();
181
182        let profit_factor = if gross_loss > 0.0 {
183            gross_profit / gross_loss
184        } else if gross_profit > 0.0 {
185            f64::INFINITY
186        } else {
187            0.0
188        };
189
190        // Average returns
191        let avg_trade_return_pct =
192            trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64;
193
194        let winning_returns: Vec<f64> = trades
195            .iter()
196            .filter(|t| t.is_profitable())
197            .map(|t| t.return_pct)
198            .collect();
199        let losing_returns: Vec<f64> = trades
200            .iter()
201            .filter(|t| t.is_loss())
202            .map(|t| t.return_pct)
203            .collect();
204
205        let avg_win_pct = if !winning_returns.is_empty() {
206            winning_returns.iter().sum::<f64>() / winning_returns.len() as f64
207        } else {
208            0.0
209        };
210
211        let avg_loss_pct = if !losing_returns.is_empty() {
212            losing_returns.iter().sum::<f64>() / losing_returns.len() as f64
213        } else {
214            0.0
215        };
216
217        // Trade durations
218        let total_duration: i64 = trades.iter().map(|t| t.duration_secs()).sum();
219        let avg_trade_duration = total_duration as f64 / total_trades as f64;
220
221        // Largest trades
222        let largest_win = trades.iter().map(|t| t.pnl).fold(0.0, f64::max);
223        let largest_loss = trades.iter().map(|t| t.pnl).fold(0.0, f64::min);
224
225        // Consecutive wins/losses
226        let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
227
228        // Total commission
229        let total_commission: f64 = trades.iter().map(|t| t.commission).sum();
230
231        // Drawdown metrics
232        let max_drawdown_pct = equity_curve
233            .iter()
234            .map(|e| e.drawdown_pct)
235            .fold(0.0, f64::max);
236
237        let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
238
239        // Total return
240        let final_equity = equity_curve
241            .last()
242            .map(|e| e.equity)
243            .unwrap_or(initial_capital);
244        let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
245
246        // Annualized return (assuming daily bars and 252 trading days)
247        let num_bars = equity_curve.len();
248        let years = num_bars as f64 / 252.0;
249        let annualized_return_pct = if years > 0.0 {
250            ((final_equity / initial_capital).powf(1.0 / years) - 1.0) * 100.0
251        } else {
252            0.0
253        };
254
255        // Sharpe and Sortino ratios
256        let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
257        let sharpe_ratio = calculate_sharpe_ratio(&returns);
258        let sortino_ratio = calculate_sortino_ratio(&returns);
259
260        // Calmar ratio
261        let calmar_ratio = if max_drawdown_pct > 0.0 {
262            annualized_return_pct / (max_drawdown_pct * 100.0)
263        } else if annualized_return_pct > 0.0 {
264            f64::INFINITY
265        } else {
266            0.0
267        };
268
269        Self {
270            total_return_pct,
271            annualized_return_pct,
272            sharpe_ratio,
273            sortino_ratio,
274            max_drawdown_pct,
275            max_drawdown_duration,
276            win_rate,
277            profit_factor,
278            avg_trade_return_pct,
279            avg_win_pct,
280            avg_loss_pct,
281            avg_trade_duration,
282            total_trades,
283            winning_trades,
284            losing_trades,
285            largest_win,
286            largest_loss,
287            max_consecutive_wins,
288            max_consecutive_losses,
289            calmar_ratio,
290            total_commission,
291            long_trades,
292            short_trades,
293            total_signals,
294            executed_signals,
295        }
296    }
297}
298
299/// Calculate maximum consecutive wins and losses
300fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
301    let mut max_wins = 0;
302    let mut max_losses = 0;
303    let mut current_wins = 0;
304    let mut current_losses = 0;
305
306    for trade in trades {
307        if trade.is_profitable() {
308            current_wins += 1;
309            current_losses = 0;
310            max_wins = max_wins.max(current_wins);
311        } else if trade.is_loss() {
312            current_losses += 1;
313            current_wins = 0;
314            max_losses = max_losses.max(current_losses);
315        } else {
316            // Break-even trade
317            current_wins = 0;
318            current_losses = 0;
319        }
320    }
321
322    (max_wins, max_losses)
323}
324
325/// Calculate maximum drawdown duration in bars
326fn calculate_max_drawdown_duration(equity_curve: &[EquityPoint]) -> i64 {
327    if equity_curve.is_empty() {
328        return 0;
329    }
330
331    let mut max_duration = 0;
332    let mut current_duration = 0;
333    let mut peak = equity_curve[0].equity;
334
335    for point in equity_curve {
336        if point.equity >= peak {
337            peak = point.equity;
338            max_duration = max_duration.max(current_duration);
339            current_duration = 0;
340        } else {
341            current_duration += 1;
342        }
343    }
344
345    max_duration.max(current_duration)
346}
347
348/// Calculate periodic returns from equity curve
349fn calculate_periodic_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
350    if equity_curve.len() < 2 {
351        return vec![];
352    }
353
354    equity_curve
355        .windows(2)
356        .map(|w| {
357            let prev = w[0].equity;
358            let curr = w[1].equity;
359            if prev > 0.0 {
360                (curr - prev) / prev
361            } else {
362                0.0
363            }
364        })
365        .collect()
366}
367
368/// Calculate Sharpe ratio (assuming risk-free rate = 0)
369fn calculate_sharpe_ratio(returns: &[f64]) -> f64 {
370    if returns.is_empty() {
371        return 0.0;
372    }
373
374    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
375    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
376    let std_dev = variance.sqrt();
377
378    if std_dev > 0.0 {
379        // Annualize: assume daily returns, 252 trading days
380        (mean / std_dev) * (252.0_f64).sqrt()
381    } else if mean > 0.0 {
382        f64::INFINITY
383    } else {
384        0.0
385    }
386}
387
388/// Calculate Sortino ratio (downside deviation only)
389fn calculate_sortino_ratio(returns: &[f64]) -> f64 {
390    if returns.is_empty() {
391        return 0.0;
392    }
393
394    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
395
396    // Only consider negative returns for downside deviation
397    let downside_returns: Vec<f64> = returns.iter().filter(|&&r| r < 0.0).copied().collect();
398
399    if downside_returns.is_empty() {
400        return if mean > 0.0 { f64::INFINITY } else { 0.0 };
401    }
402
403    let downside_variance =
404        downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / returns.len() as f64;
405    let downside_dev = downside_variance.sqrt();
406
407    if downside_dev > 0.0 {
408        (mean / downside_dev) * (252.0_f64).sqrt()
409    } else if mean > 0.0 {
410        f64::INFINITY
411    } else {
412        0.0
413    }
414}
415
416/// Complete backtest result
417#[non_exhaustive]
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct BacktestResult {
420    /// Symbol that was backtested
421    pub symbol: String,
422
423    /// Strategy name
424    pub strategy_name: String,
425
426    /// Configuration used
427    pub config: BacktestConfig,
428
429    /// Start timestamp
430    pub start_timestamp: i64,
431
432    /// End timestamp
433    pub end_timestamp: i64,
434
435    /// Initial capital
436    pub initial_capital: f64,
437
438    /// Final equity
439    pub final_equity: f64,
440
441    /// Performance metrics
442    pub metrics: PerformanceMetrics,
443
444    /// Complete trade log
445    pub trades: Vec<Trade>,
446
447    /// Equity curve (portfolio value at each bar)
448    pub equity_curve: Vec<EquityPoint>,
449
450    /// All signals generated (including non-executed)
451    pub signals: Vec<SignalRecord>,
452
453    /// Current open position (if any at end)
454    pub open_position: Option<Position>,
455}
456
457impl BacktestResult {
458    /// Get a formatted summary string
459    pub fn summary(&self) -> String {
460        format!(
461            "Backtest: {} on {}\n\
462             Period: {} bars\n\
463             Initial: ${:.2} -> Final: ${:.2}\n\
464             Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
465             Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
466            self.strategy_name,
467            self.symbol,
468            self.equity_curve.len(),
469            self.initial_capital,
470            self.final_equity,
471            self.metrics.total_return_pct,
472            self.metrics.sharpe_ratio,
473            self.metrics.max_drawdown_pct * 100.0,
474            self.metrics.total_trades,
475            self.metrics.win_rate * 100.0,
476            self.metrics.profit_factor,
477        )
478    }
479
480    /// Check if the backtest was profitable
481    pub fn is_profitable(&self) -> bool {
482        self.final_equity > self.initial_capital
483    }
484
485    /// Get total P&L
486    pub fn total_pnl(&self) -> f64 {
487        self.final_equity - self.initial_capital
488    }
489
490    /// Get the number of bars in the backtest
491    pub fn num_bars(&self) -> usize {
492        self.equity_curve.len()
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::backtesting::position::PositionSide;
500    use crate::backtesting::signal::Signal;
501
502    fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
503        Trade {
504            side: if is_long {
505                PositionSide::Long
506            } else {
507                PositionSide::Short
508            },
509            entry_timestamp: 0,
510            exit_timestamp: 100,
511            entry_price: 100.0,
512            exit_price: 100.0 + pnl / 10.0,
513            quantity: 10.0,
514            commission: 0.0,
515            pnl,
516            return_pct,
517            entry_signal: Signal::long(0, 100.0),
518            exit_signal: Signal::exit(100, 110.0),
519        }
520    }
521
522    #[test]
523    fn test_metrics_no_trades() {
524        let equity = vec![
525            EquityPoint {
526                timestamp: 0,
527                equity: 10000.0,
528                drawdown_pct: 0.0,
529            },
530            EquityPoint {
531                timestamp: 1,
532                equity: 10100.0,
533                drawdown_pct: 0.0,
534            },
535        ];
536
537        let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0);
538
539        assert_eq!(metrics.total_trades, 0);
540        assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
541    }
542
543    #[test]
544    fn test_metrics_with_trades() {
545        let trades = vec![
546            make_trade(100.0, 10.0, true), // Win
547            make_trade(-50.0, -5.0, true), // Loss
548            make_trade(75.0, 7.5, false),  // Win (short)
549            make_trade(25.0, 2.5, true),   // Win
550        ];
551
552        let equity = vec![
553            EquityPoint {
554                timestamp: 0,
555                equity: 10000.0,
556                drawdown_pct: 0.0,
557            },
558            EquityPoint {
559                timestamp: 1,
560                equity: 10100.0,
561                drawdown_pct: 0.0,
562            },
563            EquityPoint {
564                timestamp: 2,
565                equity: 10050.0,
566                drawdown_pct: 0.005,
567            },
568            EquityPoint {
569                timestamp: 3,
570                equity: 10125.0,
571                drawdown_pct: 0.0,
572            },
573            EquityPoint {
574                timestamp: 4,
575                equity: 10150.0,
576                drawdown_pct: 0.0,
577            },
578        ];
579
580        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4);
581
582        assert_eq!(metrics.total_trades, 4);
583        assert_eq!(metrics.winning_trades, 3);
584        assert_eq!(metrics.losing_trades, 1);
585        assert!((metrics.win_rate - 0.75).abs() < 0.01);
586        assert_eq!(metrics.long_trades, 3);
587        assert_eq!(metrics.short_trades, 1);
588    }
589
590    #[test]
591    fn test_consecutive_wins_losses() {
592        let trades = vec![
593            make_trade(100.0, 10.0, true), // Win
594            make_trade(50.0, 5.0, true),   // Win
595            make_trade(25.0, 2.5, true),   // Win
596            make_trade(-50.0, -5.0, true), // Loss
597            make_trade(-25.0, -2.5, true), // Loss
598            make_trade(100.0, 10.0, true), // Win
599        ];
600
601        let (max_wins, max_losses) = calculate_consecutive(&trades);
602        assert_eq!(max_wins, 3);
603        assert_eq!(max_losses, 2);
604    }
605
606    #[test]
607    fn test_drawdown_duration() {
608        let equity = vec![
609            EquityPoint {
610                timestamp: 0,
611                equity: 100.0,
612                drawdown_pct: 0.0,
613            },
614            EquityPoint {
615                timestamp: 1,
616                equity: 95.0,
617                drawdown_pct: 0.05,
618            },
619            EquityPoint {
620                timestamp: 2,
621                equity: 90.0,
622                drawdown_pct: 0.10,
623            },
624            EquityPoint {
625                timestamp: 3,
626                equity: 92.0,
627                drawdown_pct: 0.08,
628            },
629            EquityPoint {
630                timestamp: 4,
631                equity: 100.0,
632                drawdown_pct: 0.0,
633            }, // Recovery
634            EquityPoint {
635                timestamp: 5,
636                equity: 98.0,
637                drawdown_pct: 0.02,
638            },
639        ];
640
641        let duration = calculate_max_drawdown_duration(&equity);
642        assert_eq!(duration, 3); // 3 bars in drawdown (indices 1, 2, 3) before recovery at index 4
643    }
644}