Skip to main content

finance_query/backtesting/result/
metrics.rs

1use serde::{Deserialize, Serialize};
2
3use super::EquityPoint;
4use super::stats::{
5    analyze_trades, calculate_consecutive, calculate_kelly, calculate_max_drawdown_duration,
6    calculate_max_idle_period, calculate_omega_ratio, calculate_periodic_returns,
7    calculate_risk_ratios, calculate_sqn, calculate_tail_ratio, calculate_time_in_market,
8    calculate_ulcer_index, calculate_win_loss_durations,
9};
10use crate::backtesting::position::Trade;
11
12/// Performance metrics summary
13#[non_exhaustive]
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PerformanceMetrics {
16    /// Total return percentage
17    pub total_return_pct: f64,
18
19    /// Annualized return percentage (assumes 252 trading days)
20    pub annualized_return_pct: f64,
21
22    /// Sharpe ratio (risk-free rate = 0)
23    pub sharpe_ratio: f64,
24
25    /// Sortino ratio (downside deviation)
26    pub sortino_ratio: f64,
27
28    /// Maximum drawdown as a fraction (0.0–1.0, **not** a percentage).
29    ///
30    /// A value of `0.2` means the equity fell 20% from its peak at most.
31    /// Multiply by 100 to get a conventional percentage. See also
32    /// [`max_drawdown_percentage`](Self::max_drawdown_percentage) for a
33    /// pre-scaled convenience accessor.
34    pub max_drawdown_pct: f64,
35
36    /// Maximum drawdown duration measured in **bars** (not calendar time).
37    ///
38    /// Counts the number of consecutive bars from a peak until full recovery.
39    pub max_drawdown_duration: i64,
40
41    /// Win rate: `winning_trades / total_trades`.
42    ///
43    /// The denominator is `total_trades`, which includes break-even trades
44    /// (`pnl == 0.0`).  Break-even trades are neither wins nor losses, so they
45    /// reduce the win rate without appearing in `winning_trades` or
46    /// `losing_trades`.
47    pub win_rate: f64,
48
49    /// Profit factor: `gross_profit / gross_loss`.
50    ///
51    /// Returns `f64::MAX` when there are no losing trades (zero denominator)
52    /// and at least one profitable trade.  This avoids `f64::INFINITY`, which
53    /// is not representable in JSON.
54    pub profit_factor: f64,
55
56    /// Average trade return percentage
57    pub avg_trade_return_pct: f64,
58
59    /// Average winning trade return percentage
60    pub avg_win_pct: f64,
61
62    /// Average losing trade return percentage
63    pub avg_loss_pct: f64,
64
65    /// Average trade duration in seconds
66    pub avg_trade_duration: f64,
67
68    /// Total number of trades
69    pub total_trades: usize,
70
71    /// Number of winning trades (`pnl > 0.0`).
72    ///
73    /// Break-even trades (`pnl == 0.0`) are counted in neither `winning_trades`
74    /// nor `losing_trades`, so `winning_trades + losing_trades <= total_trades`.
75    pub winning_trades: usize,
76
77    /// Number of losing trades (`pnl < 0.0`).
78    ///
79    /// Break-even trades (`pnl == 0.0`) are counted in neither `winning_trades`
80    /// nor `losing_trades`. See [`winning_trades`](Self::winning_trades).
81    pub losing_trades: usize,
82
83    /// Largest winning trade P&L
84    pub largest_win: f64,
85
86    /// Largest losing trade P&L
87    pub largest_loss: f64,
88
89    /// Maximum consecutive wins
90    pub max_consecutive_wins: usize,
91
92    /// Maximum consecutive losses
93    pub max_consecutive_losses: usize,
94
95    /// Calmar ratio: `annualized_return_pct / max_drawdown_pct_scaled`.
96    ///
97    /// Returns `f64::MAX` when max drawdown is zero and the strategy is
98    /// profitable (avoids `f64::INFINITY` which cannot be serialized to JSON).
99    pub calmar_ratio: f64,
100
101    /// Total commission paid
102    pub total_commission: f64,
103
104    /// Total cost of borrowed capital over the run: short borrow fees and
105    /// margin interest. Already subtracted from each trade's P&L, and includes
106    /// what a still-open position has accrued so far.
107    #[serde(default)]
108    pub total_financing_cost: f64,
109
110    /// Number of long trades
111    pub long_trades: usize,
112
113    /// Number of short trades
114    pub short_trades: usize,
115
116    /// Total signals generated
117    pub total_signals: usize,
118
119    /// Signals that were executed
120    pub executed_signals: usize,
121
122    /// Average duration of winning trades in seconds
123    pub avg_win_duration: f64,
124
125    /// Average duration of losing trades in seconds
126    pub avg_loss_duration: f64,
127
128    /// Fraction of backtest time spent with an open position (0.0 - 1.0)
129    pub time_in_market_pct: f64,
130
131    /// Longest idle period between trades in seconds (0 if fewer than 2 trades)
132    pub max_idle_period: i64,
133
134    /// Total dividend income received across all trades
135    pub total_dividend_income: f64,
136
137    /// Kelly Criterion: optimal fraction of capital to risk per trade.
138    ///
139    /// Computed as `W - (1 - W) / R` where `R` is `avg_win_pct /
140    /// abs(avg_loss_pct)` and `W` is the win rate over decisive
141    /// (non-break-even) trades, unlike [`win_rate`](Self::win_rate) which is
142    /// diluted by break-even trades. A positive value suggests the strategy
143    /// has an edge; a negative value suggests it does not. Values above 1
144    /// indicate extreme edge (rare in practice). Returns `0.0` when there are
145    /// no losing trades to compute a ratio.
146    pub kelly_criterion: f64,
147
148    /// Van Tharp's System Quality Number.
149    ///
150    /// `SQN = (mean_R / std_R) * sqrt(n_trades)` where `R` is the
151    /// distribution of per-trade return percentages. Interpretation:
152    /// `>1.6` = below average, `>2.0` = average, `>2.5` = good,
153    /// `>3.0` = excellent, `>5.0` = superb, `>7.0` = holy grail.
154    /// Returns `0.0` when fewer than 2 trades are available.
155    ///
156    /// **Note:** Van Tharp's original definition uses *R-multiples*
157    /// (profit/loss normalised by initial risk per trade, i.e. entry-to-stop
158    /// distance). Since the engine does not track per-trade initial risk,
159    /// this implementation uses `return_pct` as a proxy. Values will
160    /// therefore not match Van Tharp's published benchmarks exactly.
161    /// At least 30 trades are recommended for statistical reliability.
162    pub sqn: f64,
163
164    /// Expectancy: expected profit per trade in dollar terms.
165    ///
166    /// `P(win) × avg_win_dollar + P(loss) × avg_loss_dollar` where each
167    /// probability is computed independently (`winning_trades / total` and
168    /// `losing_trades / total`). Unlike `avg_trade_return_pct` (which is a
169    /// percentage), this gives the expected monetary gain or loss per trade
170    /// in the same currency as `initial_capital`. A positive value means the
171    /// strategy has a statistical edge; e.g. `+$25` means you expect to make
172    /// $25 on average per trade taken.
173    pub expectancy: f64,
174
175    /// Omega Ratio: probability-weighted ratio of gains to losses.
176    ///
177    /// `Σ max(r, 0) / Σ max(-r, 0)` computed over **bar-by-bar periodic
178    /// returns** from the equity curve (consistent with Sharpe/Sortino),
179    /// using a threshold of `0.0`. More general than Sharpe — considers the
180    /// full return distribution rather than only mean and standard deviation.
181    /// Returns `f64::MAX` when there are no negative-return bars.
182    pub omega_ratio: f64,
183
184    /// Tail Ratio: ratio of right tail to left tail of trade returns.
185    ///
186    /// `abs(p95) / abs(p5)` of the trade return distribution using the
187    /// floor nearest-rank method (`floor(p × n)` as the 0-based index).
188    /// A value `>1` means large wins are more extreme than large losses
189    /// (favourable asymmetry). Returns `f64::MAX` when the 5th-percentile
190    /// return is zero. Returns `0.0` when fewer than 2 trades exist.
191    ///
192    /// **Note:** Reliable interpretation requires at least ~20 trades;
193    /// with fewer trades the percentile estimates are dominated by
194    /// individual outliers.
195    pub tail_ratio: f64,
196
197    /// Recovery Factor: net profit relative to maximum drawdown.
198    ///
199    /// `total_return_pct / (max_drawdown_pct * 100)`. Measures how
200    /// efficiently the strategy recovers from its worst drawdown. Returns
201    /// `f64::MAX` when there is no drawdown, `0.0` when unprofitable.
202    pub recovery_factor: f64,
203
204    /// Ulcer Index: root-mean-square of drawdown depth across all bars,
205    /// expressed as a **percentage** (0–100), consistent with backtesting.py
206    /// and Peter Martin's original 1987 definition.
207    ///
208    /// `sqrt(mean((drawdown_pct × 100)²))` computed from the equity curve.
209    /// Unlike max drawdown, it penalises both depth and duration — a long
210    /// shallow drawdown scores higher than a brief deep one. A lower value
211    /// indicates a smoother equity curve.
212    pub ulcer_index: f64,
213
214    /// Serenity Ratio (Martin Ratio / Ulcer Performance Index): excess
215    /// annualised return per unit of Ulcer Index risk.
216    ///
217    /// `(annualized_return_pct - risk_free_rate_pct) / ulcer_index` where
218    /// both numerator and denominator are in percentage units. Analogous to
219    /// the Sharpe Ratio but uses the Ulcer Index as the risk measure,
220    /// penalising prolonged drawdowns more heavily than short-term volatility.
221    /// Returns `f64::MAX` when Ulcer Index is zero and excess return is positive.
222    pub serenity_ratio: f64,
223}
224
225impl PerformanceMetrics {
226    /// Maximum drawdown as a conventional percentage (0–100).
227    ///
228    /// Equivalent to `self.max_drawdown_pct * 100.0`. Provided because
229    /// `max_drawdown_pct` is stored as a fraction (0.0–1.0) while most other
230    /// return fields use true percentages.
231    pub fn max_drawdown_percentage(&self) -> f64 {
232        self.max_drawdown_pct * 100.0
233    }
234
235    /// Construct a zero-trades result: all metrics are zero except `total_return_pct`
236    /// which is derived from the equity curve.
237    pub(super) fn empty(
238        initial_capital: f64,
239        equity_curve: &[EquityPoint],
240        total_signals: usize,
241        executed_signals: usize,
242    ) -> Self {
243        let final_equity = equity_curve
244            .last()
245            .map(|e| e.equity)
246            .unwrap_or(initial_capital);
247        let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
248        Self {
249            total_return_pct,
250            annualized_return_pct: 0.0,
251            sharpe_ratio: 0.0,
252            sortino_ratio: 0.0,
253            max_drawdown_pct: 0.0,
254            max_drawdown_duration: 0,
255            win_rate: 0.0,
256            profit_factor: 0.0,
257            avg_trade_return_pct: 0.0,
258            avg_win_pct: 0.0,
259            avg_loss_pct: 0.0,
260            avg_trade_duration: 0.0,
261            total_trades: 0,
262            winning_trades: 0,
263            losing_trades: 0,
264            largest_win: 0.0,
265            largest_loss: 0.0,
266            max_consecutive_wins: 0,
267            max_consecutive_losses: 0,
268            calmar_ratio: 0.0,
269            total_commission: 0.0,
270            total_financing_cost: 0.0,
271            long_trades: 0,
272            short_trades: 0,
273            total_signals,
274            executed_signals,
275            avg_win_duration: 0.0,
276            avg_loss_duration: 0.0,
277            time_in_market_pct: 0.0,
278            max_idle_period: 0,
279            total_dividend_income: 0.0,
280            kelly_criterion: 0.0,
281            sqn: 0.0,
282            expectancy: 0.0,
283            omega_ratio: 0.0,
284            tail_ratio: 0.0,
285            recovery_factor: 0.0,
286            ulcer_index: 0.0,
287            serenity_ratio: 0.0,
288        }
289    }
290
291    /// Calculate performance metrics from trades and equity curve.
292    ///
293    /// `risk_free_rate` is the **annual** rate (e.g. `0.05` for 5%). It is
294    /// converted to a per-bar rate internally before computing Sharpe/Sortino.
295    ///
296    /// `bars_per_year` controls annualisation (e.g. `252.0` for daily US equity
297    /// bars, `52.0` for weekly, `1638.0` for hourly). Affects annualised return,
298    /// Sharpe, Sortino, and Calmar calculations.
299    pub fn calculate(
300        trades: &[Trade],
301        equity_curve: &[EquityPoint],
302        initial_capital: f64,
303        total_signals: usize,
304        executed_signals: usize,
305        risk_free_rate: f64,
306        bars_per_year: f64,
307    ) -> Self {
308        // Drawdown metrics
309        let max_drawdown_pct = equity_curve
310            .iter()
311            .map(|e| e.drawdown_pct)
312            .fold(0.0, f64::max);
313
314        // Total return
315        let final_equity = equity_curve
316            .last()
317            .map(|e| e.equity)
318            .unwrap_or(initial_capital);
319
320        // Zero drawdown with both endpoints at initial capital means a flat
321        // curve: any excursion draws down or moves an endpoint. Flat with no
322        // trades zeroes every remaining metric, so skip the risk block.
323        if trades.is_empty()
324            && risk_free_rate >= 0.0
325            && max_drawdown_pct == 0.0
326            && final_equity == initial_capital
327            && equity_curve
328                .first()
329                .is_none_or(|f| f.equity == initial_capital)
330        {
331            return Self::empty(
332                initial_capital,
333                equity_curve,
334                total_signals,
335                executed_signals,
336            );
337        }
338
339        let total_trades = trades.len();
340        let stats = analyze_trades(trades);
341
342        let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
343
344        let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
345
346        // Annualized return using configured bars_per_year.
347        // Use return periods (N-1), not points (N), to avoid overestimating
348        // elapsed time for short series.
349        let num_periods = equity_curve.len().saturating_sub(1);
350        let years = num_periods as f64 / bars_per_year;
351        let growth = final_equity / initial_capital;
352        let annualized_return_pct = if years > 0.0 {
353            if growth <= 0.0 {
354                -100.0
355            } else {
356                (growth.powf(1.0 / years) - 1.0) * 100.0
357            }
358        } else {
359            0.0
360        };
361
362        // Sharpe and Sortino ratios (computed in one pass over shared excess returns)
363        let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
364        let (sharpe_ratio, sortino_ratio) =
365            calculate_risk_ratios(&returns, risk_free_rate, bars_per_year);
366
367        // Calmar ratio = annualised return (%) / max drawdown (%).
368        // Use f64::MAX instead of INFINITY when drawdown is zero to keep the
369        // value JSON-serializable.
370        let calmar_ratio = if max_drawdown_pct > 0.0 {
371            annualized_return_pct / (max_drawdown_pct * 100.0)
372        } else if annualized_return_pct > 0.0 {
373            f64::MAX
374        } else {
375            0.0
376        };
377
378        // Omega uses the same bar-by-bar returns as Sharpe/Sortino; per-trade
379        // returns vary by holding period and are incomparable across strategies.
380        let omega_ratio = calculate_omega_ratio(&returns);
381        let recovery_factor = if max_drawdown_pct > 0.0 {
382            total_return_pct / (max_drawdown_pct * 100.0)
383        } else if total_return_pct > 0.0 {
384            f64::MAX
385        } else {
386            0.0
387        };
388        // ulcer_index is already in percentage units (see calculate_ulcer_index).
389        let ulcer_index = calculate_ulcer_index(equity_curve);
390        let rf_pct = risk_free_rate * 100.0;
391        let serenity_ratio = if ulcer_index > 0.0 {
392            (annualized_return_pct - rf_pct) / ulcer_index
393        } else if annualized_return_pct > rf_pct {
394            f64::MAX
395        } else {
396            0.0
397        };
398
399        if total_trades == 0 {
400            return Self {
401                total_return_pct,
402                annualized_return_pct,
403                sharpe_ratio,
404                sortino_ratio,
405                max_drawdown_pct,
406                max_drawdown_duration,
407                win_rate: 0.0,
408                profit_factor: 0.0,
409                avg_trade_return_pct: 0.0,
410                avg_win_pct: 0.0,
411                avg_loss_pct: 0.0,
412                avg_trade_duration: 0.0,
413                total_trades: 0,
414                winning_trades: 0,
415                losing_trades: 0,
416                largest_win: 0.0,
417                largest_loss: 0.0,
418                max_consecutive_wins: 0,
419                max_consecutive_losses: 0,
420                calmar_ratio,
421                total_commission: 0.0,
422                total_financing_cost: 0.0,
423                long_trades: 0,
424                short_trades: 0,
425                total_signals,
426                executed_signals,
427                avg_win_duration: 0.0,
428                avg_loss_duration: 0.0,
429                time_in_market_pct: 0.0,
430                max_idle_period: 0,
431                total_dividend_income: 0.0,
432                kelly_criterion: 0.0,
433                sqn: 0.0,
434                expectancy: 0.0,
435                omega_ratio,
436                tail_ratio: 0.0,
437                recovery_factor,
438                ulcer_index,
439                serenity_ratio,
440            };
441        }
442
443        let win_rate = stats.winning_trades as f64 / total_trades as f64;
444
445        let profit_factor = if stats.gross_loss > 0.0 {
446            stats.gross_profit / stats.gross_loss
447        } else if stats.gross_profit > 0.0 {
448            f64::MAX
449        } else {
450            0.0
451        };
452
453        let avg_trade_return_pct = stats.total_return_sum / total_trades as f64;
454
455        let avg_win_pct = if !stats.winning_returns.is_empty() {
456            stats.winning_returns.iter().sum::<f64>() / stats.winning_returns.len() as f64
457        } else {
458            0.0
459        };
460
461        let avg_loss_pct = if !stats.losing_returns.is_empty() {
462            stats.losing_returns.iter().sum::<f64>() / stats.losing_returns.len() as f64
463        } else {
464            0.0
465        };
466
467        let avg_trade_duration = stats.total_duration as f64 / total_trades as f64;
468
469        // Consecutive wins/losses
470        let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
471
472        // Trade duration analysis
473        let (avg_win_duration, avg_loss_duration) = calculate_win_loss_durations(trades);
474        let time_in_market_pct = calculate_time_in_market(trades, equity_curve);
475        let max_idle_period = calculate_max_idle_period(trades);
476
477        // Extended metrics
478        let decisive_trades = stats.winning_trades + stats.losing_trades;
479        let kelly_win_rate = if decisive_trades > 0 {
480            stats.winning_trades as f64 / decisive_trades as f64
481        } else {
482            0.0
483        };
484        let kelly_criterion = calculate_kelly(kelly_win_rate, avg_win_pct, avg_loss_pct);
485        let sqn = calculate_sqn(&stats.all_returns);
486        // Dollar expectancy: expected profit per trade in the same currency as
487        // initial_capital. This is distinct from avg_trade_return_pct (which
488        // is a percentage). Break-even trades reduce both probabilities without
489        // contributing to either avg, so each outcome is weighted independently.
490        let loss_rate = stats.losing_trades as f64 / total_trades as f64;
491        let avg_win_dollar = if stats.winning_trades > 0 {
492            stats.gross_profit / stats.winning_trades as f64
493        } else {
494            0.0
495        };
496        let avg_loss_dollar = if stats.losing_trades > 0 {
497            -(stats.gross_loss / stats.losing_trades as f64)
498        } else {
499            0.0
500        };
501        let expectancy = win_rate * avg_win_dollar + loss_rate * avg_loss_dollar;
502        let tail_ratio = calculate_tail_ratio(&stats.all_returns);
503
504        Self {
505            total_return_pct,
506            annualized_return_pct,
507            sharpe_ratio,
508            sortino_ratio,
509            max_drawdown_pct,
510            max_drawdown_duration,
511            win_rate,
512            profit_factor,
513            avg_trade_return_pct,
514            avg_win_pct,
515            avg_loss_pct,
516            avg_trade_duration,
517            total_trades,
518            winning_trades: stats.winning_trades,
519            losing_trades: stats.losing_trades,
520            largest_win: stats.largest_win,
521            largest_loss: stats.largest_loss,
522            max_consecutive_wins,
523            max_consecutive_losses,
524            calmar_ratio,
525            total_commission: stats.total_commission,
526            total_financing_cost: stats.total_financing_cost,
527            long_trades: stats.long_trades,
528            short_trades: stats.short_trades,
529            total_signals,
530            executed_signals,
531            avg_win_duration,
532            avg_loss_duration,
533            time_in_market_pct,
534            max_idle_period,
535            total_dividend_income: stats.total_dividend_income,
536            kelly_criterion,
537            sqn,
538            expectancy,
539            omega_ratio,
540            tail_ratio,
541            recovery_factor,
542            ulcer_index,
543            serenity_ratio,
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::super::fixtures::make_trade;
551    use super::*;
552
553    #[test]
554    fn test_metrics_no_trades() {
555        let equity = vec![
556            EquityPoint {
557                timestamp: 0,
558                equity: 10000.0,
559                drawdown_pct: 0.0,
560            },
561            EquityPoint {
562                timestamp: 1,
563                equity: 10100.0,
564                drawdown_pct: 0.0,
565            },
566        ];
567
568        let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
569
570        assert_eq!(metrics.total_trades, 0);
571        assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
572    }
573
574    #[test]
575    fn test_metrics_with_trades() {
576        let trades = vec![
577            make_trade(100.0, 10.0, true), // Win
578            make_trade(-50.0, -5.0, true), // Loss
579            make_trade(75.0, 7.5, false),  // Win (short)
580            make_trade(25.0, 2.5, true),   // Win
581        ];
582
583        let equity = vec![
584            EquityPoint {
585                timestamp: 0,
586                equity: 10000.0,
587                drawdown_pct: 0.0,
588            },
589            EquityPoint {
590                timestamp: 1,
591                equity: 10100.0,
592                drawdown_pct: 0.0,
593            },
594            EquityPoint {
595                timestamp: 2,
596                equity: 10050.0,
597                drawdown_pct: 0.005,
598            },
599            EquityPoint {
600                timestamp: 3,
601                equity: 10125.0,
602                drawdown_pct: 0.0,
603            },
604            EquityPoint {
605                timestamp: 4,
606                equity: 10150.0,
607                drawdown_pct: 0.0,
608            },
609        ];
610
611        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
612
613        assert_eq!(metrics.total_trades, 4);
614        assert_eq!(metrics.winning_trades, 3);
615        assert_eq!(metrics.losing_trades, 1);
616        assert!((metrics.win_rate - 0.75).abs() < 0.01);
617        assert_eq!(metrics.long_trades, 3);
618        assert_eq!(metrics.short_trades, 1);
619    }
620
621    #[test]
622    fn test_max_drawdown_percentage_method() {
623        // Verify the convenience method returns max_drawdown_pct * 100.
624        // Use a trade so the no-trades early-return path is not taken, then
625        // supply an equity curve with a known 10% drawdown point.
626        let trade = make_trade(100.0, 10.0, true);
627        let equity = vec![
628            EquityPoint {
629                timestamp: 0,
630                equity: 10000.0,
631                drawdown_pct: 0.0,
632            },
633            EquityPoint {
634                timestamp: 1,
635                equity: 9000.0,
636                drawdown_pct: 0.1,
637            },
638            EquityPoint {
639                timestamp: 2,
640                equity: 10000.0,
641                drawdown_pct: 0.0,
642            },
643        ];
644        let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
645        assert!(
646            (metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
647            "max_drawdown_pct should be 0.1 (fraction), got {}",
648            metrics.max_drawdown_pct
649        );
650        assert!(
651            (metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
652            "max_drawdown_percentage() should be 10.0, got {}",
653            metrics.max_drawdown_percentage()
654        );
655    }
656
657    #[test]
658    fn test_new_metrics_in_calculate() {
659        // Mixed trades: 2 wins (+10%, +20%), 1 loss (-5%) with known equity curve
660        let trades = vec![
661            make_trade(100.0, 10.0, true),
662            make_trade(200.0, 20.0, true),
663            make_trade(-50.0, -5.0, true),
664        ];
665        let equity = vec![
666            EquityPoint {
667                timestamp: 0,
668                equity: 10000.0,
669                drawdown_pct: 0.0,
670            },
671            EquityPoint {
672                timestamp: 1,
673                equity: 10100.0,
674                drawdown_pct: 0.0,
675            },
676            EquityPoint {
677                timestamp: 2,
678                equity: 10300.0,
679                drawdown_pct: 0.0,
680            },
681            EquityPoint {
682                timestamp: 3,
683                equity: 10250.0,
684                drawdown_pct: 0.005,
685            },
686        ];
687        let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
688
689        // win_rate=2/3, avg_win=(10+20)/2=15, avg_loss=-5
690        // Kelly = 2/3 - (1/3)/(15/5) = 0.6667 - 0.3333/3 = 0.6667 - 0.1111 ≈ 0.5556
691        assert!(
692            m.kelly_criterion > 0.0,
693            "Kelly should be positive for profitable strategy"
694        );
695
696        // SQN with 3 trades
697        assert!(m.sqn.is_finite(), "SQN should be finite");
698
699        // Dollar expectancy: win_rate=2/3, avg_win=$100+$200)/2=$150, avg_loss=-$50
700        // = (2/3)*150 + (1/3)*(-50) = 100 - 16.67 ≈ 83.33
701        assert!(
702            m.expectancy > 0.0,
703            "Expectancy should be positive in dollar terms"
704        );
705
706        // Omega ratio is computed on periodic equity curve returns, not
707        // trade returns — just verify it is positive and finite.
708        assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
709
710        // Ulcer index from equity curve (max_drawdown=0.5%)
711        assert!(m.ulcer_index >= 0.0);
712
713        // Recovery factor: profitable with non-zero drawdown -> positive
714        assert!(m.recovery_factor > 0.0);
715    }
716
717    #[test]
718    fn test_profit_factor_all_wins_is_f64_max() {
719        let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
720        let equity = vec![
721            EquityPoint {
722                timestamp: 0,
723                equity: 10000.0,
724                drawdown_pct: 0.0,
725            },
726            EquityPoint {
727                timestamp: 1,
728                equity: 10150.0,
729                drawdown_pct: 0.0,
730            },
731        ];
732
733        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
734        assert_eq!(metrics.profit_factor, f64::MAX);
735    }
736
737    #[test]
738    fn test_kelly_uses_decisive_win_rate_not_diluted_win_rate() {
739        // 1 win (+10%), 2 break-even, 1 loss (-5%): diluted win_rate=0.25
740        // would give a negative Kelly, but the decisive win_rate (1 win of
741        // 2 decisive trades = 0.5) gives Kelly = 0.5 - 0.5/2 = 0.25.
742        let trades = vec![
743            make_trade(10.0, 10.0, true),
744            make_trade(0.0, 0.0, true),
745            make_trade(0.0, 0.0, true),
746            make_trade(-5.0, -5.0, true),
747        ];
748        let equity = vec![
749            EquityPoint {
750                timestamp: 0,
751                equity: 10000.0,
752                drawdown_pct: 0.0,
753            },
754            EquityPoint {
755                timestamp: 1,
756                equity: 10005.0,
757                drawdown_pct: 0.0,
758            },
759        ];
760
761        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 4, 4, 0.0, 252.0);
762
763        assert!((metrics.win_rate - 0.25).abs() < 1e-9);
764        assert!(
765            (metrics.kelly_criterion - 0.25).abs() < 1e-9,
766            "expected 0.25, got {}",
767            metrics.kelly_criterion
768        );
769    }
770
771    #[test]
772    fn test_metrics_no_trades_reports_curve_drawdown_for_open_position() {
773        // close_at_end = false leaves an open position with no closed trades,
774        // but the equity curve still marks the position to market every bar.
775        let equity = vec![
776            EquityPoint {
777                timestamp: 0,
778                equity: 10000.0,
779                drawdown_pct: 0.0,
780            },
781            EquityPoint {
782                timestamp: 1,
783                equity: 7000.0,
784                drawdown_pct: 0.3,
785            },
786            EquityPoint {
787                timestamp: 2,
788                equity: 10500.0,
789                drawdown_pct: 0.0,
790            },
791        ];
792
793        let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
794
795        assert_eq!(metrics.total_trades, 0);
796        assert!((metrics.total_return_pct - 5.0).abs() < 0.01);
797        assert!(
798            (metrics.max_drawdown_pct - 0.3).abs() < 1e-9,
799            "expected 0.3, got {}",
800            metrics.max_drawdown_pct
801        );
802        assert!(metrics.ulcer_index > 0.0);
803    }
804}