Skip to main content

finance_query/backtesting/
result.rs

1//! Backtest results and performance metrics.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Datelike, NaiveDateTime, Utc, Weekday};
6use serde::{Deserialize, Serialize};
7
8use super::config::BacktestConfig;
9use super::position::{Position, Trade};
10use super::signal::SignalDirection;
11
12/// Point on the equity curve
13#[non_exhaustive]
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct EquityPoint {
16    /// Timestamp
17    pub timestamp: i64,
18    /// Portfolio equity at this point
19    pub equity: f64,
20    /// Current drawdown from peak as a **fraction** (0.0–1.0, not a percentage).
21    ///
22    /// `0.0` = equity is at its running all-time high; `0.2` = 20% below peak.
23    /// Multiply by 100 to convert to a conventional percentage.
24    pub drawdown_pct: f64,
25}
26
27/// Record of a generated signal (for analysis)
28#[non_exhaustive]
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SignalRecord {
31    /// Timestamp when signal was generated
32    pub timestamp: i64,
33    /// Price at signal time
34    pub price: f64,
35    /// Signal direction
36    pub direction: SignalDirection,
37    /// Signal strength (0.0-1.0)
38    pub strength: f64,
39    /// Signal reason/description
40    pub reason: Option<String>,
41    /// Whether the signal was executed
42    pub executed: bool,
43    /// Tags copied from the originating [`Signal`].
44    ///
45    /// Enables `BacktestResult::signals` to be filtered by tag so callers
46    /// can compare total generated vs. executed signal counts per tag.
47    #[serde(default)]
48    pub tags: Vec<String>,
49}
50
51/// Performance metrics summary
52#[non_exhaustive]
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PerformanceMetrics {
55    /// Total return percentage
56    pub total_return_pct: f64,
57
58    /// Annualized return percentage (assumes 252 trading days)
59    pub annualized_return_pct: f64,
60
61    /// Sharpe ratio (risk-free rate = 0)
62    pub sharpe_ratio: f64,
63
64    /// Sortino ratio (downside deviation)
65    pub sortino_ratio: f64,
66
67    /// Maximum drawdown as a fraction (0.0–1.0, **not** a percentage).
68    ///
69    /// A value of `0.2` means the equity fell 20% from its peak at most.
70    /// Multiply by 100 to get a conventional percentage. See also
71    /// [`max_drawdown_percentage`](Self::max_drawdown_percentage) for a
72    /// pre-scaled convenience accessor.
73    pub max_drawdown_pct: f64,
74
75    /// Maximum drawdown duration measured in **bars** (not calendar time).
76    ///
77    /// Counts the number of consecutive bars from a peak until full recovery.
78    pub max_drawdown_duration: i64,
79
80    /// Win rate: `winning_trades / total_trades`.
81    ///
82    /// The denominator is `total_trades`, which includes break-even trades
83    /// (`pnl == 0.0`).  Break-even trades are neither wins nor losses, so they
84    /// reduce the win rate without appearing in `winning_trades` or
85    /// `losing_trades`.
86    pub win_rate: f64,
87
88    /// Profit factor: `gross_profit / gross_loss`.
89    ///
90    /// Returns `f64::MAX` when there are no losing trades (zero denominator)
91    /// and at least one profitable trade.  This avoids `f64::INFINITY`, which
92    /// is not representable in JSON.
93    pub profit_factor: f64,
94
95    /// Average trade return percentage
96    pub avg_trade_return_pct: f64,
97
98    /// Average winning trade return percentage
99    pub avg_win_pct: f64,
100
101    /// Average losing trade return percentage
102    pub avg_loss_pct: f64,
103
104    /// Average trade duration in bars
105    pub avg_trade_duration: f64,
106
107    /// Total number of trades
108    pub total_trades: usize,
109
110    /// Number of winning trades (`pnl > 0.0`).
111    ///
112    /// Break-even trades (`pnl == 0.0`) are counted in neither `winning_trades`
113    /// nor `losing_trades`, so `winning_trades + losing_trades <= total_trades`.
114    pub winning_trades: usize,
115
116    /// Number of losing trades (`pnl < 0.0`).
117    ///
118    /// Break-even trades (`pnl == 0.0`) are counted in neither `winning_trades`
119    /// nor `losing_trades`. See [`winning_trades`](Self::winning_trades).
120    pub losing_trades: usize,
121
122    /// Largest winning trade P&L
123    pub largest_win: f64,
124
125    /// Largest losing trade P&L
126    pub largest_loss: f64,
127
128    /// Maximum consecutive wins
129    pub max_consecutive_wins: usize,
130
131    /// Maximum consecutive losses
132    pub max_consecutive_losses: usize,
133
134    /// Calmar ratio: `annualized_return_pct / max_drawdown_pct_scaled`.
135    ///
136    /// Returns `f64::MAX` when max drawdown is zero and the strategy is
137    /// profitable (avoids `f64::INFINITY` which cannot be serialized to JSON).
138    pub calmar_ratio: f64,
139
140    /// Total commission paid
141    pub total_commission: f64,
142
143    /// Number of long trades
144    pub long_trades: usize,
145
146    /// Number of short trades
147    pub short_trades: usize,
148
149    /// Total signals generated
150    pub total_signals: usize,
151
152    /// Signals that were executed
153    pub executed_signals: usize,
154
155    /// Average duration of winning trades in seconds
156    pub avg_win_duration: f64,
157
158    /// Average duration of losing trades in seconds
159    pub avg_loss_duration: f64,
160
161    /// Fraction of backtest time spent with an open position (0.0 - 1.0)
162    pub time_in_market_pct: f64,
163
164    /// Longest idle period between trades in seconds (0 if fewer than 2 trades)
165    pub max_idle_period: i64,
166
167    /// Total dividend income received across all trades
168    pub total_dividend_income: f64,
169
170    /// Kelly Criterion: optimal fraction of capital to risk per trade.
171    ///
172    /// Computed as `W - (1 - W) / R` where `W` is win rate and `R` is
173    /// `avg_win_pct / abs(avg_loss_pct)`. A positive value suggests the
174    /// strategy has an edge; a negative value suggests it does not. Values
175    /// above 1 indicate extreme edge (rare in practice). Returns `0.0` when
176    /// there are no losing trades to compute a ratio.
177    pub kelly_criterion: f64,
178
179    /// Van Tharp's System Quality Number.
180    ///
181    /// `SQN = (mean_R / std_R) * sqrt(n_trades)` where `R` is the
182    /// distribution of per-trade return percentages. Interpretation:
183    /// `>1.6` = below average, `>2.0` = average, `>2.5` = good,
184    /// `>3.0` = excellent, `>5.0` = superb, `>7.0` = holy grail.
185    /// Returns `0.0` when fewer than 2 trades are available.
186    ///
187    /// **Note:** Van Tharp's original definition uses *R-multiples*
188    /// (profit/loss normalised by initial risk per trade, i.e. entry-to-stop
189    /// distance). Since the engine does not track per-trade initial risk,
190    /// this implementation uses `return_pct` as a proxy. Values will
191    /// therefore not match Van Tharp's published benchmarks exactly.
192    /// At least 30 trades are recommended for statistical reliability.
193    pub sqn: f64,
194
195    /// Expectancy: expected profit per trade in dollar terms.
196    ///
197    /// `P(win) × avg_win_dollar + P(loss) × avg_loss_dollar` where each
198    /// probability is computed independently (`winning_trades / total` and
199    /// `losing_trades / total`). Unlike `avg_trade_return_pct` (which is a
200    /// percentage), this gives the expected monetary gain or loss per trade
201    /// in the same currency as `initial_capital`. A positive value means the
202    /// strategy has a statistical edge; e.g. `+$25` means you expect to make
203    /// $25 on average per trade taken.
204    pub expectancy: f64,
205
206    /// Omega Ratio: probability-weighted ratio of gains to losses.
207    ///
208    /// `Σ max(r, 0) / Σ max(-r, 0)` computed over **bar-by-bar periodic
209    /// returns** from the equity curve (consistent with Sharpe/Sortino),
210    /// using a threshold of `0.0`. More general than Sharpe — considers the
211    /// full return distribution rather than only mean and standard deviation.
212    /// Returns `f64::MAX` when there are no negative-return bars.
213    pub omega_ratio: f64,
214
215    /// Tail Ratio: ratio of right tail to left tail of trade returns.
216    ///
217    /// `abs(p95) / abs(p5)` of the trade return distribution using the
218    /// floor nearest-rank method (`floor(p × n)` as the 0-based index).
219    /// A value `>1` means large wins are more extreme than large losses
220    /// (favourable asymmetry). Returns `f64::MAX` when the 5th-percentile
221    /// return is zero. Returns `0.0` when fewer than 2 trades exist.
222    ///
223    /// **Note:** Reliable interpretation requires at least ~20 trades;
224    /// with fewer trades the percentile estimates are dominated by
225    /// individual outliers.
226    pub tail_ratio: f64,
227
228    /// Recovery Factor: net profit relative to maximum drawdown.
229    ///
230    /// `total_return_pct / (max_drawdown_pct * 100)`. Measures how
231    /// efficiently the strategy recovers from its worst drawdown. Returns
232    /// `f64::MAX` when there is no drawdown, `0.0` when unprofitable.
233    pub recovery_factor: f64,
234
235    /// Ulcer Index: root-mean-square of drawdown depth across all bars,
236    /// expressed as a **percentage** (0–100), consistent with backtesting.py
237    /// and Peter Martin's original 1987 definition.
238    ///
239    /// `sqrt(mean((drawdown_pct × 100)²))` computed from the equity curve.
240    /// Unlike max drawdown, it penalises both depth and duration — a long
241    /// shallow drawdown scores higher than a brief deep one. A lower value
242    /// indicates a smoother equity curve.
243    pub ulcer_index: f64,
244
245    /// Serenity Ratio (Martin Ratio / Ulcer Performance Index): excess
246    /// annualised return per unit of Ulcer Index risk.
247    ///
248    /// `(annualized_return_pct - risk_free_rate_pct) / ulcer_index` where
249    /// both numerator and denominator are in percentage units. Analogous to
250    /// the Sharpe Ratio but uses the Ulcer Index as the risk measure,
251    /// penalising prolonged drawdowns more heavily than short-term volatility.
252    /// Returns `f64::MAX` when Ulcer Index is zero and excess return is positive.
253    pub serenity_ratio: f64,
254}
255
256impl PerformanceMetrics {
257    /// Maximum drawdown as a conventional percentage (0–100).
258    ///
259    /// Equivalent to `self.max_drawdown_pct * 100.0`. Provided because
260    /// `max_drawdown_pct` is stored as a fraction (0.0–1.0) while most other
261    /// return fields use true percentages.
262    pub fn max_drawdown_percentage(&self) -> f64 {
263        self.max_drawdown_pct * 100.0
264    }
265
266    /// Construct a zero-trades result: all metrics are zero except `total_return_pct`
267    /// which is derived from the equity curve.
268    fn empty(
269        initial_capital: f64,
270        equity_curve: &[EquityPoint],
271        total_signals: usize,
272        executed_signals: usize,
273    ) -> Self {
274        let final_equity = equity_curve
275            .last()
276            .map(|e| e.equity)
277            .unwrap_or(initial_capital);
278        let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
279        Self {
280            total_return_pct,
281            annualized_return_pct: 0.0,
282            sharpe_ratio: 0.0,
283            sortino_ratio: 0.0,
284            max_drawdown_pct: 0.0,
285            max_drawdown_duration: 0,
286            win_rate: 0.0,
287            profit_factor: 0.0,
288            avg_trade_return_pct: 0.0,
289            avg_win_pct: 0.0,
290            avg_loss_pct: 0.0,
291            avg_trade_duration: 0.0,
292            total_trades: 0,
293            winning_trades: 0,
294            losing_trades: 0,
295            largest_win: 0.0,
296            largest_loss: 0.0,
297            max_consecutive_wins: 0,
298            max_consecutive_losses: 0,
299            calmar_ratio: 0.0,
300            total_commission: 0.0,
301            long_trades: 0,
302            short_trades: 0,
303            total_signals,
304            executed_signals,
305            avg_win_duration: 0.0,
306            avg_loss_duration: 0.0,
307            time_in_market_pct: 0.0,
308            max_idle_period: 0,
309            total_dividend_income: 0.0,
310            kelly_criterion: 0.0,
311            sqn: 0.0,
312            expectancy: 0.0,
313            omega_ratio: 0.0,
314            tail_ratio: 0.0,
315            recovery_factor: 0.0,
316            ulcer_index: 0.0,
317            serenity_ratio: 0.0,
318        }
319    }
320
321    /// Calculate performance metrics from trades and equity curve.
322    ///
323    /// `risk_free_rate` is the **annual** rate (e.g. `0.05` for 5%). It is
324    /// converted to a per-bar rate internally before computing Sharpe/Sortino.
325    ///
326    /// `bars_per_year` controls annualisation (e.g. `252.0` for daily US equity
327    /// bars, `52.0` for weekly, `1638.0` for hourly). Affects annualised return,
328    /// Sharpe, Sortino, and Calmar calculations.
329    pub fn calculate(
330        trades: &[Trade],
331        equity_curve: &[EquityPoint],
332        initial_capital: f64,
333        total_signals: usize,
334        executed_signals: usize,
335        risk_free_rate: f64,
336        bars_per_year: f64,
337    ) -> Self {
338        if trades.is_empty() {
339            return Self::empty(
340                initial_capital,
341                equity_curve,
342                total_signals,
343                executed_signals,
344            );
345        }
346
347        let total_trades = trades.len();
348        let stats = analyze_trades(trades);
349
350        let win_rate = stats.winning_trades as f64 / total_trades as f64;
351
352        let profit_factor = if stats.gross_loss > 0.0 {
353            stats.gross_profit / stats.gross_loss
354        } else if stats.gross_profit > 0.0 {
355            f64::MAX
356        } else {
357            0.0
358        };
359
360        let avg_trade_return_pct = stats.total_return_sum / total_trades as f64;
361
362        let avg_win_pct = if !stats.winning_returns.is_empty() {
363            stats.winning_returns.iter().sum::<f64>() / stats.winning_returns.len() as f64
364        } else {
365            0.0
366        };
367
368        let avg_loss_pct = if !stats.losing_returns.is_empty() {
369            stats.losing_returns.iter().sum::<f64>() / stats.losing_returns.len() as f64
370        } else {
371            0.0
372        };
373
374        let avg_trade_duration = stats.total_duration as f64 / total_trades as f64;
375
376        // Consecutive wins/losses
377        let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
378
379        // Drawdown metrics
380        let max_drawdown_pct = equity_curve
381            .iter()
382            .map(|e| e.drawdown_pct)
383            .fold(0.0, f64::max);
384
385        let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
386
387        // Total return
388        let final_equity = equity_curve
389            .last()
390            .map(|e| e.equity)
391            .unwrap_or(initial_capital);
392        let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
393
394        // Annualized return using configured bars_per_year.
395        // Use return periods (N-1), not points (N), to avoid overestimating
396        // elapsed time for short series.
397        let num_periods = equity_curve.len().saturating_sub(1);
398        let years = num_periods as f64 / bars_per_year;
399        let growth = final_equity / initial_capital;
400        let annualized_return_pct = if years > 0.0 {
401            if growth <= 0.0 {
402                -100.0
403            } else {
404                (growth.powf(1.0 / years) - 1.0) * 100.0
405            }
406        } else {
407            0.0
408        };
409
410        // Sharpe and Sortino ratios (computed in one pass over shared excess returns)
411        let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
412        let (sharpe_ratio, sortino_ratio) =
413            calculate_risk_ratios(&returns, risk_free_rate, bars_per_year);
414
415        // Calmar ratio = annualised return (%) / max drawdown (%).
416        // Use f64::MAX instead of INFINITY when drawdown is zero to keep the
417        // value JSON-serializable.
418        let calmar_ratio = if max_drawdown_pct > 0.0 {
419            annualized_return_pct / (max_drawdown_pct * 100.0)
420        } else if annualized_return_pct > 0.0 {
421            f64::MAX
422        } else {
423            0.0
424        };
425
426        // Trade duration analysis
427        let (avg_win_duration, avg_loss_duration) = calculate_win_loss_durations(trades);
428        let time_in_market_pct = calculate_time_in_market(trades, equity_curve);
429        let max_idle_period = calculate_max_idle_period(trades);
430
431        // Phase 1 — extended metrics
432        let kelly_criterion = calculate_kelly(win_rate, avg_win_pct, avg_loss_pct);
433        let sqn = calculate_sqn(&stats.all_returns);
434        // Dollar expectancy: expected profit per trade in the same currency as
435        // initial_capital. This is distinct from avg_trade_return_pct (which
436        // is a percentage). Break-even trades reduce both probabilities without
437        // contributing to either avg, so each outcome is weighted independently.
438        let loss_rate = stats.losing_trades as f64 / total_trades as f64;
439        let avg_win_dollar = if stats.winning_trades > 0 {
440            stats.gross_profit / stats.winning_trades as f64
441        } else {
442            0.0
443        };
444        let avg_loss_dollar = if stats.losing_trades > 0 {
445            -(stats.gross_loss / stats.losing_trades as f64)
446        } else {
447            0.0
448        };
449        let expectancy = win_rate * avg_win_dollar + loss_rate * avg_loss_dollar;
450        // Omega Ratio is defined on the continuous return distribution —
451        // use the same bar-by-bar periodic returns as Sharpe/Sortino, not
452        // per-trade returns (which vary by holding period and are incomparable
453        // across strategies with different average trade durations).
454        let omega_ratio = calculate_omega_ratio(&returns);
455        let tail_ratio = calculate_tail_ratio(&stats.all_returns);
456        let recovery_factor = if max_drawdown_pct > 0.0 {
457            total_return_pct / (max_drawdown_pct * 100.0)
458        } else if total_return_pct > 0.0 {
459            f64::MAX
460        } else {
461            0.0
462        };
463        // ulcer_index is already in percentage units (see calculate_ulcer_index).
464        let ulcer_index = calculate_ulcer_index(equity_curve);
465        let rf_pct = risk_free_rate * 100.0;
466        let serenity_ratio = if ulcer_index > 0.0 {
467            (annualized_return_pct - rf_pct) / ulcer_index
468        } else if annualized_return_pct > rf_pct {
469            f64::MAX
470        } else {
471            0.0
472        };
473
474        Self {
475            total_return_pct,
476            annualized_return_pct,
477            sharpe_ratio,
478            sortino_ratio,
479            max_drawdown_pct,
480            max_drawdown_duration,
481            win_rate,
482            profit_factor,
483            avg_trade_return_pct,
484            avg_win_pct,
485            avg_loss_pct,
486            avg_trade_duration,
487            total_trades,
488            winning_trades: stats.winning_trades,
489            losing_trades: stats.losing_trades,
490            largest_win: stats.largest_win,
491            largest_loss: stats.largest_loss,
492            max_consecutive_wins,
493            max_consecutive_losses,
494            calmar_ratio,
495            total_commission: stats.total_commission,
496            long_trades: stats.long_trades,
497            short_trades: stats.short_trades,
498            total_signals,
499            executed_signals,
500            avg_win_duration,
501            avg_loss_duration,
502            time_in_market_pct,
503            max_idle_period,
504            total_dividend_income: stats.total_dividend_income,
505            kelly_criterion,
506            sqn,
507            expectancy,
508            omega_ratio,
509            tail_ratio,
510            recovery_factor,
511            ulcer_index,
512            serenity_ratio,
513        }
514    }
515}
516
517/// Aggregated trade statistics collected in a single pass over the trade log.
518struct TradeStats {
519    winning_trades: usize,
520    losing_trades: usize,
521    long_trades: usize,
522    short_trades: usize,
523    gross_profit: f64,
524    gross_loss: f64,
525    total_return_sum: f64,
526    total_duration: i64,
527    largest_win: f64,
528    largest_loss: f64,
529    total_commission: f64,
530    total_dividend_income: f64,
531    winning_returns: Vec<f64>,
532    losing_returns: Vec<f64>,
533    /// All trade return percentages (wins + losses + break-even).
534    all_returns: Vec<f64>,
535}
536
537/// Single-pass accumulation of all per-trade statistics.
538fn analyze_trades(trades: &[Trade]) -> TradeStats {
539    let mut stats = TradeStats {
540        winning_trades: 0,
541        losing_trades: 0,
542        long_trades: 0,
543        short_trades: 0,
544        gross_profit: 0.0,
545        gross_loss: 0.0,
546        total_return_sum: 0.0,
547        total_duration: 0,
548        largest_win: 0.0,
549        largest_loss: 0.0,
550        total_commission: 0.0,
551        total_dividend_income: 0.0,
552        winning_returns: Vec::new(),
553        losing_returns: Vec::new(),
554        all_returns: Vec::new(),
555    };
556
557    for t in trades {
558        if t.is_profitable() {
559            stats.winning_trades += 1;
560            stats.gross_profit += t.pnl;
561            stats.winning_returns.push(t.return_pct);
562            stats.largest_win = stats.largest_win.max(t.pnl);
563        } else if t.is_loss() {
564            stats.losing_trades += 1;
565            stats.gross_loss += t.pnl.abs();
566            stats.losing_returns.push(t.return_pct);
567            stats.largest_loss = stats.largest_loss.min(t.pnl);
568        }
569        if t.is_long() {
570            stats.long_trades += 1;
571        } else {
572            stats.short_trades += 1;
573        }
574        stats.total_return_sum += t.return_pct;
575        stats.total_duration += t.duration_secs();
576        stats.total_commission += t.commission;
577        stats.total_dividend_income += t.dividend_income;
578        stats.all_returns.push(t.return_pct);
579    }
580
581    stats
582}
583
584/// Kelly Criterion: `W - (1 - W) / R` where R = avg_win / abs(avg_loss).
585///
586/// Returns `f64::MAX` when there are no losing trades and wins are positive
587/// (unbounded edge). Returns `0.0` when inputs are degenerate.
588fn calculate_kelly(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
589    let abs_loss = avg_loss_pct.abs();
590    if abs_loss == 0.0 {
591        // No losing trades: edge is unbounded. Use f64::MAX to match the
592        // sentinel convention used by profit_factor and calmar_ratio.
593        return if avg_win_pct > 0.0 { f64::MAX } else { 0.0 };
594    }
595    if avg_win_pct == 0.0 {
596        return 0.0;
597    }
598    let r = avg_win_pct / abs_loss;
599    win_rate - (1.0 - win_rate) / r
600}
601
602/// Van Tharp's System Quality Number.
603///
604/// `(mean_R / std_R) * sqrt(n)` over per-trade return percentages.
605/// Uses sample standard deviation (n-1). Returns `0.0` for fewer than 2 trades.
606fn calculate_sqn(returns: &[f64]) -> f64 {
607    let n = returns.len();
608    if n < 2 {
609        return 0.0;
610    }
611    let mean = returns.iter().sum::<f64>() / n as f64;
612    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
613    let std_dev = variance.sqrt();
614    if std_dev == 0.0 {
615        return 0.0;
616    }
617    (mean / std_dev) * (n as f64).sqrt()
618}
619
620/// Omega Ratio using a threshold of 0.0.
621///
622/// `Σ max(r, 0) / Σ max(-r, 0)`. Returns `f64::MAX` when the denominator
623/// is zero (no negative returns), `0.0` when the numerator is also zero.
624fn calculate_omega_ratio(returns: &[f64]) -> f64 {
625    let gains: f64 = returns.iter().map(|&r| r.max(0.0)).sum();
626    let losses: f64 = returns.iter().map(|&r| (-r).max(0.0)).sum();
627    if losses == 0.0 {
628        if gains > 0.0 { f64::MAX } else { 0.0 }
629    } else {
630        gains / losses
631    }
632}
633
634/// Tail Ratio: `abs(p95) / abs(p5)` of trade returns.
635///
636/// Returns `0.0` for fewer than 2 trades, `f64::MAX` when `p5 == 0`.
637fn calculate_tail_ratio(returns: &[f64]) -> f64 {
638    let n = returns.len();
639    if n < 2 {
640        return 0.0;
641    }
642    let mut sorted = returns.to_vec();
643    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
644
645    let p5_idx = ((0.05 * n as f64).floor() as usize).min(n - 1);
646    let p95_idx = ((0.95 * n as f64).floor() as usize).min(n - 1);
647
648    let p5 = sorted[p5_idx].abs();
649    let p95 = sorted[p95_idx].abs();
650
651    if p5 == 0.0 {
652        if p95 > 0.0 { f64::MAX } else { 0.0 }
653    } else {
654        p95 / p5
655    }
656}
657
658/// Ulcer Index: `sqrt(mean(drawdown_pct²))` across all equity curve points,
659/// returned in **percentage** units (0–100) to match standard tool output.
660fn calculate_ulcer_index(equity_curve: &[EquityPoint]) -> f64 {
661    if equity_curve.is_empty() {
662        return 0.0;
663    }
664    // drawdown_pct is a fraction (0–1); multiply by 100 before squaring so
665    // the result is in percentage units consistent with backtesting.py and
666    // Peter Martin's original definition.
667    let sum_sq: f64 = equity_curve
668        .iter()
669        .map(|p| (p.drawdown_pct * 100.0).powi(2))
670        .sum();
671    (sum_sq / equity_curve.len() as f64).sqrt()
672}
673
674/// Calculate maximum consecutive wins and losses
675fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
676    let mut max_wins = 0;
677    let mut max_losses = 0;
678    let mut current_wins = 0;
679    let mut current_losses = 0;
680
681    for trade in trades {
682        if trade.is_profitable() {
683            current_wins += 1;
684            current_losses = 0;
685            max_wins = max_wins.max(current_wins);
686        } else if trade.is_loss() {
687            current_losses += 1;
688            current_wins = 0;
689            max_losses = max_losses.max(current_losses);
690        } else {
691            // Break-even trade
692            current_wins = 0;
693            current_losses = 0;
694        }
695    }
696
697    (max_wins, max_losses)
698}
699
700/// Calculate maximum drawdown duration in bars
701fn calculate_max_drawdown_duration(equity_curve: &[EquityPoint]) -> i64 {
702    if equity_curve.is_empty() {
703        return 0;
704    }
705
706    let mut max_duration = 0;
707    let mut current_duration = 0;
708    let mut peak = equity_curve[0].equity;
709
710    for point in equity_curve {
711        if point.equity >= peak {
712            peak = point.equity;
713            max_duration = max_duration.max(current_duration);
714            current_duration = 0;
715        } else {
716            current_duration += 1;
717        }
718    }
719
720    max_duration.max(current_duration)
721}
722
723/// Calculate periodic returns from equity curve
724fn calculate_periodic_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
725    if equity_curve.len() < 2 {
726        return vec![];
727    }
728
729    equity_curve
730        .windows(2)
731        .map(|w| {
732            let prev = w[0].equity;
733            let curr = w[1].equity;
734            if prev > 0.0 {
735                (curr - prev) / prev
736            } else {
737                0.0
738            }
739        })
740        .collect()
741}
742
743/// Convert an annual risk-free rate to a per-bar rate.
744///
745/// `bars_per_year` controls the compounding frequency (e.g. 252 for daily US
746/// equity bars, 52 for weekly, 1638 for hourly). The resulting per-bar rate is
747/// subtracted from each return before computing Sharpe/Sortino.
748fn annual_to_periodic_rf(annual_rate: f64, bars_per_year: f64) -> f64 {
749    (1.0 + annual_rate).powf(1.0 / bars_per_year) - 1.0
750}
751
752/// Calculate Sharpe and Sortino ratios in a single pass over excess returns.
753///
754/// Computes the shared `excess` vec and `mean` once, then derives both ratios.
755/// Uses sample standard deviation (n-1) and annualises by `sqrt(bars_per_year)`.
756/// Returns `f64::MAX` for the positive-mean / zero-deviation edge case so the
757/// value survives JSON round-trips (avoids `INFINITY`).
758fn calculate_risk_ratios(
759    returns: &[f64],
760    annual_risk_free_rate: f64,
761    bars_per_year: f64,
762) -> (f64, f64) {
763    if returns.len() < 2 {
764        return (0.0, 0.0);
765    }
766
767    let periodic_rf = annual_to_periodic_rf(annual_risk_free_rate, bars_per_year);
768    let excess: Vec<f64> = returns.iter().map(|r| r - periodic_rf).collect();
769    let n = excess.len() as f64;
770    let mean = excess.iter().sum::<f64>() / n;
771
772    // Sharpe: sample variance (n-1) for unbiased estimation
773    let variance = excess.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
774    let std_dev = variance.sqrt();
775    let sharpe = if std_dev > 0.0 {
776        (mean / std_dev) * bars_per_year.sqrt()
777    } else if mean > 0.0 {
778        f64::MAX
779    } else {
780        0.0
781    };
782
783    // Sortino: downside deviation (only negative excess; denominator is n-1,
784    // per Sortino's original definition and the `risk` module convention)
785    let downside_sq_sum: f64 = excess.iter().filter(|&&r| r < 0.0).map(|r| r.powi(2)).sum();
786    let downside_dev = (downside_sq_sum / (n - 1.0)).sqrt();
787    let sortino = if downside_dev > 0.0 {
788        (mean / downside_dev) * bars_per_year.sqrt()
789    } else if mean > 0.0 {
790        f64::MAX
791    } else {
792        0.0
793    };
794
795    (sharpe, sortino)
796}
797
798/// Calculate average duration (in seconds) for winning and losing trades separately.
799fn calculate_win_loss_durations(trades: &[Trade]) -> (f64, f64) {
800    let win_durations: Vec<i64> = trades
801        .iter()
802        .filter(|t| t.is_profitable())
803        .map(|t| t.duration_secs())
804        .collect();
805    let loss_durations: Vec<i64> = trades
806        .iter()
807        .filter(|t| t.is_loss())
808        .map(|t| t.duration_secs())
809        .collect();
810
811    let avg_win = if win_durations.is_empty() {
812        0.0
813    } else {
814        win_durations.iter().sum::<i64>() as f64 / win_durations.len() as f64
815    };
816
817    let avg_loss = if loss_durations.is_empty() {
818        0.0
819    } else {
820        loss_durations.iter().sum::<i64>() as f64 / loss_durations.len() as f64
821    };
822
823    (avg_win, avg_loss)
824}
825
826/// Calculate fraction of backtest time spent in a position.
827///
828/// Uses the ratio of total trade duration to the total backtest duration
829/// derived from the equity curve timestamps.
830fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
831    let total_duration_secs: i64 = trades.iter().map(|t| t.duration_secs()).sum();
832
833    let backtest_secs = match (equity_curve.first(), equity_curve.last()) {
834        (Some(first), Some(last)) if last.timestamp > first.timestamp => {
835            last.timestamp - first.timestamp
836        }
837        _ => return 0.0,
838    };
839
840    (total_duration_secs as f64 / backtest_secs as f64).min(1.0)
841}
842
843/// Calculate the longest idle period (seconds) between consecutive trades.
844///
845/// Returns 0 if there are fewer than 2 trades.
846fn calculate_max_idle_period(trades: &[Trade]) -> i64 {
847    if trades.len() < 2 {
848        return 0;
849    }
850
851    // Trades are appended in chronological order; compute gaps between
852    // exit of trade N and entry of trade N+1.
853    trades
854        .windows(2)
855        .map(|w| (w[1].entry_timestamp - w[0].exit_timestamp).max(0))
856        .max()
857        .unwrap_or(0)
858}
859
860/// Infer the effective bars-per-year from the calendar span of an equity slice.
861///
862/// When an equity slice contains non-consecutive bars (e.g. every Monday in a
863/// daily-bar backtest), the configured `bars_per_year` is no longer the right
864/// annualisation denominator.  This function derives the correct value from
865/// the number of return periods and the elapsed calendar time so that Sharpe
866/// and Sortino ratios are annualised accurately regardless of bar frequency.
867///
868/// Falls back to `fallback_bpy` when the slice has fewer than two points or
869/// its timestamp span is non-positive.
870fn infer_bars_per_year(equity_slice: &[EquityPoint], fallback_bpy: f64) -> f64 {
871    if equity_slice.len() < 2 {
872        return fallback_bpy;
873    }
874    let first_ts = equity_slice.first().unwrap().timestamp as f64;
875    let last_ts = equity_slice.last().unwrap().timestamp as f64;
876    let seconds_per_year = 365.25 * 24.0 * 3600.0;
877    let years = (last_ts - first_ts) / seconds_per_year;
878    if years <= 0.0 {
879        return fallback_bpy;
880    }
881    // Use (len - 1) = number of return periods, consistent with how
882    // calculate_periodic_returns counts returns.
883    ((equity_slice.len() - 1) as f64 / years).max(1.0)
884}
885
886/// Zero out time-scaled ratios when a period slice covers less than half a
887/// year of bars.
888///
889/// Geometric annualisation of a sub-half-year return magnifies the result
890/// by raising `growth` to a power > 2, making `annualized_return_pct`,
891/// `calmar_ratio`, and `serenity_ratio` misleadingly large for short slices
892/// (e.g. partial first/last years, individual monthly buckets).  Setting
893/// them to `0.0` signals to callers that no reliable annual rate is available
894/// for this period without requiring a new return type.
895fn partial_period_adjust(
896    mut metrics: PerformanceMetrics,
897    slice_len: usize,
898    bpy: f64,
899) -> PerformanceMetrics {
900    let periods = slice_len.saturating_sub(1) as f64;
901    if periods / bpy < 0.5 {
902        metrics.annualized_return_pct = 0.0;
903        metrics.calmar_ratio = 0.0;
904        metrics.serenity_ratio = 0.0;
905    }
906    metrics
907}
908
909/// Convert a Unix-second timestamp to a `NaiveDateTime` (UTC).
910///
911/// Returns `None` for timestamps outside the range representable by
912/// [`DateTime<Utc>`] (i.e. before ≈ year −262144 or after ≈ year 262143).
913/// Call sites should skip entries that map to `None` rather than defaulting
914/// to the Unix epoch, which would silently misattribute those records to
915/// `1970-01-01 Thursday`.
916fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
917    DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
918}
919
920/// Comparison of strategy performance against a benchmark.
921///
922/// Populated when a benchmark symbol is supplied to `backtest_with_benchmark`.
923#[non_exhaustive]
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub struct BenchmarkMetrics {
926    /// Benchmark symbol (e.g. `"SPY"`)
927    pub symbol: String,
928
929    /// Buy-and-hold return of the benchmark over the same period (percentage)
930    pub benchmark_return_pct: f64,
931
932    /// Buy-and-hold return of the backtested symbol over the same period (percentage)
933    pub buy_and_hold_return_pct: f64,
934
935    /// Jensen's Alpha: annualised strategy excess return over the benchmark (CAPM).
936    ///
937    /// Computed as `strategy_ann - rf - β × (benchmark_ann - rf)` on the
938    /// timestamp-aligned subset of strategy and benchmark returns.
939    ///
940    /// # Accuracy Caveat
941    ///
942    /// Annualisation uses `aligned_bars / bars_per_year` to estimate elapsed
943    /// years.  If the strategy and benchmark candles have **different sampling
944    /// frequencies** (e.g., daily strategy vs. weekly benchmark), the aligned
945    /// subset contains far fewer bars than the full backtest period and the
946    /// per-year estimate will be wrong — both `strategy_ann` and `benchmark_ann`
947    /// are inflated by the same factor, but the risk-free rate is always the
948    /// true annual rate, making alpha unreliable.
949    ///
950    /// For accurate alpha, supply benchmark candles with the **same interval**
951    /// as the strategy candles.
952    pub alpha: f64,
953
954    /// Beta: sensitivity of strategy returns to benchmark movements
955    pub beta: f64,
956
957    /// Information ratio: excess return per unit of tracking error (annualised)
958    pub information_ratio: f64,
959}
960
961/// Complete backtest result
962#[non_exhaustive]
963#[derive(Debug, Clone, Serialize, Deserialize)]
964pub struct BacktestResult {
965    /// Symbol that was backtested
966    pub symbol: String,
967
968    /// Strategy name
969    pub strategy_name: String,
970
971    /// Configuration used
972    pub config: BacktestConfig,
973
974    /// Start timestamp
975    pub start_timestamp: i64,
976
977    /// End timestamp
978    pub end_timestamp: i64,
979
980    /// Initial capital
981    pub initial_capital: f64,
982
983    /// Final equity
984    pub final_equity: f64,
985
986    /// Performance metrics
987    pub metrics: PerformanceMetrics,
988
989    /// Complete trade log
990    pub trades: Vec<Trade>,
991
992    /// Equity curve (portfolio value at each bar)
993    pub equity_curve: Vec<EquityPoint>,
994
995    /// All signals generated (including non-executed)
996    pub signals: Vec<SignalRecord>,
997
998    /// Current open position (if any at end)
999    pub open_position: Option<Position>,
1000
1001    /// Benchmark comparison metrics (set when a benchmark is provided)
1002    pub benchmark: Option<BenchmarkMetrics>,
1003
1004    /// Diagnostic messages (e.g. why zero trades were produced).
1005    ///
1006    /// Empty when the backtest ran without issues. Populated with actionable
1007    /// hints when the engine detects likely misconfiguration.
1008    #[serde(default)]
1009    pub diagnostics: Vec<String>,
1010}
1011
1012impl BacktestResult {
1013    /// Get a formatted summary string
1014    pub fn summary(&self) -> String {
1015        format!(
1016            "Backtest: {} on {}\n\
1017             Period: {} bars\n\
1018             Initial: ${:.2} -> Final: ${:.2}\n\
1019             Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
1020             Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
1021            self.strategy_name,
1022            self.symbol,
1023            self.equity_curve.len(),
1024            self.initial_capital,
1025            self.final_equity,
1026            self.metrics.total_return_pct,
1027            self.metrics.sharpe_ratio,
1028            self.metrics.max_drawdown_pct * 100.0,
1029            self.metrics.total_trades,
1030            self.metrics.win_rate * 100.0,
1031            self.metrics.profit_factor,
1032        )
1033    }
1034
1035    /// Check if the backtest was profitable
1036    pub fn is_profitable(&self) -> bool {
1037        self.final_equity > self.initial_capital
1038    }
1039
1040    /// Get total P&L
1041    pub fn total_pnl(&self) -> f64 {
1042        self.final_equity - self.initial_capital
1043    }
1044
1045    /// Get the number of bars in the backtest
1046    pub fn num_bars(&self) -> usize {
1047        self.equity_curve.len()
1048    }
1049
1050    // ─── Phase 2 — Rolling & Temporal Analysis ───────────────────────────────
1051
1052    /// Rolling Sharpe ratio over a sliding window of equity-curve bars.
1053    ///
1054    /// For each window of `window` consecutive bar-to-bar returns, computes
1055    /// the Sharpe ratio using the same `risk_free_rate` and `bars_per_year`
1056    /// as the overall backtest.  The first element corresponds to bars
1057    /// `0..window` of the equity curve.
1058    ///
1059    /// Returns an empty vector when `window == 0` or when the equity curve
1060    /// contains fewer than `window + 1` bars (i.e. fewer than `window`
1061    /// return periods).
1062    ///
1063    /// # Statistical reliability
1064    ///
1065    /// Sharpe and Sortino are computed from `window` return observations using
1066    /// sample variance (`n − 1` degrees of freedom).  Very small windows
1067    /// produce extreme and unreliable values — at least **30 bars** is a
1068    /// practical lower bound; **60–252** is typical for daily backtests.
1069    pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
1070        if window == 0 {
1071            return vec![];
1072        }
1073        let returns = calculate_periodic_returns(&self.equity_curve);
1074        if returns.len() < window {
1075            return vec![];
1076        }
1077        let rf = self.config.risk_free_rate;
1078        let bpy = self.config.bars_per_year;
1079        returns
1080            .windows(window)
1081            .map(|w| {
1082                let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
1083                sharpe
1084            })
1085            .collect()
1086    }
1087
1088    /// Running drawdown fraction at each bar of the equity curve (0.0–1.0).
1089    ///
1090    /// Each value is the fractional decline from the running all-time-high
1091    /// equity up to that bar: `0.0` means the equity is at a new peak; `0.2`
1092    /// means it is 20% below the highest value seen so far.
1093    ///
1094    /// **This is not a sliding-window computation.** Values are read directly
1095    /// from the precomputed [`EquityPoint::drawdown_pct`] field, which tracks
1096    /// the running-peak drawdown since the backtest began.  To compute the
1097    /// *maximum* drawdown within a rolling N-bar window (regime-change
1098    /// detection), iterate over [`BacktestResult::equity_curve`] manually.
1099    ///
1100    /// The returned vector has the same length as
1101    /// [`BacktestResult::equity_curve`].
1102    pub fn drawdown_series(&self) -> Vec<f64> {
1103        self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
1104    }
1105
1106    /// Rolling win rate over a sliding window of consecutive closed trades.
1107    ///
1108    /// For each window of `window` trades (ordered by exit timestamp as stored
1109    /// in the trade log), returns the fraction of winning trades in that
1110    /// window.  The first element corresponds to trades `0..window`.
1111    ///
1112    /// This is a **trade-count window**, not a time window.  To compute win
1113    /// rate over a fixed calendar period, use [`by_year`](Self::by_year),
1114    /// [`by_month`](Self::by_month), or filter [`BacktestResult::trades`]
1115    /// directly by timestamp.
1116    ///
1117    /// Returns an empty vector when `window == 0` or when fewer than `window`
1118    /// trades were closed.
1119    pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
1120        if window == 0 || self.trades.len() < window {
1121            return vec![];
1122        }
1123        self.trades
1124            .windows(window)
1125            .map(|w| {
1126                let wins = w.iter().filter(|t| t.is_profitable()).count();
1127                wins as f64 / window as f64
1128            })
1129            .collect()
1130    }
1131
1132    /// Performance metrics broken down by calendar year.
1133    ///
1134    /// Each trade is attributed to the year in which it **closed**
1135    /// (`exit_timestamp`).  The equity curve is sliced to the bars that fall
1136    /// within that calendar year, and the equity at the first bar of the year
1137    /// serves as `initial_capital` for the period metrics.
1138    ///
1139    /// Years with no closed trades are omitted from the result.
1140    ///
1141    /// # Caveats
1142    ///
1143    /// - **Open positions**: a position that is open throughout the year
1144    ///   contributes to the equity-curve drawdown and Sharpe of that year but
1145    ///   does **not** appear in `total_trades` or `win_rate`, because those
1146    ///   are derived from closed trades only.  Strategies with long holding
1147    ///   periods will show systematically low trade counts per year.
1148    /// - **Partial years**: the first and last year of a backtest typically
1149    ///   cover fewer than 12 months.  `annualized_return_pct`, `calmar_ratio`,
1150    ///   and `serenity_ratio` are set to `0.0` for slices shorter than half a
1151    ///   year (`< bars_per_year / 2` bars) to prevent geometric-compounding
1152    ///   distortion.
1153    /// - **`total_signals` / `executed_signals`**: these fields are `0` in
1154    ///   period breakdowns because signal records are not partitioned per
1155    ///   period.  Use [`BacktestResult::signals`] directly if needed.
1156    pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
1157        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
1158    }
1159
1160    /// Performance metrics broken down by calendar month.
1161    ///
1162    /// Each trade is attributed to the `(year, month)` in which it **closed**.
1163    /// Uses the same equity-slicing approach as [`by_year`](Self::by_year);
1164    /// the same caveats about open positions, partial periods, and signal
1165    /// counts apply here as well.
1166    pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
1167        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
1168    }
1169
1170    /// Performance metrics broken down by day of week.
1171    ///
1172    /// Each trade is attributed to the weekday on which it **closed**
1173    /// (`exit_timestamp`).  Only weekdays present in the trade log appear in
1174    /// the result.  Trades and equity-curve points with timestamps that cannot
1175    /// be converted to a valid date are silently skipped.
1176    ///
1177    /// # Sharpe / Sortino annualisation
1178    ///
1179    /// The equity curve is filtered to bars that fall on each specific
1180    /// weekday, so consecutive equity points in each slice are roughly one
1181    /// *week* apart (for a daily-bar backtest).  `bars_per_year` is inferred
1182    /// from the calendar span of each slice so that annualisation matches the
1183    /// actual sampling frequency — **you do not need to adjust the config**.
1184    /// The inferred value is approximately `52` for daily bars, `12` for
1185    /// weekly bars, and so on.
1186    ///
1187    /// # Other caveats
1188    ///
1189    /// The same open-position and signal-count caveats from
1190    /// [`by_year`](Self::by_year) apply here.
1191    pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
1192        // Pre-group trades by weekday — O(T)
1193        let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
1194        for trade in &self.trades {
1195            if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
1196            {
1197                trade_groups.entry(day).or_default().push(trade);
1198            }
1199        }
1200
1201        // Pre-group equity curve by weekday — O(N), avoids O(N × K) rescanning
1202        let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
1203        for p in &self.equity_curve {
1204            if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
1205                equity_groups.entry(day).or_default().push(p.clone());
1206            }
1207        }
1208
1209        trade_groups
1210            .into_iter()
1211            .map(|(day, group_trades)| {
1212                let equity_slice = equity_groups.remove(&day).unwrap_or_default();
1213                let initial_capital = equity_slice
1214                    .first()
1215                    .map(|p| p.equity)
1216                    .unwrap_or(self.initial_capital);
1217                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1218                // Infer the effective bars_per_year from the slice's calendar
1219                // span: same-weekday bars are ~5 trading days apart for a
1220                // daily-bar backtest, so the correct annualisation factor is
1221                // ≈52, not the configured 252.
1222                let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
1223                let metrics = PerformanceMetrics::calculate(
1224                    &trades_vec,
1225                    &equity_slice,
1226                    initial_capital,
1227                    0,
1228                    0,
1229                    self.config.risk_free_rate,
1230                    bpy,
1231                );
1232                let slice_len = equity_slice.len();
1233                (day, partial_period_adjust(metrics, slice_len, bpy))
1234            })
1235            .collect()
1236    }
1237
1238    /// Groups trades and equity-curve points by an arbitrary calendar key,
1239    /// then computes [`PerformanceMetrics`] for each group.
1240    ///
1241    /// `key_fn` maps a Unix-second timestamp to `Some(K)`, or `None` for
1242    /// timestamps that cannot be parsed (those entries are silently skipped).
1243    ///
1244    /// Both trades and equity-curve points are pre-grouped in **O(N + T)**
1245    /// passes before metrics are computed per period, avoiding the O(N × K)
1246    /// inner-loop cost of the naïve approach.
1247    fn temporal_metrics<K>(
1248        &self,
1249        key_fn: impl Fn(i64) -> Option<K>,
1250    ) -> HashMap<K, PerformanceMetrics>
1251    where
1252        K: std::hash::Hash + Eq + Copy,
1253    {
1254        // Pre-group trades by period key — O(T)
1255        let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
1256        for trade in &self.trades {
1257            if let Some(key) = key_fn(trade.exit_timestamp) {
1258                trade_groups.entry(key).or_default().push(trade);
1259            }
1260        }
1261
1262        // Pre-group equity curve by period key — O(N)
1263        let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
1264        for p in &self.equity_curve {
1265            if let Some(key) = key_fn(p.timestamp) {
1266                equity_groups.entry(key).or_default().push(p.clone());
1267            }
1268        }
1269
1270        trade_groups
1271            .into_iter()
1272            .map(|(key, group_trades)| {
1273                let equity_slice = equity_groups.remove(&key).unwrap_or_default();
1274                let initial_capital = equity_slice
1275                    .first()
1276                    .map(|p| p.equity)
1277                    .unwrap_or(self.initial_capital);
1278                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1279                let metrics = PerformanceMetrics::calculate(
1280                    &trades_vec,
1281                    &equity_slice,
1282                    initial_capital,
1283                    // H-3: both zero — signal records are not partitioned
1284                    // per period; callers should filter BacktestResult::signals
1285                    // directly if per-period signal counts are needed.
1286                    0,
1287                    0,
1288                    self.config.risk_free_rate,
1289                    self.config.bars_per_year,
1290                );
1291                let slice_len = equity_slice.len();
1292                // C-2: suppress annualised metrics for sub-half-year slices.
1293                (
1294                    key,
1295                    partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
1296                )
1297            })
1298            .collect()
1299    }
1300
1301    // ─── Phase 3 — Trade Tagging & Subgroup Analysis ─────────────────────────
1302
1303    /// Return all trades that carry the given tag.
1304    ///
1305    /// Tags are attached to a [`Signal`] at strategy time with `.tag("name")`
1306    /// and propagated to [`Trade::tags`] when the position closes.
1307    ///
1308    /// Tag comparison is **exact and case-sensitive**: `"Breakout"` and
1309    /// `"breakout"` are distinct tags.  Normalise tag strings at the call
1310    /// site if case-insensitive matching is required.
1311    ///
1312    /// Returns an empty `Vec` when no trades match or no trades have been
1313    /// tagged at all.
1314    pub fn trades_by_tag(&self, tag: &str) -> Vec<&Trade> {
1315        self.trades
1316            .iter()
1317            .filter(|t| t.tags.iter().any(|t2| t2 == tag))
1318            .collect()
1319    }
1320
1321    /// Compute `PerformanceMetrics` for the subset of trades that carry `tag`.
1322    ///
1323    /// A synthetic equity curve is built by replaying the tagged trades in
1324    /// sequence starting from `initial_capital`, which gives an accurate
1325    /// drawdown and return series for that trade subset.
1326    ///
1327    /// # Capital base
1328    ///
1329    /// All return metrics (`total_return_pct`, `annualized_return_pct`,
1330    /// `sharpe_ratio`, `calmar_ratio`) are computed relative to
1331    /// **`initial_capital`** — the full portfolio starting value — *not* the
1332    /// capital actually deployed into tagged trades.  A tag that fired 2
1333    /// small trades on a $10,000 portfolio will show a lower `total_return_pct`
1334    /// than a tag that deployed the same profit using more capital.
1335    ///
1336    /// For capital-independent comparisons across tags prefer:
1337    /// `profit_factor`, `win_rate`, `avg_win_pct`, `avg_loss_pct`.
1338    ///
1339    /// # Sharpe / Sortino annualisation
1340    ///
1341    /// `bars_per_year` is inferred from the calendar span of the synthetic
1342    /// equity curve (same technique as [`by_day_of_week`](Self::by_day_of_week))
1343    /// so that a sparsely-firing tag is not penalised by an inflated
1344    /// annualisation factor.
1345    ///
1346    /// Returns [`PerformanceMetrics::empty`] when no tagged trades exist.
1347    pub fn metrics_by_tag(&self, tag: &str) -> PerformanceMetrics {
1348        // Single pass: collect tagged trades and build synthetic equity curve.
1349        let mut equity = self.initial_capital;
1350        let mut peak = equity;
1351        let mut trades_vec: Vec<Trade> = Vec::new();
1352        let mut equity_curve: Vec<EquityPoint> = Vec::new();
1353
1354        for trade in &self.trades {
1355            if !trade.tags.iter().any(|t| t == tag) {
1356                continue;
1357            }
1358            if equity_curve.is_empty() {
1359                equity_curve.push(EquityPoint {
1360                    timestamp: trade.entry_timestamp,
1361                    equity,
1362                    drawdown_pct: 0.0,
1363                });
1364            }
1365            equity += trade.pnl;
1366            if equity > peak {
1367                peak = equity;
1368            }
1369            let drawdown_pct = if peak > 0.0 {
1370                (peak - equity) / peak
1371            } else {
1372                0.0
1373            };
1374            equity_curve.push(EquityPoint {
1375                timestamp: trade.exit_timestamp,
1376                equity,
1377                drawdown_pct,
1378            });
1379            trades_vec.push(trade.clone());
1380        }
1381
1382        if trades_vec.is_empty() {
1383            return PerformanceMetrics::empty(self.initial_capital, &[], 0, 0);
1384        }
1385
1386        // H-2: infer effective bars_per_year from the synthetic curve's
1387        // calendar span — avoids inflating Sharpe for sparsely-firing tags.
1388        let bpy = infer_bars_per_year(&equity_curve, self.config.bars_per_year);
1389        let metrics = PerformanceMetrics::calculate(
1390            &trades_vec,
1391            &equity_curve,
1392            self.initial_capital,
1393            0,
1394            0,
1395            self.config.risk_free_rate,
1396            bpy,
1397        );
1398        partial_period_adjust(metrics, equity_curve.len(), bpy)
1399    }
1400
1401    /// Return a sorted, deduplicated list of all tags used across all trades.
1402    ///
1403    /// Useful for discovering which tags are present in a result before
1404    /// calling `trades_by_tag` or `metrics_by_tag`.
1405    pub fn all_tags(&self) -> Vec<&str> {
1406        let mut tags: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1407        for trade in &self.trades {
1408            for tag in &trade.tags {
1409                tags.insert(tag.as_str());
1410            }
1411        }
1412        tags.into_iter().collect()
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use crate::backtesting::position::PositionSide;
1420    use crate::backtesting::signal::Signal;
1421
1422    fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
1423        Trade {
1424            side: if is_long {
1425                PositionSide::Long
1426            } else {
1427                PositionSide::Short
1428            },
1429            entry_timestamp: 0,
1430            exit_timestamp: 100,
1431            entry_price: 100.0,
1432            exit_price: 100.0 + pnl / 10.0,
1433            quantity: 10.0,
1434            entry_quantity: 10.0,
1435            commission: 0.0,
1436            transaction_tax: 0.0,
1437            pnl,
1438            return_pct,
1439            dividend_income: 0.0,
1440            unreinvested_dividends: 0.0,
1441            tags: Vec::new(),
1442            is_partial: false,
1443            scale_sequence: 0,
1444            entry_signal: Signal::long(0, 100.0),
1445            exit_signal: Signal::exit(100, 110.0),
1446        }
1447    }
1448
1449    #[test]
1450    fn test_metrics_no_trades() {
1451        let equity = vec![
1452            EquityPoint {
1453                timestamp: 0,
1454                equity: 10000.0,
1455                drawdown_pct: 0.0,
1456            },
1457            EquityPoint {
1458                timestamp: 1,
1459                equity: 10100.0,
1460                drawdown_pct: 0.0,
1461            },
1462        ];
1463
1464        let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
1465
1466        assert_eq!(metrics.total_trades, 0);
1467        assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
1468    }
1469
1470    #[test]
1471    fn test_metrics_with_trades() {
1472        let trades = vec![
1473            make_trade(100.0, 10.0, true), // Win
1474            make_trade(-50.0, -5.0, true), // Loss
1475            make_trade(75.0, 7.5, false),  // Win (short)
1476            make_trade(25.0, 2.5, true),   // Win
1477        ];
1478
1479        let equity = vec![
1480            EquityPoint {
1481                timestamp: 0,
1482                equity: 10000.0,
1483                drawdown_pct: 0.0,
1484            },
1485            EquityPoint {
1486                timestamp: 1,
1487                equity: 10100.0,
1488                drawdown_pct: 0.0,
1489            },
1490            EquityPoint {
1491                timestamp: 2,
1492                equity: 10050.0,
1493                drawdown_pct: 0.005,
1494            },
1495            EquityPoint {
1496                timestamp: 3,
1497                equity: 10125.0,
1498                drawdown_pct: 0.0,
1499            },
1500            EquityPoint {
1501                timestamp: 4,
1502                equity: 10150.0,
1503                drawdown_pct: 0.0,
1504            },
1505        ];
1506
1507        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
1508
1509        assert_eq!(metrics.total_trades, 4);
1510        assert_eq!(metrics.winning_trades, 3);
1511        assert_eq!(metrics.losing_trades, 1);
1512        assert!((metrics.win_rate - 0.75).abs() < 0.01);
1513        assert_eq!(metrics.long_trades, 3);
1514        assert_eq!(metrics.short_trades, 1);
1515    }
1516
1517    #[test]
1518    fn test_consecutive_wins_losses() {
1519        let trades = vec![
1520            make_trade(100.0, 10.0, true), // Win
1521            make_trade(50.0, 5.0, true),   // Win
1522            make_trade(25.0, 2.5, true),   // Win
1523            make_trade(-50.0, -5.0, true), // Loss
1524            make_trade(-25.0, -2.5, true), // Loss
1525            make_trade(100.0, 10.0, true), // Win
1526        ];
1527
1528        let (max_wins, max_losses) = calculate_consecutive(&trades);
1529        assert_eq!(max_wins, 3);
1530        assert_eq!(max_losses, 2);
1531    }
1532
1533    #[test]
1534    fn test_drawdown_duration() {
1535        let equity = vec![
1536            EquityPoint {
1537                timestamp: 0,
1538                equity: 100.0,
1539                drawdown_pct: 0.0,
1540            },
1541            EquityPoint {
1542                timestamp: 1,
1543                equity: 95.0,
1544                drawdown_pct: 0.05,
1545            },
1546            EquityPoint {
1547                timestamp: 2,
1548                equity: 90.0,
1549                drawdown_pct: 0.10,
1550            },
1551            EquityPoint {
1552                timestamp: 3,
1553                equity: 92.0,
1554                drawdown_pct: 0.08,
1555            },
1556            EquityPoint {
1557                timestamp: 4,
1558                equity: 100.0,
1559                drawdown_pct: 0.0,
1560            }, // Recovery
1561            EquityPoint {
1562                timestamp: 5,
1563                equity: 98.0,
1564                drawdown_pct: 0.02,
1565            },
1566        ];
1567
1568        let duration = calculate_max_drawdown_duration(&equity);
1569        assert_eq!(duration, 3); // 3 bars in drawdown (indices 1, 2, 3) before recovery at index 4
1570    }
1571
1572    #[test]
1573    fn test_sharpe_uses_sample_variance() {
1574        // Verify Sharpe uses n-1 (sample) not n (population) variance.
1575        // With returns = [0.01, -0.01, 0.02, -0.02] and rf=0:
1576        //   mean = 0.0
1577        //   sample variance = (0.01^2 + 0.01^2 + 0.02^2 + 0.02^2) / 3 = 0.001 / 3
1578        //   std_dev = sqrt(0.001/3) ≈ 0.018257
1579        //   Sharpe = (0.0 / 0.018257) * sqrt(252) = 0.0
1580        let returns = vec![0.01, -0.01, 0.02, -0.02];
1581        let (sharpe, _) = calculate_risk_ratios(&returns, 0.0, 252.0);
1582        // Mean is exactly 0 so Sharpe must be 0 regardless of std_dev
1583        assert!(
1584            (sharpe).abs() < 1e-10,
1585            "Sharpe of zero-mean returns should be 0, got {}",
1586            sharpe
1587        );
1588    }
1589
1590    #[test]
1591    fn test_max_drawdown_percentage_method() {
1592        // Verify the convenience method returns max_drawdown_pct * 100.
1593        // Use a trade so the no-trades early-return path is not taken, then
1594        // supply an equity curve with a known 10% drawdown point.
1595        let trade = make_trade(100.0, 10.0, true);
1596        let equity = vec![
1597            EquityPoint {
1598                timestamp: 0,
1599                equity: 10000.0,
1600                drawdown_pct: 0.0,
1601            },
1602            EquityPoint {
1603                timestamp: 1,
1604                equity: 9000.0,
1605                drawdown_pct: 0.1,
1606            },
1607            EquityPoint {
1608                timestamp: 2,
1609                equity: 10000.0,
1610                drawdown_pct: 0.0,
1611            },
1612        ];
1613        let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
1614        assert!(
1615            (metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
1616            "max_drawdown_pct should be 0.1 (fraction), got {}",
1617            metrics.max_drawdown_pct
1618        );
1619        assert!(
1620            (metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
1621            "max_drawdown_percentage() should be 10.0, got {}",
1622            metrics.max_drawdown_percentage()
1623        );
1624    }
1625
1626    #[test]
1627    fn test_kelly_criterion() {
1628        // W=0.6, avg_win=10%, avg_loss=5% => R=2.0 => Kelly=0.6 - 0.4/2 = 0.4
1629        let kelly = calculate_kelly(0.6, 10.0, -5.0);
1630        assert!(
1631            (kelly - 0.4).abs() < 1e-9,
1632            "Kelly should be 0.4, got {kelly}"
1633        );
1634
1635        // No losses with positive wins => f64::MAX (unbounded edge)
1636        assert_eq!(calculate_kelly(1.0, 10.0, 0.0), f64::MAX);
1637        // No losses, no wins => 0.0
1638        assert_eq!(calculate_kelly(0.0, 0.0, 0.0), 0.0);
1639
1640        // Negative edge: W=0.3, R=1.0 => Kelly=0.3-0.7=-0.4
1641        let kelly_neg = calculate_kelly(0.3, 5.0, -5.0);
1642        assert!(
1643            (kelly_neg - (-0.4)).abs() < 1e-9,
1644            "Kelly should be -0.4, got {kelly_neg}"
1645        );
1646    }
1647
1648    #[test]
1649    fn test_sqn() {
1650        // 10 trades all returning 1.0% -> std_dev=0 -> SQN=0
1651        let returns = vec![1.0; 10];
1652        assert_eq!(calculate_sqn(&returns), 0.0);
1653
1654        // Fewer than 2 trades -> 0
1655        assert_eq!(calculate_sqn(&[1.0]), 0.0);
1656        assert_eq!(calculate_sqn(&[]), 0.0);
1657
1658        // Known values: returns = [2, -1, 3, -1, 2], n=5
1659        // mean = 1.0, sample_std = sqrt(((1+4+4+4+1)/4)) = sqrt(14/4) = sqrt(3.5) ≈ 1.8708
1660        // SQN = (1.0 / 1.8708) * sqrt(5) ≈ 0.5345 * 2.2361 ≈ 1.1952
1661        let returns2 = vec![2.0, -1.0, 3.0, -1.0, 2.0];
1662        let sqn = calculate_sqn(&returns2);
1663        assert!(
1664            (sqn - 1.1952).abs() < 0.001,
1665            "SQN should be ~1.195, got {sqn}"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_omega_ratio() {
1671        // All positive: gains=6, losses=0 -> f64::MAX
1672        assert_eq!(calculate_omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
1673
1674        // All negative: gains=0, losses=6 -> 0.0
1675        assert_eq!(calculate_omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
1676
1677        // Mixed: [2, -1, 3, -2] -> gains=5, losses=3 -> omega=5/3
1678        let omega = calculate_omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
1679        assert!(
1680            (omega - 5.0 / 3.0).abs() < 1e-9,
1681            "Omega should be 5/3, got {omega}"
1682        );
1683    }
1684
1685    #[test]
1686    fn test_tail_ratio() {
1687        // Fewer than 2 -> 0
1688        assert_eq!(calculate_tail_ratio(&[1.0]), 0.0);
1689
1690        // 20 values: p5 at idx 1, p95 at idx 19
1691        // sorted: -10, -5, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 10
1692        let mut vals = vec![1.0f64; 16];
1693        vals.extend([-10.0, -5.0, 5.0, 10.0]);
1694        vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
1695        // n=20, p5_idx=floor(0.05*20)=1 -> sorted[1]=-5 -> abs=5
1696        //        p95_idx=floor(0.95*20)=19 -> sorted[19]=10 -> abs=10
1697        // tail_ratio = 10/5 = 2.0
1698        let tr = calculate_tail_ratio(&vals);
1699        assert!(
1700            (tr - 2.0).abs() < 1e-9,
1701            "Tail ratio should be 2.0, got {tr}"
1702        );
1703
1704        // p5 = 0 -> f64::MAX when p95 > 0
1705        let zeros_with_win = vec![
1706            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1707            0.0, 0.0, 5.0,
1708        ];
1709        assert_eq!(calculate_tail_ratio(&zeros_with_win), f64::MAX);
1710    }
1711
1712    #[test]
1713    fn test_ulcer_index() {
1714        // No drawdowns -> 0
1715        let flat = vec![
1716            EquityPoint {
1717                timestamp: 0,
1718                equity: 100.0,
1719                drawdown_pct: 0.0,
1720            },
1721            EquityPoint {
1722                timestamp: 1,
1723                equity: 110.0,
1724                drawdown_pct: 0.0,
1725            },
1726        ];
1727        assert_eq!(calculate_ulcer_index(&flat), 0.0);
1728
1729        // drawdown_pct fractions 0.1 and 0.2 → 10% and 20%
1730        // sqrt((10² + 20²) / 2) = sqrt(250) ≈ 15.811 (percentage units)
1731        let dd = vec![
1732            EquityPoint {
1733                timestamp: 0,
1734                equity: 100.0,
1735                drawdown_pct: 0.1,
1736            },
1737            EquityPoint {
1738                timestamp: 1,
1739                equity: 90.0,
1740                drawdown_pct: 0.2,
1741            },
1742        ];
1743        let ui = calculate_ulcer_index(&dd);
1744        let expected = ((100.0f64 + 400.0) / 2.0).sqrt(); // sqrt(250) ≈ 15.811
1745        assert!(
1746            (ui - expected).abs() < 1e-9,
1747            "Ulcer index should be {expected}, got {ui}"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_new_metrics_in_calculate() {
1753        // Mixed trades: 2 wins (+10%, +20%), 1 loss (-5%) with known equity curve
1754        let trades = vec![
1755            make_trade(100.0, 10.0, true),
1756            make_trade(200.0, 20.0, true),
1757            make_trade(-50.0, -5.0, true),
1758        ];
1759        let equity = vec![
1760            EquityPoint {
1761                timestamp: 0,
1762                equity: 10000.0,
1763                drawdown_pct: 0.0,
1764            },
1765            EquityPoint {
1766                timestamp: 1,
1767                equity: 10100.0,
1768                drawdown_pct: 0.0,
1769            },
1770            EquityPoint {
1771                timestamp: 2,
1772                equity: 10300.0,
1773                drawdown_pct: 0.0,
1774            },
1775            EquityPoint {
1776                timestamp: 3,
1777                equity: 10250.0,
1778                drawdown_pct: 0.005,
1779            },
1780        ];
1781        let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
1782
1783        // win_rate=2/3, avg_win=(10+20)/2=15, avg_loss=-5
1784        // Kelly = 2/3 - (1/3)/(15/5) = 0.6667 - 0.3333/3 = 0.6667 - 0.1111 ≈ 0.5556
1785        assert!(
1786            m.kelly_criterion > 0.0,
1787            "Kelly should be positive for profitable strategy"
1788        );
1789
1790        // SQN with 3 trades
1791        assert!(m.sqn.is_finite(), "SQN should be finite");
1792
1793        // Dollar expectancy: win_rate=2/3, avg_win=$100+$200)/2=$150, avg_loss=-$50
1794        // = (2/3)*150 + (1/3)*(-50) = 100 - 16.67 ≈ 83.33
1795        assert!(
1796            m.expectancy > 0.0,
1797            "Expectancy should be positive in dollar terms"
1798        );
1799
1800        // Omega ratio is computed on periodic equity curve returns, not
1801        // trade returns — just verify it is positive and finite.
1802        assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
1803
1804        // Ulcer index from equity curve (max_drawdown=0.5%)
1805        assert!(m.ulcer_index >= 0.0);
1806
1807        // Recovery factor: profitable with non-zero drawdown -> positive
1808        assert!(m.recovery_factor > 0.0);
1809    }
1810
1811    #[test]
1812    fn test_profit_factor_all_wins_is_f64_max() {
1813        let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
1814        let equity = vec![
1815            EquityPoint {
1816                timestamp: 0,
1817                equity: 10000.0,
1818                drawdown_pct: 0.0,
1819            },
1820            EquityPoint {
1821                timestamp: 1,
1822                equity: 10150.0,
1823                drawdown_pct: 0.0,
1824            },
1825        ];
1826
1827        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
1828        assert_eq!(metrics.profit_factor, f64::MAX);
1829    }
1830
1831    // ─── Phase 2 — Rolling & Temporal Analysis ───────────────────────────────
1832
1833    use super::super::config::BacktestConfig;
1834    use crate::backtesting::position::Position;
1835    use chrono::{NaiveDate, Weekday};
1836
1837    fn make_trade_timed(pnl: f64, return_pct: f64, entry_ts: i64, exit_ts: i64) -> Trade {
1838        Trade {
1839            side: PositionSide::Long,
1840            entry_timestamp: entry_ts,
1841            exit_timestamp: exit_ts,
1842            entry_price: 100.0,
1843            exit_price: 100.0 + pnl / 10.0,
1844            quantity: 10.0,
1845            entry_quantity: 10.0,
1846            commission: 0.0,
1847            transaction_tax: 0.0,
1848            pnl,
1849            return_pct,
1850            dividend_income: 0.0,
1851            unreinvested_dividends: 0.0,
1852            tags: Vec::new(),
1853            is_partial: false,
1854            scale_sequence: 0,
1855            entry_signal: Signal::long(entry_ts, 100.0),
1856            exit_signal: Signal::exit(exit_ts, 100.0 + pnl / 10.0),
1857        }
1858    }
1859
1860    /// Minimal `BacktestResult` fixture using the default `BacktestConfig`
1861    /// (risk_free_rate=0.0, bars_per_year=252.0).
1862    fn make_result(trades: Vec<Trade>, equity_curve: Vec<EquityPoint>) -> BacktestResult {
1863        let metrics = PerformanceMetrics::calculate(
1864            &trades,
1865            &equity_curve,
1866            10000.0,
1867            trades.len(),
1868            trades.len(),
1869            0.0,
1870            252.0,
1871        );
1872        BacktestResult {
1873            symbol: "TEST".to_string(),
1874            strategy_name: "TestStrategy".to_string(),
1875            config: BacktestConfig::default(),
1876            start_timestamp: equity_curve.first().map(|e| e.timestamp).unwrap_or(0),
1877            end_timestamp: equity_curve.last().map(|e| e.timestamp).unwrap_or(0),
1878            initial_capital: 10000.0,
1879            final_equity: equity_curve.last().map(|e| e.equity).unwrap_or(10000.0),
1880            metrics,
1881            trades,
1882            equity_curve,
1883            signals: vec![],
1884            open_position: None::<Position>,
1885            benchmark: None,
1886            diagnostics: vec![],
1887        }
1888    }
1889
1890    fn ts(date: &str) -> i64 {
1891        let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
1892        d.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp()
1893    }
1894
1895    fn equity_point(timestamp: i64, equity: f64, drawdown_pct: f64) -> EquityPoint {
1896        EquityPoint {
1897            timestamp,
1898            equity,
1899            drawdown_pct,
1900        }
1901    }
1902
1903    // ── rolling_sharpe ────────────────────────────────────────────────────────
1904
1905    #[test]
1906    fn rolling_sharpe_window_zero_returns_empty() {
1907        let result = make_result(
1908            vec![],
1909            vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
1910        );
1911        assert!(result.rolling_sharpe(0).is_empty());
1912    }
1913
1914    #[test]
1915    fn rolling_sharpe_insufficient_bars_returns_empty() {
1916        // 3 equity points → 2 returns; window=3 needs 3 returns → empty
1917        let result = make_result(
1918            vec![],
1919            vec![
1920                equity_point(0, 10000.0, 0.0),
1921                equity_point(1, 10100.0, 0.0),
1922                equity_point(2, 10200.0, 0.0),
1923            ],
1924        );
1925        assert!(result.rolling_sharpe(3).is_empty());
1926    }
1927
1928    #[test]
1929    fn rolling_sharpe_correct_length() {
1930        // 5 equity points → 4 returns; window=2 → 3 values
1931        let pts: Vec<EquityPoint> = (0..5)
1932            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1933            .collect();
1934        let result = make_result(vec![], pts);
1935        assert_eq!(result.rolling_sharpe(2).len(), 3);
1936    }
1937
1938    #[test]
1939    fn rolling_sharpe_monotone_increase_positive() {
1940        // Strictly increasing equity → all positive Sharpe values
1941        let pts: Vec<EquityPoint> = (0..10)
1942            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1943            .collect();
1944        let result = make_result(vec![], pts);
1945        let sharpes = result.rolling_sharpe(3);
1946        assert!(!sharpes.is_empty());
1947        for s in &sharpes {
1948            assert!(
1949                *s > 0.0 || *s == f64::MAX,
1950                "expected positive Sharpe, got {s}"
1951            );
1952        }
1953    }
1954
1955    // ── drawdown_series ───────────────────────────────────────────────────────
1956
1957    #[test]
1958    fn drawdown_series_mirrors_equity_curve() {
1959        let pts = vec![
1960            equity_point(0, 10000.0, 0.00),
1961            equity_point(1, 9500.0, 0.05),
1962            equity_point(2, 9000.0, 0.10),
1963            equity_point(3, 9200.0, 0.08),
1964            equity_point(4, 10000.0, 0.00),
1965        ];
1966        let result = make_result(vec![], pts.clone());
1967        let dd = result.drawdown_series();
1968        assert_eq!(dd.len(), pts.len());
1969        for (got, ep) in dd.iter().zip(pts.iter()) {
1970            assert!(
1971                (got - ep.drawdown_pct).abs() < f64::EPSILON,
1972                "expected {}, got {}",
1973                ep.drawdown_pct,
1974                got
1975            );
1976        }
1977    }
1978
1979    #[test]
1980    fn drawdown_series_empty_curve() {
1981        let result = make_result(vec![], vec![]);
1982        assert!(result.drawdown_series().is_empty());
1983    }
1984
1985    // ── rolling_win_rate ──────────────────────────────────────────────────────
1986
1987    #[test]
1988    fn rolling_win_rate_window_zero_returns_empty() {
1989        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1990        assert!(result.rolling_win_rate(0).is_empty());
1991    }
1992
1993    #[test]
1994    fn rolling_win_rate_window_exceeds_trades_returns_empty() {
1995        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1996        assert!(result.rolling_win_rate(2).is_empty());
1997    }
1998
1999    #[test]
2000    fn rolling_win_rate_all_wins() {
2001        let trades = vec![
2002            make_trade(10.0, 1.0, true),
2003            make_trade(20.0, 2.0, true),
2004            make_trade(15.0, 1.5, true),
2005        ];
2006        let result = make_result(trades, vec![]);
2007        let wr = result.rolling_win_rate(2);
2008        // 3 trades, window=2 → 2 values, each 1.0
2009        assert_eq!(wr, vec![1.0, 1.0]);
2010    }
2011
2012    #[test]
2013    fn rolling_win_rate_alternating() {
2014        // win, loss, win, loss → window=2 → [0.5, 0.5, 0.5]
2015        let trades = vec![
2016            make_trade(10.0, 1.0, true),
2017            make_trade(-10.0, -1.0, true),
2018            make_trade(10.0, 1.0, true),
2019            make_trade(-10.0, -1.0, true),
2020        ];
2021        let result = make_result(trades, vec![]);
2022        let wr = result.rolling_win_rate(2);
2023        assert_eq!(wr.len(), 3);
2024        for v in &wr {
2025            assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
2026        }
2027    }
2028
2029    #[test]
2030    fn rolling_win_rate_correct_length() {
2031        let trades: Vec<Trade> = (0..5)
2032            .map(|i| make_trade(i as f64, i as f64, true))
2033            .collect();
2034        let result = make_result(trades, vec![]);
2035        // 5 trades, window=3 → 3 values
2036        assert_eq!(result.rolling_win_rate(3).len(), 3);
2037    }
2038
2039    #[test]
2040    fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
2041        // L-2: boundary — window == trades.len() → exactly 1 element
2042        let trades = vec![
2043            make_trade(10.0, 1.0, true),
2044            make_trade(-5.0, -0.5, true),
2045            make_trade(8.0, 0.8, true),
2046        ];
2047        let result = make_result(trades, vec![]);
2048        let wr = result.rolling_win_rate(3);
2049        assert_eq!(wr.len(), 1);
2050        // 2 wins out of 3
2051        assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
2052    }
2053
2054    // ── partial_period_adjust ─────────────────────────────────────────────────
2055
2056    #[test]
2057    fn partial_period_adjust_zeroes_annualised_fields_for_short_slice() {
2058        // C-2: a 10-bar slice with bpy=252 → years ≈ 0.036 < 0.5 → zero out
2059        let dummy_metrics = PerformanceMetrics::calculate(
2060            &[make_trade(100.0, 10.0, true)],
2061            &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2062            10000.0,
2063            0,
2064            0,
2065            0.0,
2066            252.0,
2067        );
2068        assert!(dummy_metrics.annualized_return_pct != 0.0);
2069        let adjusted = partial_period_adjust(dummy_metrics, 10, 252.0);
2070        assert_eq!(adjusted.annualized_return_pct, 0.0);
2071        assert_eq!(adjusted.calmar_ratio, 0.0);
2072        assert_eq!(adjusted.serenity_ratio, 0.0);
2073    }
2074
2075    #[test]
2076    fn partial_period_adjust_preserves_full_year_metrics() {
2077        // A 252-bar slice with bpy=252 → years ≈ 1.0 ≥ 0.5 → no change
2078        let metrics = PerformanceMetrics::calculate(
2079            &[make_trade(100.0, 10.0, true)],
2080            &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2081            10000.0,
2082            0,
2083            0,
2084            0.0,
2085            252.0,
2086        );
2087        let ann_before = metrics.annualized_return_pct;
2088        let adjusted = partial_period_adjust(metrics, 252, 252.0);
2089        assert_eq!(adjusted.annualized_return_pct, ann_before);
2090    }
2091
2092    // ── by_year ───────────────────────────────────────────────────────────────
2093
2094    #[test]
2095    fn by_year_no_trades_empty() {
2096        let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
2097        assert!(result.by_year().is_empty());
2098    }
2099
2100    #[test]
2101    fn by_year_splits_across_years() {
2102        let eq = vec![
2103            equity_point(ts("2022-06-15"), 10000.0, 0.0),
2104            equity_point(ts("2022-06-16"), 10100.0, 0.0),
2105            equity_point(ts("2023-06-15"), 10200.0, 0.0),
2106            equity_point(ts("2023-06-16"), 10300.0, 0.0),
2107        ];
2108        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
2109        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
2110        let result = make_result(vec![t1, t2], eq);
2111        let by_year = result.by_year();
2112        assert_eq!(by_year.len(), 2);
2113        assert!(by_year.contains_key(&2022));
2114        assert!(by_year.contains_key(&2023));
2115        assert_eq!(by_year[&2022].total_trades, 1);
2116        assert_eq!(by_year[&2023].total_trades, 1);
2117    }
2118
2119    #[test]
2120    fn by_year_all_same_year() {
2121        let eq = vec![
2122            equity_point(ts("2023-03-01"), 10000.0, 0.0),
2123            equity_point(ts("2023-06-01"), 10200.0, 0.0),
2124            equity_point(ts("2023-09-01"), 10500.0, 0.0),
2125        ];
2126        let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
2127        let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
2128        let result = make_result(vec![t1, t2], eq);
2129        let by_year = result.by_year();
2130        assert_eq!(by_year.len(), 1);
2131        assert!(by_year.contains_key(&2023));
2132        assert_eq!(by_year[&2023].total_trades, 2);
2133    }
2134
2135    // ── by_month ──────────────────────────────────────────────────────────────
2136
2137    #[test]
2138    fn by_month_splits_across_months() {
2139        let eq = vec![
2140            equity_point(ts("2023-03-15"), 10000.0, 0.0),
2141            equity_point(ts("2023-03-16"), 10100.0, 0.0),
2142            equity_point(ts("2023-07-15"), 10200.0, 0.0),
2143            equity_point(ts("2023-07-16"), 10300.0, 0.0),
2144        ];
2145        let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
2146        let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
2147        let result = make_result(vec![t1, t2], eq);
2148        let by_month = result.by_month();
2149        assert_eq!(by_month.len(), 2);
2150        assert!(by_month.contains_key(&(2023, 3)));
2151        assert!(by_month.contains_key(&(2023, 7)));
2152    }
2153
2154    #[test]
2155    fn by_month_same_month_different_years_are_separate_keys() {
2156        let eq = vec![
2157            equity_point(ts("2022-06-15"), 10000.0, 0.0),
2158            equity_point(ts("2023-06-15"), 10200.0, 0.0),
2159        ];
2160        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
2161        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
2162        let result = make_result(vec![t1, t2], eq);
2163        let by_month = result.by_month();
2164        assert_eq!(by_month.len(), 2);
2165        assert!(by_month.contains_key(&(2022, 6)));
2166        assert!(by_month.contains_key(&(2023, 6)));
2167    }
2168
2169    // ── by_day_of_week ────────────────────────────────────────────────────────
2170
2171    #[test]
2172    fn by_day_of_week_single_day() {
2173        // 2023-01-02 is a Monday
2174        let monday = ts("2023-01-02");
2175        let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2176        let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
2177        let eq = vec![equity_point(monday, 10000.0, 0.0)];
2178        let result = make_result(vec![t1, t2], eq);
2179        let by_dow = result.by_day_of_week();
2180        assert_eq!(by_dow.len(), 1);
2181        assert!(by_dow.contains_key(&Weekday::Mon));
2182        assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
2183    }
2184
2185    #[test]
2186    fn by_day_of_week_multiple_days() {
2187        // 2023-01-02 = Monday, 2023-01-03 = Tuesday
2188        let monday = ts("2023-01-02");
2189        let tuesday = ts("2023-01-03");
2190        let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2191        let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
2192        let eq = vec![
2193            equity_point(monday, 10000.0, 0.0),
2194            equity_point(tuesday, 10100.0, 0.0),
2195        ];
2196        let result = make_result(vec![t_mon, t_tue], eq);
2197        let by_dow = result.by_day_of_week();
2198        assert_eq!(by_dow.len(), 2);
2199        assert!(by_dow.contains_key(&Weekday::Mon));
2200        assert!(by_dow.contains_key(&Weekday::Tue));
2201        assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
2202        assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
2203        assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
2204        assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
2205    }
2206
2207    #[test]
2208    fn by_day_of_week_no_trades_empty() {
2209        let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
2210        assert!(result.by_day_of_week().is_empty());
2211    }
2212
2213    #[test]
2214    fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
2215        // C-3: for a daily-bar backtest filtered to Mondays, the inferred
2216        // bars_per_year should be ≈52 (one per week), not the configured 252.
2217        // We verify this indirectly: Sharpe from by_day_of_week should differ
2218        // from a Sharpe computed with bpy=252 on the same Monday returns,
2219        // confirming that infer_bars_per_year adjusted the annualisation.
2220        //
2221        // Build 2 years of weekly Monday equity points (≈104 points).
2222        let base = ts("2023-01-02"); // Monday
2223        let week_secs = 7 * 86400i64;
2224        let n_weeks = 104usize;
2225        let equity_pts: Vec<EquityPoint> = (0..n_weeks)
2226            .map(|i| {
2227                equity_point(
2228                    base + (i as i64) * week_secs,
2229                    10000.0 + i as f64 * 10.0,
2230                    0.0,
2231                )
2232            })
2233            .collect();
2234
2235        let trade = make_trade_timed(
2236            100.0,
2237            1.0,
2238            base,
2239            base + week_secs, // exit on the second Monday
2240        );
2241        let result = make_result(vec![trade], equity_pts.clone());
2242        let by_dow = result.by_day_of_week();
2243
2244        // The inferred bpy from 103 weekly returns over ~2 years ≈ 52.
2245        // With bpy=252, Sharpe would be sqrt(252/52) ≈ 2.2× larger.
2246        // We only assert the result is finite and present — correctness of
2247        // the specific ratio is covered by infer_bars_per_year unit behaviour.
2248        assert!(by_dow.contains_key(&Weekday::Mon));
2249        let s = by_dow[&Weekday::Mon].sharpe_ratio;
2250        assert!(
2251            s.is_finite() || s == f64::MAX,
2252            "Sharpe should be finite, got {s}"
2253        );
2254    }
2255
2256    #[test]
2257    fn infer_bars_per_year_approximates_weekly_for_monday_subset() {
2258        // Direct unit test for infer_bars_per_year.
2259        // 104 weekly Monday points over ~2 calendar years → ≈ 52 bpy
2260        let base = ts("2023-01-02");
2261        let week_secs = 7 * 86400i64;
2262        let pts: Vec<EquityPoint> = (0..104)
2263            .map(|i| equity_point(base + i * week_secs, 10000.0, 0.0))
2264            .collect();
2265        let bpy = infer_bars_per_year(&pts, 252.0);
2266        // 103 return periods over ~2 years ≈ 51.5; accept 48–56 as reasonable
2267        assert!(bpy > 48.0 && bpy < 56.0, "expected ~52, got {bpy}");
2268    }
2269
2270    // ─── Phase 3 — Trade Tagging & Subgroup Analysis ─────────────────────────
2271
2272    /// Create a tagged trade by going through the real `Position::close` path
2273    /// so that tag propagation is exercised end-to-end in tests.
2274    fn make_tagged_trade(pnl: f64, tags: &[&str]) -> Trade {
2275        let entry_signal = tags
2276            .iter()
2277            .fold(Signal::long(0, 100.0), |sig, &t| sig.tag(t));
2278        // quantity=10, entry_price=100 → entry_value=1000
2279        // exit_price chosen so that pnl = (exit - 100) * 10
2280        let exit_price = 100.0 + pnl / 10.0;
2281        let exit_ts = 86400i64;
2282        let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, entry_signal);
2283        pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2284    }
2285
2286    /// Like `make_tagged_trade` but for a short position.
2287    fn make_tagged_short_trade(pnl: f64, tags: &[&str]) -> Trade {
2288        let entry_signal = tags
2289            .iter()
2290            .fold(Signal::short(0, 100.0), |sig, &t| sig.tag(t));
2291        // For a short: pnl = (entry - exit) * qty
2292        // entry_price=100, qty=10 → pnl = (100 - exit) * 10 → exit = 100 - pnl/10
2293        let exit_price = 100.0 - pnl / 10.0;
2294        let exit_ts = 86400i64;
2295        let pos = Position::new(PositionSide::Short, 0, 100.0, 10.0, 0.0, entry_signal);
2296        pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2297    }
2298
2299    // ── Signal::tag builder ───────────────────────────────────────────────────
2300
2301    #[test]
2302    fn signal_tag_builder_appends_tag() {
2303        let sig = Signal::long(0, 100.0).tag("breakout");
2304        assert_eq!(sig.tags, vec!["breakout"]);
2305    }
2306
2307    #[test]
2308    fn signal_tag_builder_chains_multiple_tags() {
2309        let sig = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2310        assert_eq!(sig.tags, vec!["breakout", "high_volume"]);
2311    }
2312
2313    #[test]
2314    fn signal_tag_builder_preserves_order() {
2315        let sig = Signal::long(0, 100.0).tag("a").tag("b").tag("c");
2316        assert_eq!(sig.tags, vec!["a", "b", "c"]);
2317    }
2318
2319    #[test]
2320    fn signal_constructors_start_with_empty_tags() {
2321        assert!(Signal::long(0, 0.0).tags.is_empty());
2322        assert!(Signal::short(0, 0.0).tags.is_empty());
2323        assert!(Signal::exit(0, 0.0).tags.is_empty());
2324        assert!(Signal::hold().tags.is_empty());
2325    }
2326
2327    // ── Tag propagation via Position::close ───────────────────────────────────
2328
2329    #[test]
2330    fn position_close_propagates_entry_signal_tags_to_trade() {
2331        let entry_signal = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2332        let pos = Position::new(
2333            crate::backtesting::position::PositionSide::Long,
2334            0,
2335            100.0,
2336            10.0,
2337            0.0,
2338            entry_signal,
2339        );
2340        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2341        assert_eq!(trade.tags, vec!["breakout", "high_volume"]);
2342    }
2343
2344    #[test]
2345    fn position_close_propagates_empty_tags_when_none_set() {
2346        let entry_signal = Signal::long(0, 100.0);
2347        let pos = Position::new(
2348            crate::backtesting::position::PositionSide::Long,
2349            0,
2350            100.0,
2351            10.0,
2352            0.0,
2353            entry_signal,
2354        );
2355        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2356        assert!(trade.tags.is_empty());
2357    }
2358
2359    // ── trades_by_tag ─────────────────────────────────────────────────────────
2360
2361    #[test]
2362    fn trades_by_tag_returns_matching_trades() {
2363        let result = make_result(
2364            vec![
2365                make_tagged_trade(100.0, &["breakout"]),
2366                make_tagged_trade(-50.0, &["reversal"]),
2367                make_tagged_trade(200.0, &["breakout", "high_volume"]),
2368            ],
2369            vec![equity_point(0, 10000.0, 0.0)],
2370        );
2371        let tagged = result.trades_by_tag("breakout");
2372        assert_eq!(tagged.len(), 2);
2373        assert!((tagged[0].pnl - 100.0).abs() < 1e-9);
2374        assert!((tagged[1].pnl - 200.0).abs() < 1e-9);
2375    }
2376
2377    #[test]
2378    fn trades_by_tag_returns_empty_for_missing_tag() {
2379        let result = make_result(
2380            vec![make_tagged_trade(100.0, &["breakout"])],
2381            vec![equity_point(0, 10000.0, 0.0)],
2382        );
2383        assert!(result.trades_by_tag("nonexistent").is_empty());
2384    }
2385
2386    #[test]
2387    fn trades_by_tag_returns_empty_when_no_trades_tagged() {
2388        let result = make_result(
2389            vec![make_trade(100.0, 10.0, true)],
2390            vec![equity_point(0, 10000.0, 0.0)],
2391        );
2392        assert!(result.trades_by_tag("breakout").is_empty());
2393    }
2394
2395    #[test]
2396    fn trades_by_tag_multi_tag_trade_matches_each_tag() {
2397        let result = make_result(
2398            vec![make_tagged_trade(100.0, &["a", "b", "c"])],
2399            vec![equity_point(0, 10000.0, 0.0)],
2400        );
2401        assert_eq!(result.trades_by_tag("a").len(), 1);
2402        assert_eq!(result.trades_by_tag("b").len(), 1);
2403        assert_eq!(result.trades_by_tag("c").len(), 1);
2404        assert_eq!(result.trades_by_tag("d").len(), 0);
2405    }
2406
2407    // ── all_tags ──────────────────────────────────────────────────────────────
2408
2409    #[test]
2410    fn all_tags_returns_sorted_deduped_tags() {
2411        let result = make_result(
2412            vec![
2413                make_tagged_trade(10.0, &["z_tag", "a_tag"]),
2414                make_tagged_trade(10.0, &["m_tag", "a_tag"]),
2415            ],
2416            vec![equity_point(0, 10000.0, 0.0)],
2417        );
2418        let tags = result.all_tags();
2419        assert_eq!(tags, vec!["a_tag", "m_tag", "z_tag"]);
2420    }
2421
2422    #[test]
2423    fn all_tags_returns_empty_when_no_tagged_trades() {
2424        let result = make_result(
2425            vec![make_trade(100.0, 10.0, true)],
2426            vec![equity_point(0, 10000.0, 0.0)],
2427        );
2428        assert!(result.all_tags().is_empty());
2429    }
2430
2431    #[test]
2432    fn all_tags_returns_empty_when_no_trades() {
2433        let result = make_result(vec![], vec![equity_point(0, 10000.0, 0.0)]);
2434        assert!(result.all_tags().is_empty());
2435    }
2436
2437    // ── metrics_by_tag ────────────────────────────────────────────────────────
2438
2439    #[test]
2440    fn metrics_by_tag_returns_empty_metrics_for_missing_tag() {
2441        let result = make_result(
2442            vec![make_tagged_trade(100.0, &["breakout"])],
2443            vec![equity_point(0, 10000.0, 0.0)],
2444        );
2445        let metrics = result.metrics_by_tag("nonexistent");
2446        assert_eq!(metrics.total_trades, 0);
2447        assert_eq!(metrics.win_rate, 0.0);
2448    }
2449
2450    #[test]
2451    fn metrics_by_tag_counts_only_tagged_trades() {
2452        let result = make_result(
2453            vec![
2454                make_tagged_trade(100.0, &["breakout"]),
2455                make_tagged_trade(200.0, &["breakout"]),
2456                make_tagged_trade(-50.0, &["reversal"]),
2457            ],
2458            vec![equity_point(0, 10000.0, 0.0)],
2459        );
2460        let metrics = result.metrics_by_tag("breakout");
2461        assert_eq!(metrics.total_trades, 2);
2462        assert_eq!(metrics.long_trades, 2);
2463    }
2464
2465    #[test]
2466    fn metrics_by_tag_win_rate_all_profitable() {
2467        let result = make_result(
2468            vec![
2469                make_tagged_trade(100.0, &["win"]),
2470                make_tagged_trade(200.0, &["win"]),
2471            ],
2472            vec![equity_point(0, 10000.0, 0.0)],
2473        );
2474        let metrics = result.metrics_by_tag("win");
2475        assert!(
2476            (metrics.win_rate - 1.0).abs() < 1e-9,
2477            "expected 100% win rate"
2478        );
2479    }
2480
2481    #[test]
2482    fn metrics_by_tag_win_rate_half_profitable() {
2483        let result = make_result(
2484            vec![
2485                make_tagged_trade(100.0, &["mixed"]),
2486                make_tagged_trade(-100.0, &["mixed"]),
2487            ],
2488            vec![equity_point(0, 10000.0, 0.0)],
2489        );
2490        let metrics = result.metrics_by_tag("mixed");
2491        assert!(
2492            (metrics.win_rate - 0.5).abs() < 1e-9,
2493            "expected 50% win rate, got {}",
2494            metrics.win_rate
2495        );
2496    }
2497
2498    #[test]
2499    fn metrics_by_tag_total_return_reflects_tagged_pnl() {
2500        // Two breakout trades: +$100, +$200 → total P&L $300 on $10,000 capital = 3%
2501        let result = make_result(
2502            vec![
2503                make_tagged_trade(100.0, &["breakout"]),
2504                make_tagged_trade(200.0, &["breakout"]),
2505                make_tagged_trade(-500.0, &["other"]),
2506            ],
2507            vec![equity_point(0, 10000.0, 0.0)],
2508        );
2509        let metrics = result.metrics_by_tag("breakout");
2510        // total_return_pct = (final_equity - initial) / initial * 100
2511        // = 300 / 10000 * 100 = 3.0%
2512        assert!(
2513            (metrics.total_return_pct - 3.0).abs() < 0.01,
2514            "expected 3%, got {}",
2515            metrics.total_return_pct
2516        );
2517    }
2518
2519    // L-3: mixed-side trades under the same tag
2520    #[test]
2521    fn metrics_by_tag_mixed_long_short_counts_correctly() {
2522        let long_trade = make_tagged_trade(100.0, &["strategy"]);
2523        let short_trade = make_tagged_short_trade(50.0, &["strategy"]);
2524        assert!(long_trade.is_long());
2525        assert!(short_trade.is_short());
2526
2527        let result = make_result(
2528            vec![long_trade, short_trade],
2529            vec![equity_point(0, 10000.0, 0.0)],
2530        );
2531        let metrics = result.metrics_by_tag("strategy");
2532        assert_eq!(metrics.total_trades, 2);
2533        assert_eq!(metrics.long_trades, 1);
2534        assert_eq!(metrics.short_trades, 1);
2535        assert!(
2536            (metrics.win_rate - 1.0).abs() < 1e-9,
2537            "both trades are profitable"
2538        );
2539    }
2540
2541    // L-4: duplicate tags on a single signal are stored as-is; all_tags deduplicates
2542    #[test]
2543    fn all_tags_deduplicates_within_single_trade() {
2544        let sig = Signal::long(0, 100.0).tag("dup").tag("dup");
2545        let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, sig);
2546        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2547        assert_eq!(trade.tags, vec!["dup", "dup"]); // raw tags preserved on Trade
2548        let result = make_result(vec![trade], vec![equity_point(0, 10000.0, 0.0)]);
2549        assert_eq!(result.all_tags(), vec!["dup"]); // all_tags deduplicates
2550    }
2551
2552    // L-2: case sensitivity documented behaviour
2553    #[test]
2554    fn trades_by_tag_is_case_sensitive() {
2555        let result = make_result(
2556            vec![make_tagged_trade(100.0, &["Breakout"])],
2557            vec![equity_point(0, 10000.0, 0.0)],
2558        );
2559        assert_eq!(result.trades_by_tag("Breakout").len(), 1);
2560        assert_eq!(result.trades_by_tag("breakout").len(), 0);
2561        assert_eq!(result.trades_by_tag("BREAKOUT").len(), 0);
2562    }
2563}