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}