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 n = returns.len() as f64;
769
770    // Pass 1: mean of excess returns (no allocation)
771    let mean = returns.iter().map(|r| r - periodic_rf).sum::<f64>() / n;
772
773    // Pass 2: variance and downside sum in one loop (no allocation)
774    let (var_sum, downside_sq_sum) = returns.iter().fold((0.0_f64, 0.0_f64), |(v, d), &r| {
775        let e = r - periodic_rf;
776        let delta = e - mean;
777        (v + delta * delta, if e < 0.0 { d + e * e } else { d })
778    });
779
780    // Sharpe: sample variance (n-1) for unbiased estimation
781    let std_dev = (var_sum / (n - 1.0)).sqrt();
782    let sharpe = if std_dev > 0.0 {
783        (mean / std_dev) * bars_per_year.sqrt()
784    } else if mean > 0.0 {
785        f64::MAX
786    } else {
787        0.0
788    };
789
790    // Sortino: downside deviation (only negative excess; denominator is n-1,
791    // per Sortino's original definition and the `risk` module convention)
792    let downside_dev = (downside_sq_sum / (n - 1.0)).sqrt();
793    let sortino = if downside_dev > 0.0 {
794        (mean / downside_dev) * bars_per_year.sqrt()
795    } else if mean > 0.0 {
796        f64::MAX
797    } else {
798        0.0
799    };
800
801    (sharpe, sortino)
802}
803
804/// Calculate average duration (in seconds) for winning and losing trades separately.
805fn calculate_win_loss_durations(trades: &[Trade]) -> (f64, f64) {
806    let (win_sum, win_count, loss_sum, loss_count) =
807        trades
808            .iter()
809            .fold((0i64, 0usize, 0i64, 0usize), |(ws, wc, ls, lc), t| {
810                if t.is_profitable() {
811                    (ws + t.duration_secs(), wc + 1, ls, lc)
812                } else if t.is_loss() {
813                    (ws, wc, ls + t.duration_secs(), lc + 1)
814                } else {
815                    (ws, wc, ls, lc)
816                }
817            });
818
819    let avg_win = if win_count == 0 {
820        0.0
821    } else {
822        win_sum as f64 / win_count as f64
823    };
824    let avg_loss = if loss_count == 0 {
825        0.0
826    } else {
827        loss_sum as f64 / loss_count as f64
828    };
829
830    (avg_win, avg_loss)
831}
832
833/// Calculate fraction of backtest time spent in a position.
834///
835/// Uses the ratio of total trade duration to the total backtest duration
836/// derived from the equity curve timestamps.
837fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
838    let total_duration_secs: i64 = trades.iter().map(|t| t.duration_secs()).sum();
839
840    let backtest_secs = match (equity_curve.first(), equity_curve.last()) {
841        (Some(first), Some(last)) if last.timestamp > first.timestamp => {
842            last.timestamp - first.timestamp
843        }
844        _ => return 0.0,
845    };
846
847    (total_duration_secs as f64 / backtest_secs as f64).min(1.0)
848}
849
850/// Calculate the longest idle period (seconds) between consecutive trades.
851///
852/// Returns 0 if there are fewer than 2 trades.
853fn calculate_max_idle_period(trades: &[Trade]) -> i64 {
854    if trades.len() < 2 {
855        return 0;
856    }
857
858    // Trades are appended in chronological order; compute gaps between
859    // exit of trade N and entry of trade N+1.
860    trades
861        .windows(2)
862        .map(|w| (w[1].entry_timestamp - w[0].exit_timestamp).max(0))
863        .max()
864        .unwrap_or(0)
865}
866
867/// Infer the effective bars-per-year from the calendar span of an equity slice.
868///
869/// When an equity slice contains non-consecutive bars (e.g. every Monday in a
870/// daily-bar backtest), the configured `bars_per_year` is no longer the right
871/// annualisation denominator.  This function derives the correct value from
872/// the number of return periods and the elapsed calendar time so that Sharpe
873/// and Sortino ratios are annualised accurately regardless of bar frequency.
874///
875/// Falls back to `fallback_bpy` when the slice has fewer than two points or
876/// its timestamp span is non-positive.
877fn infer_bars_per_year(equity_slice: &[EquityPoint], fallback_bpy: f64) -> f64 {
878    if equity_slice.len() < 2 {
879        return fallback_bpy;
880    }
881    let first_ts = equity_slice.first().unwrap().timestamp as f64;
882    let last_ts = equity_slice.last().unwrap().timestamp as f64;
883    let seconds_per_year = 365.25 * 24.0 * 3600.0;
884    let years = (last_ts - first_ts) / seconds_per_year;
885    if years <= 0.0 {
886        return fallback_bpy;
887    }
888    // Use (len - 1) = number of return periods, consistent with how
889    // calculate_periodic_returns counts returns.
890    ((equity_slice.len() - 1) as f64 / years).max(1.0)
891}
892
893/// Zero out time-scaled ratios when a period slice covers less than half a
894/// year of bars.
895///
896/// Geometric annualisation of a sub-half-year return magnifies the result
897/// by raising `growth` to a power > 2, making `annualized_return_pct`,
898/// `calmar_ratio`, and `serenity_ratio` misleadingly large for short slices
899/// (e.g. partial first/last years, individual monthly buckets).  Setting
900/// them to `0.0` signals to callers that no reliable annual rate is available
901/// for this period without requiring a new return type.
902fn partial_period_adjust(
903    mut metrics: PerformanceMetrics,
904    slice_len: usize,
905    bpy: f64,
906) -> PerformanceMetrics {
907    let periods = slice_len.saturating_sub(1) as f64;
908    if periods / bpy < 0.5 {
909        metrics.annualized_return_pct = 0.0;
910        metrics.calmar_ratio = 0.0;
911        metrics.serenity_ratio = 0.0;
912    }
913    metrics
914}
915
916/// Convert a Unix-second timestamp to a `NaiveDateTime` (UTC).
917///
918/// Returns `None` for timestamps outside the range representable by
919/// [`DateTime<Utc>`] (i.e. before ≈ year −262144 or after ≈ year 262143).
920/// Call sites should skip entries that map to `None` rather than defaulting
921/// to the Unix epoch, which would silently misattribute those records to
922/// `1970-01-01 Thursday`.
923fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
924    DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
925}
926
927/// Comparison of strategy performance against a benchmark.
928///
929/// Populated when a benchmark symbol is supplied to `backtest_with_benchmark`.
930#[non_exhaustive]
931#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct BenchmarkMetrics {
933    /// Benchmark symbol (e.g. `"SPY"`)
934    pub symbol: String,
935
936    /// Buy-and-hold return of the benchmark over the same period (percentage)
937    pub benchmark_return_pct: f64,
938
939    /// Buy-and-hold return of the backtested symbol over the same period (percentage)
940    pub buy_and_hold_return_pct: f64,
941
942    /// Jensen's Alpha: annualised strategy excess return over the benchmark (CAPM).
943    ///
944    /// Computed as `strategy_ann - rf - β × (benchmark_ann - rf)` on the
945    /// timestamp-aligned subset of strategy and benchmark returns.
946    ///
947    /// # Accuracy Caveat
948    ///
949    /// Annualisation uses `aligned_bars / bars_per_year` to estimate elapsed
950    /// years.  If the strategy and benchmark candles have **different sampling
951    /// frequencies** (e.g., daily strategy vs. weekly benchmark), the aligned
952    /// subset contains far fewer bars than the full backtest period and the
953    /// per-year estimate will be wrong — both `strategy_ann` and `benchmark_ann`
954    /// are inflated by the same factor, but the risk-free rate is always the
955    /// true annual rate, making alpha unreliable.
956    ///
957    /// For accurate alpha, supply benchmark candles with the **same interval**
958    /// as the strategy candles.
959    pub alpha: f64,
960
961    /// Beta: sensitivity of strategy returns to benchmark movements
962    pub beta: f64,
963
964    /// Information ratio: excess return per unit of tracking error (annualised)
965    pub information_ratio: f64,
966}
967
968/// Complete backtest result
969#[non_exhaustive]
970#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct BacktestResult {
972    /// Symbol that was backtested
973    pub symbol: String,
974
975    /// Strategy name
976    pub strategy_name: String,
977
978    /// Configuration used
979    pub config: BacktestConfig,
980
981    /// Start timestamp
982    pub start_timestamp: i64,
983
984    /// End timestamp
985    pub end_timestamp: i64,
986
987    /// Initial capital
988    pub initial_capital: f64,
989
990    /// Final equity
991    pub final_equity: f64,
992
993    /// Performance metrics
994    pub metrics: PerformanceMetrics,
995
996    /// Complete trade log
997    pub trades: Vec<Trade>,
998
999    /// Equity curve (portfolio value at each bar)
1000    pub equity_curve: Vec<EquityPoint>,
1001
1002    /// All signals generated (including non-executed)
1003    pub signals: Vec<SignalRecord>,
1004
1005    /// Current open position (if any at end)
1006    pub open_position: Option<Position>,
1007
1008    /// Benchmark comparison metrics (set when a benchmark is provided)
1009    pub benchmark: Option<BenchmarkMetrics>,
1010
1011    /// Diagnostic messages (e.g. why zero trades were produced).
1012    ///
1013    /// Empty when the backtest ran without issues. Populated with actionable
1014    /// hints when the engine detects likely misconfiguration.
1015    #[serde(default)]
1016    pub diagnostics: Vec<String>,
1017}
1018
1019impl BacktestResult {
1020    /// Get a formatted summary string
1021    pub fn summary(&self) -> String {
1022        format!(
1023            "Backtest: {} on {}\n\
1024             Period: {} bars\n\
1025             Initial: ${:.2} -> Final: ${:.2}\n\
1026             Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
1027             Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
1028            self.strategy_name,
1029            self.symbol,
1030            self.equity_curve.len(),
1031            self.initial_capital,
1032            self.final_equity,
1033            self.metrics.total_return_pct,
1034            self.metrics.sharpe_ratio,
1035            self.metrics.max_drawdown_pct * 100.0,
1036            self.metrics.total_trades,
1037            self.metrics.win_rate * 100.0,
1038            self.metrics.profit_factor,
1039        )
1040    }
1041
1042    /// Check if the backtest was profitable
1043    pub fn is_profitable(&self) -> bool {
1044        self.final_equity > self.initial_capital
1045    }
1046
1047    /// Get total P&L
1048    pub fn total_pnl(&self) -> f64 {
1049        self.final_equity - self.initial_capital
1050    }
1051
1052    /// Get the number of bars in the backtest
1053    pub fn num_bars(&self) -> usize {
1054        self.equity_curve.len()
1055    }
1056
1057    // ─── Phase 2 — Rolling & Temporal Analysis ───────────────────────────────
1058
1059    /// Rolling Sharpe ratio over a sliding window of equity-curve bars.
1060    ///
1061    /// For each window of `window` consecutive bar-to-bar returns, computes
1062    /// the Sharpe ratio using the same `risk_free_rate` and `bars_per_year`
1063    /// as the overall backtest.  The first element corresponds to bars
1064    /// `0..window` of the equity curve.
1065    ///
1066    /// Returns an empty vector when `window == 0` or when the equity curve
1067    /// contains fewer than `window + 1` bars (i.e. fewer than `window`
1068    /// return periods).
1069    ///
1070    /// # Statistical reliability
1071    ///
1072    /// Sharpe and Sortino are computed from `window` return observations using
1073    /// sample variance (`n − 1` degrees of freedom).  Very small windows
1074    /// produce extreme and unreliable values — at least **30 bars** is a
1075    /// practical lower bound; **60–252** is typical for daily backtests.
1076    pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
1077        if window == 0 {
1078            return vec![];
1079        }
1080        let returns = calculate_periodic_returns(&self.equity_curve);
1081        if returns.len() < window {
1082            return vec![];
1083        }
1084        let rf = self.config.risk_free_rate;
1085        let bpy = self.config.bars_per_year;
1086        returns
1087            .windows(window)
1088            .map(|w| {
1089                let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
1090                sharpe
1091            })
1092            .collect()
1093    }
1094
1095    /// Running drawdown fraction at each bar of the equity curve (0.0–1.0).
1096    ///
1097    /// Each value is the fractional decline from the running all-time-high
1098    /// equity up to that bar: `0.0` means the equity is at a new peak; `0.2`
1099    /// means it is 20% below the highest value seen so far.
1100    ///
1101    /// **This is not a sliding-window computation.** Values are read directly
1102    /// from the precomputed [`EquityPoint::drawdown_pct`] field, which tracks
1103    /// the running-peak drawdown since the backtest began.  To compute the
1104    /// *maximum* drawdown within a rolling N-bar window (regime-change
1105    /// detection), iterate over [`BacktestResult::equity_curve`] manually.
1106    ///
1107    /// The returned vector has the same length as
1108    /// [`BacktestResult::equity_curve`].
1109    pub fn drawdown_series(&self) -> Vec<f64> {
1110        self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
1111    }
1112
1113    /// Rolling win rate over a sliding window of consecutive closed trades.
1114    ///
1115    /// For each window of `window` trades (ordered by exit timestamp as stored
1116    /// in the trade log), returns the fraction of winning trades in that
1117    /// window.  The first element corresponds to trades `0..window`.
1118    ///
1119    /// This is a **trade-count window**, not a time window.  To compute win
1120    /// rate over a fixed calendar period, use [`by_year`](Self::by_year),
1121    /// [`by_month`](Self::by_month), or filter [`BacktestResult::trades`]
1122    /// directly by timestamp.
1123    ///
1124    /// Returns an empty vector when `window == 0` or when fewer than `window`
1125    /// trades were closed.
1126    pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
1127        if window == 0 || self.trades.len() < window {
1128            return vec![];
1129        }
1130        self.trades
1131            .windows(window)
1132            .map(|w| {
1133                let wins = w.iter().filter(|t| t.is_profitable()).count();
1134                wins as f64 / window as f64
1135            })
1136            .collect()
1137    }
1138
1139    /// Performance metrics broken down by calendar year.
1140    ///
1141    /// Each trade is attributed to the year in which it **closed**
1142    /// (`exit_timestamp`).  The equity curve is sliced to the bars that fall
1143    /// within that calendar year, and the equity at the first bar of the year
1144    /// serves as `initial_capital` for the period metrics.
1145    ///
1146    /// Years with no closed trades are omitted from the result.
1147    ///
1148    /// # Caveats
1149    ///
1150    /// - **Open positions**: a position that is open throughout the year
1151    ///   contributes to the equity-curve drawdown and Sharpe of that year but
1152    ///   does **not** appear in `total_trades` or `win_rate`, because those
1153    ///   are derived from closed trades only.  Strategies with long holding
1154    ///   periods will show systematically low trade counts per year.
1155    /// - **Partial years**: the first and last year of a backtest typically
1156    ///   cover fewer than 12 months.  `annualized_return_pct`, `calmar_ratio`,
1157    ///   and `serenity_ratio` are set to `0.0` for slices shorter than half a
1158    ///   year (`< bars_per_year / 2` bars) to prevent geometric-compounding
1159    ///   distortion.
1160    /// - **`total_signals` / `executed_signals`**: these fields are `0` in
1161    ///   period breakdowns because signal records are not partitioned per
1162    ///   period.  Use [`BacktestResult::signals`] directly if needed.
1163    pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
1164        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
1165    }
1166
1167    /// Performance metrics broken down by calendar month.
1168    ///
1169    /// Each trade is attributed to the `(year, month)` in which it **closed**.
1170    /// Uses the same equity-slicing approach as [`by_year`](Self::by_year);
1171    /// the same caveats about open positions, partial periods, and signal
1172    /// counts apply here as well.
1173    pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
1174        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
1175    }
1176
1177    /// Performance metrics broken down by day of week.
1178    ///
1179    /// Each trade is attributed to the weekday on which it **closed**
1180    /// (`exit_timestamp`).  Only weekdays present in the trade log appear in
1181    /// the result.  Trades and equity-curve points with timestamps that cannot
1182    /// be converted to a valid date are silently skipped.
1183    ///
1184    /// # Sharpe / Sortino annualisation
1185    ///
1186    /// The equity curve is filtered to bars that fall on each specific
1187    /// weekday, so consecutive equity points in each slice are roughly one
1188    /// *week* apart (for a daily-bar backtest).  `bars_per_year` is inferred
1189    /// from the calendar span of each slice so that annualisation matches the
1190    /// actual sampling frequency — **you do not need to adjust the config**.
1191    /// The inferred value is approximately `52` for daily bars, `12` for
1192    /// weekly bars, and so on.
1193    ///
1194    /// # Other caveats
1195    ///
1196    /// The same open-position and signal-count caveats from
1197    /// [`by_year`](Self::by_year) apply here.
1198    pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
1199        // Pre-group trades by weekday — O(T)
1200        let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
1201        for trade in &self.trades {
1202            if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
1203            {
1204                trade_groups.entry(day).or_default().push(trade);
1205            }
1206        }
1207
1208        // Pre-group equity curve by weekday — O(N), avoids O(N × K) rescanning
1209        let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
1210        for p in &self.equity_curve {
1211            if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
1212                equity_groups.entry(day).or_default().push(p.clone());
1213            }
1214        }
1215
1216        trade_groups
1217            .into_iter()
1218            .map(|(day, group_trades)| {
1219                let equity_slice = equity_groups.remove(&day).unwrap_or_default();
1220                let initial_capital = equity_slice
1221                    .first()
1222                    .map(|p| p.equity)
1223                    .unwrap_or(self.initial_capital);
1224                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1225                // Infer the effective bars_per_year from the slice's calendar
1226                // span: same-weekday bars are ~5 trading days apart for a
1227                // daily-bar backtest, so the correct annualisation factor is
1228                // ≈52, not the configured 252.
1229                let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
1230                let metrics = PerformanceMetrics::calculate(
1231                    &trades_vec,
1232                    &equity_slice,
1233                    initial_capital,
1234                    0,
1235                    0,
1236                    self.config.risk_free_rate,
1237                    bpy,
1238                );
1239                let slice_len = equity_slice.len();
1240                (day, partial_period_adjust(metrics, slice_len, bpy))
1241            })
1242            .collect()
1243    }
1244
1245    /// Groups trades and equity-curve points by an arbitrary calendar key,
1246    /// then computes [`PerformanceMetrics`] for each group.
1247    ///
1248    /// `key_fn` maps a Unix-second timestamp to `Some(K)`, or `None` for
1249    /// timestamps that cannot be parsed (those entries are silently skipped).
1250    ///
1251    /// Both trades and equity-curve points are pre-grouped in **O(N + T)**
1252    /// passes before metrics are computed per period, avoiding the O(N × K)
1253    /// inner-loop cost of the naïve approach.
1254    fn temporal_metrics<K>(
1255        &self,
1256        key_fn: impl Fn(i64) -> Option<K>,
1257    ) -> HashMap<K, PerformanceMetrics>
1258    where
1259        K: std::hash::Hash + Eq + Copy,
1260    {
1261        // Pre-group trades by period key — O(T)
1262        let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
1263        for trade in &self.trades {
1264            if let Some(key) = key_fn(trade.exit_timestamp) {
1265                trade_groups.entry(key).or_default().push(trade);
1266            }
1267        }
1268
1269        // Pre-group equity curve by period key — O(N)
1270        let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
1271        for p in &self.equity_curve {
1272            if let Some(key) = key_fn(p.timestamp) {
1273                equity_groups.entry(key).or_default().push(p.clone());
1274            }
1275        }
1276
1277        trade_groups
1278            .into_iter()
1279            .map(|(key, group_trades)| {
1280                let equity_slice = equity_groups.remove(&key).unwrap_or_default();
1281                let initial_capital = equity_slice
1282                    .first()
1283                    .map(|p| p.equity)
1284                    .unwrap_or(self.initial_capital);
1285                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1286                let metrics = PerformanceMetrics::calculate(
1287                    &trades_vec,
1288                    &equity_slice,
1289                    initial_capital,
1290                    // H-3: both zero — signal records are not partitioned
1291                    // per period; callers should filter BacktestResult::signals
1292                    // directly if per-period signal counts are needed.
1293                    0,
1294                    0,
1295                    self.config.risk_free_rate,
1296                    self.config.bars_per_year,
1297                );
1298                let slice_len = equity_slice.len();
1299                // C-2: suppress annualised metrics for sub-half-year slices.
1300                (
1301                    key,
1302                    partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
1303                )
1304            })
1305            .collect()
1306    }
1307
1308    // ─── Phase 3 — Trade Tagging & Subgroup Analysis ─────────────────────────
1309
1310    /// Return all trades that carry the given tag.
1311    ///
1312    /// Tags are attached to a [`Signal`] at strategy time with `.tag("name")`
1313    /// and propagated to [`Trade::tags`] when the position closes.
1314    ///
1315    /// Tag comparison is **exact and case-sensitive**: `"Breakout"` and
1316    /// `"breakout"` are distinct tags.  Normalise tag strings at the call
1317    /// site if case-insensitive matching is required.
1318    ///
1319    /// Returns an empty `Vec` when no trades match or no trades have been
1320    /// tagged at all.
1321    pub fn trades_by_tag(&self, tag: &str) -> Vec<&Trade> {
1322        self.trades
1323            .iter()
1324            .filter(|t| t.tags.iter().any(|t2| t2 == tag))
1325            .collect()
1326    }
1327
1328    /// Compute `PerformanceMetrics` for the subset of trades that carry `tag`.
1329    ///
1330    /// A synthetic equity curve is built by replaying the tagged trades in
1331    /// sequence starting from `initial_capital`, which gives an accurate
1332    /// drawdown and return series for that trade subset.
1333    ///
1334    /// # Capital base
1335    ///
1336    /// All return metrics (`total_return_pct`, `annualized_return_pct`,
1337    /// `sharpe_ratio`, `calmar_ratio`) are computed relative to
1338    /// **`initial_capital`** — the full portfolio starting value — *not* the
1339    /// capital actually deployed into tagged trades.  A tag that fired 2
1340    /// small trades on a $10,000 portfolio will show a lower `total_return_pct`
1341    /// than a tag that deployed the same profit using more capital.
1342    ///
1343    /// For capital-independent comparisons across tags prefer:
1344    /// `profit_factor`, `win_rate`, `avg_win_pct`, `avg_loss_pct`.
1345    ///
1346    /// # Sharpe / Sortino annualisation
1347    ///
1348    /// `bars_per_year` is inferred from the calendar span of the synthetic
1349    /// equity curve (same technique as [`by_day_of_week`](Self::by_day_of_week))
1350    /// so that a sparsely-firing tag is not penalised by an inflated
1351    /// annualisation factor.
1352    ///
1353    /// Returns [`PerformanceMetrics::empty`] when no tagged trades exist.
1354    pub fn metrics_by_tag(&self, tag: &str) -> PerformanceMetrics {
1355        // Single pass: collect tagged trades and build synthetic equity curve.
1356        let mut equity = self.initial_capital;
1357        let mut peak = equity;
1358        let mut trades_vec: Vec<Trade> = Vec::new();
1359        let mut equity_curve: Vec<EquityPoint> = Vec::new();
1360
1361        for trade in &self.trades {
1362            if !trade.tags.iter().any(|t| t == tag) {
1363                continue;
1364            }
1365            if equity_curve.is_empty() {
1366                equity_curve.push(EquityPoint {
1367                    timestamp: trade.entry_timestamp,
1368                    equity,
1369                    drawdown_pct: 0.0,
1370                });
1371            }
1372            equity += trade.pnl;
1373            if equity > peak {
1374                peak = equity;
1375            }
1376            let drawdown_pct = if peak > 0.0 {
1377                (peak - equity) / peak
1378            } else {
1379                0.0
1380            };
1381            equity_curve.push(EquityPoint {
1382                timestamp: trade.exit_timestamp,
1383                equity,
1384                drawdown_pct,
1385            });
1386            trades_vec.push(trade.clone());
1387        }
1388
1389        if trades_vec.is_empty() {
1390            return PerformanceMetrics::empty(self.initial_capital, &[], 0, 0);
1391        }
1392
1393        // H-2: infer effective bars_per_year from the synthetic curve's
1394        // calendar span — avoids inflating Sharpe for sparsely-firing tags.
1395        let bpy = infer_bars_per_year(&equity_curve, self.config.bars_per_year);
1396        let metrics = PerformanceMetrics::calculate(
1397            &trades_vec,
1398            &equity_curve,
1399            self.initial_capital,
1400            0,
1401            0,
1402            self.config.risk_free_rate,
1403            bpy,
1404        );
1405        partial_period_adjust(metrics, equity_curve.len(), bpy)
1406    }
1407
1408    /// Return a sorted, deduplicated list of all tags used across all trades.
1409    ///
1410    /// Useful for discovering which tags are present in a result before
1411    /// calling `trades_by_tag` or `metrics_by_tag`.
1412    pub fn all_tags(&self) -> Vec<&str> {
1413        let mut tags: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1414        for trade in &self.trades {
1415            for tag in &trade.tags {
1416                tags.insert(tag.as_str());
1417            }
1418        }
1419        tags.into_iter().collect()
1420    }
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425    use super::*;
1426    use crate::backtesting::position::PositionSide;
1427    use crate::backtesting::signal::Signal;
1428
1429    fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
1430        Trade {
1431            side: if is_long {
1432                PositionSide::Long
1433            } else {
1434                PositionSide::Short
1435            },
1436            entry_timestamp: 0,
1437            exit_timestamp: 100,
1438            entry_price: 100.0,
1439            exit_price: 100.0 + pnl / 10.0,
1440            quantity: 10.0,
1441            entry_quantity: 10.0,
1442            commission: 0.0,
1443            transaction_tax: 0.0,
1444            pnl,
1445            return_pct,
1446            dividend_income: 0.0,
1447            unreinvested_dividends: 0.0,
1448            tags: Vec::new(),
1449            is_partial: false,
1450            scale_sequence: 0,
1451            entry_signal: Signal::long(0, 100.0),
1452            exit_signal: Signal::exit(100, 110.0),
1453        }
1454    }
1455
1456    #[test]
1457    fn test_metrics_no_trades() {
1458        let equity = vec![
1459            EquityPoint {
1460                timestamp: 0,
1461                equity: 10000.0,
1462                drawdown_pct: 0.0,
1463            },
1464            EquityPoint {
1465                timestamp: 1,
1466                equity: 10100.0,
1467                drawdown_pct: 0.0,
1468            },
1469        ];
1470
1471        let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
1472
1473        assert_eq!(metrics.total_trades, 0);
1474        assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
1475    }
1476
1477    #[test]
1478    fn test_metrics_with_trades() {
1479        let trades = vec![
1480            make_trade(100.0, 10.0, true), // Win
1481            make_trade(-50.0, -5.0, true), // Loss
1482            make_trade(75.0, 7.5, false),  // Win (short)
1483            make_trade(25.0, 2.5, true),   // Win
1484        ];
1485
1486        let equity = vec![
1487            EquityPoint {
1488                timestamp: 0,
1489                equity: 10000.0,
1490                drawdown_pct: 0.0,
1491            },
1492            EquityPoint {
1493                timestamp: 1,
1494                equity: 10100.0,
1495                drawdown_pct: 0.0,
1496            },
1497            EquityPoint {
1498                timestamp: 2,
1499                equity: 10050.0,
1500                drawdown_pct: 0.005,
1501            },
1502            EquityPoint {
1503                timestamp: 3,
1504                equity: 10125.0,
1505                drawdown_pct: 0.0,
1506            },
1507            EquityPoint {
1508                timestamp: 4,
1509                equity: 10150.0,
1510                drawdown_pct: 0.0,
1511            },
1512        ];
1513
1514        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
1515
1516        assert_eq!(metrics.total_trades, 4);
1517        assert_eq!(metrics.winning_trades, 3);
1518        assert_eq!(metrics.losing_trades, 1);
1519        assert!((metrics.win_rate - 0.75).abs() < 0.01);
1520        assert_eq!(metrics.long_trades, 3);
1521        assert_eq!(metrics.short_trades, 1);
1522    }
1523
1524    #[test]
1525    fn test_consecutive_wins_losses() {
1526        let trades = vec![
1527            make_trade(100.0, 10.0, true), // Win
1528            make_trade(50.0, 5.0, true),   // Win
1529            make_trade(25.0, 2.5, true),   // Win
1530            make_trade(-50.0, -5.0, true), // Loss
1531            make_trade(-25.0, -2.5, true), // Loss
1532            make_trade(100.0, 10.0, true), // Win
1533        ];
1534
1535        let (max_wins, max_losses) = calculate_consecutive(&trades);
1536        assert_eq!(max_wins, 3);
1537        assert_eq!(max_losses, 2);
1538    }
1539
1540    #[test]
1541    fn test_drawdown_duration() {
1542        let equity = vec![
1543            EquityPoint {
1544                timestamp: 0,
1545                equity: 100.0,
1546                drawdown_pct: 0.0,
1547            },
1548            EquityPoint {
1549                timestamp: 1,
1550                equity: 95.0,
1551                drawdown_pct: 0.05,
1552            },
1553            EquityPoint {
1554                timestamp: 2,
1555                equity: 90.0,
1556                drawdown_pct: 0.10,
1557            },
1558            EquityPoint {
1559                timestamp: 3,
1560                equity: 92.0,
1561                drawdown_pct: 0.08,
1562            },
1563            EquityPoint {
1564                timestamp: 4,
1565                equity: 100.0,
1566                drawdown_pct: 0.0,
1567            }, // Recovery
1568            EquityPoint {
1569                timestamp: 5,
1570                equity: 98.0,
1571                drawdown_pct: 0.02,
1572            },
1573        ];
1574
1575        let duration = calculate_max_drawdown_duration(&equity);
1576        assert_eq!(duration, 3); // 3 bars in drawdown (indices 1, 2, 3) before recovery at index 4
1577    }
1578
1579    #[test]
1580    fn test_sharpe_uses_sample_variance() {
1581        // Verify Sharpe uses n-1 (sample) not n (population) variance.
1582        // With returns = [0.01, -0.01, 0.02, -0.02] and rf=0:
1583        //   mean = 0.0
1584        //   sample variance = (0.01^2 + 0.01^2 + 0.02^2 + 0.02^2) / 3 = 0.001 / 3
1585        //   std_dev = sqrt(0.001/3) ≈ 0.018257
1586        //   Sharpe = (0.0 / 0.018257) * sqrt(252) = 0.0
1587        let returns = vec![0.01, -0.01, 0.02, -0.02];
1588        let (sharpe, _) = calculate_risk_ratios(&returns, 0.0, 252.0);
1589        // Mean is exactly 0 so Sharpe must be 0 regardless of std_dev
1590        assert!(
1591            (sharpe).abs() < 1e-10,
1592            "Sharpe of zero-mean returns should be 0, got {}",
1593            sharpe
1594        );
1595    }
1596
1597    #[test]
1598    fn test_max_drawdown_percentage_method() {
1599        // Verify the convenience method returns max_drawdown_pct * 100.
1600        // Use a trade so the no-trades early-return path is not taken, then
1601        // supply an equity curve with a known 10% drawdown point.
1602        let trade = make_trade(100.0, 10.0, true);
1603        let equity = vec![
1604            EquityPoint {
1605                timestamp: 0,
1606                equity: 10000.0,
1607                drawdown_pct: 0.0,
1608            },
1609            EquityPoint {
1610                timestamp: 1,
1611                equity: 9000.0,
1612                drawdown_pct: 0.1,
1613            },
1614            EquityPoint {
1615                timestamp: 2,
1616                equity: 10000.0,
1617                drawdown_pct: 0.0,
1618            },
1619        ];
1620        let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
1621        assert!(
1622            (metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
1623            "max_drawdown_pct should be 0.1 (fraction), got {}",
1624            metrics.max_drawdown_pct
1625        );
1626        assert!(
1627            (metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
1628            "max_drawdown_percentage() should be 10.0, got {}",
1629            metrics.max_drawdown_percentage()
1630        );
1631    }
1632
1633    #[test]
1634    fn test_kelly_criterion() {
1635        // W=0.6, avg_win=10%, avg_loss=5% => R=2.0 => Kelly=0.6 - 0.4/2 = 0.4
1636        let kelly = calculate_kelly(0.6, 10.0, -5.0);
1637        assert!(
1638            (kelly - 0.4).abs() < 1e-9,
1639            "Kelly should be 0.4, got {kelly}"
1640        );
1641
1642        // No losses with positive wins => f64::MAX (unbounded edge)
1643        assert_eq!(calculate_kelly(1.0, 10.0, 0.0), f64::MAX);
1644        // No losses, no wins => 0.0
1645        assert_eq!(calculate_kelly(0.0, 0.0, 0.0), 0.0);
1646
1647        // Negative edge: W=0.3, R=1.0 => Kelly=0.3-0.7=-0.4
1648        let kelly_neg = calculate_kelly(0.3, 5.0, -5.0);
1649        assert!(
1650            (kelly_neg - (-0.4)).abs() < 1e-9,
1651            "Kelly should be -0.4, got {kelly_neg}"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_sqn() {
1657        // 10 trades all returning 1.0% -> std_dev=0 -> SQN=0
1658        let returns = vec![1.0; 10];
1659        assert_eq!(calculate_sqn(&returns), 0.0);
1660
1661        // Fewer than 2 trades -> 0
1662        assert_eq!(calculate_sqn(&[1.0]), 0.0);
1663        assert_eq!(calculate_sqn(&[]), 0.0);
1664
1665        // Known values: returns = [2, -1, 3, -1, 2], n=5
1666        // mean = 1.0, sample_std = sqrt(((1+4+4+4+1)/4)) = sqrt(14/4) = sqrt(3.5) ≈ 1.8708
1667        // SQN = (1.0 / 1.8708) * sqrt(5) ≈ 0.5345 * 2.2361 ≈ 1.1952
1668        let returns2 = vec![2.0, -1.0, 3.0, -1.0, 2.0];
1669        let sqn = calculate_sqn(&returns2);
1670        assert!(
1671            (sqn - 1.1952).abs() < 0.001,
1672            "SQN should be ~1.195, got {sqn}"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_omega_ratio() {
1678        // All positive: gains=6, losses=0 -> f64::MAX
1679        assert_eq!(calculate_omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
1680
1681        // All negative: gains=0, losses=6 -> 0.0
1682        assert_eq!(calculate_omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
1683
1684        // Mixed: [2, -1, 3, -2] -> gains=5, losses=3 -> omega=5/3
1685        let omega = calculate_omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
1686        assert!(
1687            (omega - 5.0 / 3.0).abs() < 1e-9,
1688            "Omega should be 5/3, got {omega}"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_tail_ratio() {
1694        // Fewer than 2 -> 0
1695        assert_eq!(calculate_tail_ratio(&[1.0]), 0.0);
1696
1697        // 20 values: p5 at idx 1, p95 at idx 19
1698        // sorted: -10, -5, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 10
1699        let mut vals = vec![1.0f64; 16];
1700        vals.extend([-10.0, -5.0, 5.0, 10.0]);
1701        vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
1702        // n=20, p5_idx=floor(0.05*20)=1 -> sorted[1]=-5 -> abs=5
1703        //        p95_idx=floor(0.95*20)=19 -> sorted[19]=10 -> abs=10
1704        // tail_ratio = 10/5 = 2.0
1705        let tr = calculate_tail_ratio(&vals);
1706        assert!(
1707            (tr - 2.0).abs() < 1e-9,
1708            "Tail ratio should be 2.0, got {tr}"
1709        );
1710
1711        // p5 = 0 -> f64::MAX when p95 > 0
1712        let zeros_with_win = vec![
1713            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,
1714            0.0, 0.0, 5.0,
1715        ];
1716        assert_eq!(calculate_tail_ratio(&zeros_with_win), f64::MAX);
1717    }
1718
1719    #[test]
1720    fn test_ulcer_index() {
1721        // No drawdowns -> 0
1722        let flat = vec![
1723            EquityPoint {
1724                timestamp: 0,
1725                equity: 100.0,
1726                drawdown_pct: 0.0,
1727            },
1728            EquityPoint {
1729                timestamp: 1,
1730                equity: 110.0,
1731                drawdown_pct: 0.0,
1732            },
1733        ];
1734        assert_eq!(calculate_ulcer_index(&flat), 0.0);
1735
1736        // drawdown_pct fractions 0.1 and 0.2 → 10% and 20%
1737        // sqrt((10² + 20²) / 2) = sqrt(250) ≈ 15.811 (percentage units)
1738        let dd = vec![
1739            EquityPoint {
1740                timestamp: 0,
1741                equity: 100.0,
1742                drawdown_pct: 0.1,
1743            },
1744            EquityPoint {
1745                timestamp: 1,
1746                equity: 90.0,
1747                drawdown_pct: 0.2,
1748            },
1749        ];
1750        let ui = calculate_ulcer_index(&dd);
1751        let expected = ((100.0f64 + 400.0) / 2.0).sqrt(); // sqrt(250) ≈ 15.811
1752        assert!(
1753            (ui - expected).abs() < 1e-9,
1754            "Ulcer index should be {expected}, got {ui}"
1755        );
1756    }
1757
1758    #[test]
1759    fn test_new_metrics_in_calculate() {
1760        // Mixed trades: 2 wins (+10%, +20%), 1 loss (-5%) with known equity curve
1761        let trades = vec![
1762            make_trade(100.0, 10.0, true),
1763            make_trade(200.0, 20.0, true),
1764            make_trade(-50.0, -5.0, true),
1765        ];
1766        let equity = vec![
1767            EquityPoint {
1768                timestamp: 0,
1769                equity: 10000.0,
1770                drawdown_pct: 0.0,
1771            },
1772            EquityPoint {
1773                timestamp: 1,
1774                equity: 10100.0,
1775                drawdown_pct: 0.0,
1776            },
1777            EquityPoint {
1778                timestamp: 2,
1779                equity: 10300.0,
1780                drawdown_pct: 0.0,
1781            },
1782            EquityPoint {
1783                timestamp: 3,
1784                equity: 10250.0,
1785                drawdown_pct: 0.005,
1786            },
1787        ];
1788        let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
1789
1790        // win_rate=2/3, avg_win=(10+20)/2=15, avg_loss=-5
1791        // Kelly = 2/3 - (1/3)/(15/5) = 0.6667 - 0.3333/3 = 0.6667 - 0.1111 ≈ 0.5556
1792        assert!(
1793            m.kelly_criterion > 0.0,
1794            "Kelly should be positive for profitable strategy"
1795        );
1796
1797        // SQN with 3 trades
1798        assert!(m.sqn.is_finite(), "SQN should be finite");
1799
1800        // Dollar expectancy: win_rate=2/3, avg_win=$100+$200)/2=$150, avg_loss=-$50
1801        // = (2/3)*150 + (1/3)*(-50) = 100 - 16.67 ≈ 83.33
1802        assert!(
1803            m.expectancy > 0.0,
1804            "Expectancy should be positive in dollar terms"
1805        );
1806
1807        // Omega ratio is computed on periodic equity curve returns, not
1808        // trade returns — just verify it is positive and finite.
1809        assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
1810
1811        // Ulcer index from equity curve (max_drawdown=0.5%)
1812        assert!(m.ulcer_index >= 0.0);
1813
1814        // Recovery factor: profitable with non-zero drawdown -> positive
1815        assert!(m.recovery_factor > 0.0);
1816    }
1817
1818    #[test]
1819    fn test_profit_factor_all_wins_is_f64_max() {
1820        let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
1821        let equity = vec![
1822            EquityPoint {
1823                timestamp: 0,
1824                equity: 10000.0,
1825                drawdown_pct: 0.0,
1826            },
1827            EquityPoint {
1828                timestamp: 1,
1829                equity: 10150.0,
1830                drawdown_pct: 0.0,
1831            },
1832        ];
1833
1834        let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
1835        assert_eq!(metrics.profit_factor, f64::MAX);
1836    }
1837
1838    // ─── Phase 2 — Rolling & Temporal Analysis ───────────────────────────────
1839
1840    use super::super::config::BacktestConfig;
1841    use crate::backtesting::position::Position;
1842    use chrono::{NaiveDate, Weekday};
1843
1844    fn make_trade_timed(pnl: f64, return_pct: f64, entry_ts: i64, exit_ts: i64) -> Trade {
1845        Trade {
1846            side: PositionSide::Long,
1847            entry_timestamp: entry_ts,
1848            exit_timestamp: exit_ts,
1849            entry_price: 100.0,
1850            exit_price: 100.0 + pnl / 10.0,
1851            quantity: 10.0,
1852            entry_quantity: 10.0,
1853            commission: 0.0,
1854            transaction_tax: 0.0,
1855            pnl,
1856            return_pct,
1857            dividend_income: 0.0,
1858            unreinvested_dividends: 0.0,
1859            tags: Vec::new(),
1860            is_partial: false,
1861            scale_sequence: 0,
1862            entry_signal: Signal::long(entry_ts, 100.0),
1863            exit_signal: Signal::exit(exit_ts, 100.0 + pnl / 10.0),
1864        }
1865    }
1866
1867    /// Minimal `BacktestResult` fixture using the default `BacktestConfig`
1868    /// (risk_free_rate=0.0, bars_per_year=252.0).
1869    fn make_result(trades: Vec<Trade>, equity_curve: Vec<EquityPoint>) -> BacktestResult {
1870        let metrics = PerformanceMetrics::calculate(
1871            &trades,
1872            &equity_curve,
1873            10000.0,
1874            trades.len(),
1875            trades.len(),
1876            0.0,
1877            252.0,
1878        );
1879        BacktestResult {
1880            symbol: "TEST".to_string(),
1881            strategy_name: "TestStrategy".to_string(),
1882            config: BacktestConfig::default(),
1883            start_timestamp: equity_curve.first().map(|e| e.timestamp).unwrap_or(0),
1884            end_timestamp: equity_curve.last().map(|e| e.timestamp).unwrap_or(0),
1885            initial_capital: 10000.0,
1886            final_equity: equity_curve.last().map(|e| e.equity).unwrap_or(10000.0),
1887            metrics,
1888            trades,
1889            equity_curve,
1890            signals: vec![],
1891            open_position: None::<Position>,
1892            benchmark: None,
1893            diagnostics: vec![],
1894        }
1895    }
1896
1897    fn ts(date: &str) -> i64 {
1898        let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
1899        d.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp()
1900    }
1901
1902    fn equity_point(timestamp: i64, equity: f64, drawdown_pct: f64) -> EquityPoint {
1903        EquityPoint {
1904            timestamp,
1905            equity,
1906            drawdown_pct,
1907        }
1908    }
1909
1910    // ── rolling_sharpe ────────────────────────────────────────────────────────
1911
1912    #[test]
1913    fn rolling_sharpe_window_zero_returns_empty() {
1914        let result = make_result(
1915            vec![],
1916            vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
1917        );
1918        assert!(result.rolling_sharpe(0).is_empty());
1919    }
1920
1921    #[test]
1922    fn rolling_sharpe_insufficient_bars_returns_empty() {
1923        // 3 equity points → 2 returns; window=3 needs 3 returns → empty
1924        let result = make_result(
1925            vec![],
1926            vec![
1927                equity_point(0, 10000.0, 0.0),
1928                equity_point(1, 10100.0, 0.0),
1929                equity_point(2, 10200.0, 0.0),
1930            ],
1931        );
1932        assert!(result.rolling_sharpe(3).is_empty());
1933    }
1934
1935    #[test]
1936    fn rolling_sharpe_correct_length() {
1937        // 5 equity points → 4 returns; window=2 → 3 values
1938        let pts: Vec<EquityPoint> = (0..5)
1939            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1940            .collect();
1941        let result = make_result(vec![], pts);
1942        assert_eq!(result.rolling_sharpe(2).len(), 3);
1943    }
1944
1945    #[test]
1946    fn rolling_sharpe_monotone_increase_positive() {
1947        // Strictly increasing equity → all positive Sharpe values
1948        let pts: Vec<EquityPoint> = (0..10)
1949            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1950            .collect();
1951        let result = make_result(vec![], pts);
1952        let sharpes = result.rolling_sharpe(3);
1953        assert!(!sharpes.is_empty());
1954        for s in &sharpes {
1955            assert!(
1956                *s > 0.0 || *s == f64::MAX,
1957                "expected positive Sharpe, got {s}"
1958            );
1959        }
1960    }
1961
1962    // ── drawdown_series ───────────────────────────────────────────────────────
1963
1964    #[test]
1965    fn drawdown_series_mirrors_equity_curve() {
1966        let pts = vec![
1967            equity_point(0, 10000.0, 0.00),
1968            equity_point(1, 9500.0, 0.05),
1969            equity_point(2, 9000.0, 0.10),
1970            equity_point(3, 9200.0, 0.08),
1971            equity_point(4, 10000.0, 0.00),
1972        ];
1973        let result = make_result(vec![], pts.clone());
1974        let dd = result.drawdown_series();
1975        assert_eq!(dd.len(), pts.len());
1976        for (got, ep) in dd.iter().zip(pts.iter()) {
1977            assert!(
1978                (got - ep.drawdown_pct).abs() < f64::EPSILON,
1979                "expected {}, got {}",
1980                ep.drawdown_pct,
1981                got
1982            );
1983        }
1984    }
1985
1986    #[test]
1987    fn drawdown_series_empty_curve() {
1988        let result = make_result(vec![], vec![]);
1989        assert!(result.drawdown_series().is_empty());
1990    }
1991
1992    // ── rolling_win_rate ──────────────────────────────────────────────────────
1993
1994    #[test]
1995    fn rolling_win_rate_window_zero_returns_empty() {
1996        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1997        assert!(result.rolling_win_rate(0).is_empty());
1998    }
1999
2000    #[test]
2001    fn rolling_win_rate_window_exceeds_trades_returns_empty() {
2002        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
2003        assert!(result.rolling_win_rate(2).is_empty());
2004    }
2005
2006    #[test]
2007    fn rolling_win_rate_all_wins() {
2008        let trades = vec![
2009            make_trade(10.0, 1.0, true),
2010            make_trade(20.0, 2.0, true),
2011            make_trade(15.0, 1.5, true),
2012        ];
2013        let result = make_result(trades, vec![]);
2014        let wr = result.rolling_win_rate(2);
2015        // 3 trades, window=2 → 2 values, each 1.0
2016        assert_eq!(wr, vec![1.0, 1.0]);
2017    }
2018
2019    #[test]
2020    fn rolling_win_rate_alternating() {
2021        // win, loss, win, loss → window=2 → [0.5, 0.5, 0.5]
2022        let trades = vec![
2023            make_trade(10.0, 1.0, true),
2024            make_trade(-10.0, -1.0, true),
2025            make_trade(10.0, 1.0, true),
2026            make_trade(-10.0, -1.0, true),
2027        ];
2028        let result = make_result(trades, vec![]);
2029        let wr = result.rolling_win_rate(2);
2030        assert_eq!(wr.len(), 3);
2031        for v in &wr {
2032            assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
2033        }
2034    }
2035
2036    #[test]
2037    fn rolling_win_rate_correct_length() {
2038        let trades: Vec<Trade> = (0..5)
2039            .map(|i| make_trade(i as f64, i as f64, true))
2040            .collect();
2041        let result = make_result(trades, vec![]);
2042        // 5 trades, window=3 → 3 values
2043        assert_eq!(result.rolling_win_rate(3).len(), 3);
2044    }
2045
2046    #[test]
2047    fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
2048        // L-2: boundary — window == trades.len() → exactly 1 element
2049        let trades = vec![
2050            make_trade(10.0, 1.0, true),
2051            make_trade(-5.0, -0.5, true),
2052            make_trade(8.0, 0.8, true),
2053        ];
2054        let result = make_result(trades, vec![]);
2055        let wr = result.rolling_win_rate(3);
2056        assert_eq!(wr.len(), 1);
2057        // 2 wins out of 3
2058        assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
2059    }
2060
2061    // ── partial_period_adjust ─────────────────────────────────────────────────
2062
2063    #[test]
2064    fn partial_period_adjust_zeroes_annualised_fields_for_short_slice() {
2065        // C-2: a 10-bar slice with bpy=252 → years ≈ 0.036 < 0.5 → zero out
2066        let dummy_metrics = PerformanceMetrics::calculate(
2067            &[make_trade(100.0, 10.0, true)],
2068            &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2069            10000.0,
2070            0,
2071            0,
2072            0.0,
2073            252.0,
2074        );
2075        assert!(dummy_metrics.annualized_return_pct != 0.0);
2076        let adjusted = partial_period_adjust(dummy_metrics, 10, 252.0);
2077        assert_eq!(adjusted.annualized_return_pct, 0.0);
2078        assert_eq!(adjusted.calmar_ratio, 0.0);
2079        assert_eq!(adjusted.serenity_ratio, 0.0);
2080    }
2081
2082    #[test]
2083    fn partial_period_adjust_preserves_full_year_metrics() {
2084        // A 252-bar slice with bpy=252 → years ≈ 1.0 ≥ 0.5 → no change
2085        let metrics = PerformanceMetrics::calculate(
2086            &[make_trade(100.0, 10.0, true)],
2087            &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2088            10000.0,
2089            0,
2090            0,
2091            0.0,
2092            252.0,
2093        );
2094        let ann_before = metrics.annualized_return_pct;
2095        let adjusted = partial_period_adjust(metrics, 252, 252.0);
2096        assert_eq!(adjusted.annualized_return_pct, ann_before);
2097    }
2098
2099    // ── by_year ───────────────────────────────────────────────────────────────
2100
2101    #[test]
2102    fn by_year_no_trades_empty() {
2103        let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
2104        assert!(result.by_year().is_empty());
2105    }
2106
2107    #[test]
2108    fn by_year_splits_across_years() {
2109        let eq = vec![
2110            equity_point(ts("2022-06-15"), 10000.0, 0.0),
2111            equity_point(ts("2022-06-16"), 10100.0, 0.0),
2112            equity_point(ts("2023-06-15"), 10200.0, 0.0),
2113            equity_point(ts("2023-06-16"), 10300.0, 0.0),
2114        ];
2115        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
2116        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
2117        let result = make_result(vec![t1, t2], eq);
2118        let by_year = result.by_year();
2119        assert_eq!(by_year.len(), 2);
2120        assert!(by_year.contains_key(&2022));
2121        assert!(by_year.contains_key(&2023));
2122        assert_eq!(by_year[&2022].total_trades, 1);
2123        assert_eq!(by_year[&2023].total_trades, 1);
2124    }
2125
2126    #[test]
2127    fn by_year_all_same_year() {
2128        let eq = vec![
2129            equity_point(ts("2023-03-01"), 10000.0, 0.0),
2130            equity_point(ts("2023-06-01"), 10200.0, 0.0),
2131            equity_point(ts("2023-09-01"), 10500.0, 0.0),
2132        ];
2133        let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
2134        let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
2135        let result = make_result(vec![t1, t2], eq);
2136        let by_year = result.by_year();
2137        assert_eq!(by_year.len(), 1);
2138        assert!(by_year.contains_key(&2023));
2139        assert_eq!(by_year[&2023].total_trades, 2);
2140    }
2141
2142    // ── by_month ──────────────────────────────────────────────────────────────
2143
2144    #[test]
2145    fn by_month_splits_across_months() {
2146        let eq = vec![
2147            equity_point(ts("2023-03-15"), 10000.0, 0.0),
2148            equity_point(ts("2023-03-16"), 10100.0, 0.0),
2149            equity_point(ts("2023-07-15"), 10200.0, 0.0),
2150            equity_point(ts("2023-07-16"), 10300.0, 0.0),
2151        ];
2152        let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
2153        let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
2154        let result = make_result(vec![t1, t2], eq);
2155        let by_month = result.by_month();
2156        assert_eq!(by_month.len(), 2);
2157        assert!(by_month.contains_key(&(2023, 3)));
2158        assert!(by_month.contains_key(&(2023, 7)));
2159    }
2160
2161    #[test]
2162    fn by_month_same_month_different_years_are_separate_keys() {
2163        let eq = vec![
2164            equity_point(ts("2022-06-15"), 10000.0, 0.0),
2165            equity_point(ts("2023-06-15"), 10200.0, 0.0),
2166        ];
2167        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
2168        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
2169        let result = make_result(vec![t1, t2], eq);
2170        let by_month = result.by_month();
2171        assert_eq!(by_month.len(), 2);
2172        assert!(by_month.contains_key(&(2022, 6)));
2173        assert!(by_month.contains_key(&(2023, 6)));
2174    }
2175
2176    // ── by_day_of_week ────────────────────────────────────────────────────────
2177
2178    #[test]
2179    fn by_day_of_week_single_day() {
2180        // 2023-01-02 is a Monday
2181        let monday = ts("2023-01-02");
2182        let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2183        let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
2184        let eq = vec![equity_point(monday, 10000.0, 0.0)];
2185        let result = make_result(vec![t1, t2], eq);
2186        let by_dow = result.by_day_of_week();
2187        assert_eq!(by_dow.len(), 1);
2188        assert!(by_dow.contains_key(&Weekday::Mon));
2189        assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
2190    }
2191
2192    #[test]
2193    fn by_day_of_week_multiple_days() {
2194        // 2023-01-02 = Monday, 2023-01-03 = Tuesday
2195        let monday = ts("2023-01-02");
2196        let tuesday = ts("2023-01-03");
2197        let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2198        let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
2199        let eq = vec![
2200            equity_point(monday, 10000.0, 0.0),
2201            equity_point(tuesday, 10100.0, 0.0),
2202        ];
2203        let result = make_result(vec![t_mon, t_tue], eq);
2204        let by_dow = result.by_day_of_week();
2205        assert_eq!(by_dow.len(), 2);
2206        assert!(by_dow.contains_key(&Weekday::Mon));
2207        assert!(by_dow.contains_key(&Weekday::Tue));
2208        assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
2209        assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
2210        assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
2211        assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
2212    }
2213
2214    #[test]
2215    fn by_day_of_week_no_trades_empty() {
2216        let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
2217        assert!(result.by_day_of_week().is_empty());
2218    }
2219
2220    #[test]
2221    fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
2222        // C-3: for a daily-bar backtest filtered to Mondays, the inferred
2223        // bars_per_year should be ≈52 (one per week), not the configured 252.
2224        // We verify this indirectly: Sharpe from by_day_of_week should differ
2225        // from a Sharpe computed with bpy=252 on the same Monday returns,
2226        // confirming that infer_bars_per_year adjusted the annualisation.
2227        //
2228        // Build 2 years of weekly Monday equity points (≈104 points).
2229        let base = ts("2023-01-02"); // Monday
2230        let week_secs = 7 * 86400i64;
2231        let n_weeks = 104usize;
2232        let equity_pts: Vec<EquityPoint> = (0..n_weeks)
2233            .map(|i| {
2234                equity_point(
2235                    base + (i as i64) * week_secs,
2236                    10000.0 + i as f64 * 10.0,
2237                    0.0,
2238                )
2239            })
2240            .collect();
2241
2242        let trade = make_trade_timed(
2243            100.0,
2244            1.0,
2245            base,
2246            base + week_secs, // exit on the second Monday
2247        );
2248        let result = make_result(vec![trade], equity_pts.clone());
2249        let by_dow = result.by_day_of_week();
2250
2251        // The inferred bpy from 103 weekly returns over ~2 years ≈ 52.
2252        // With bpy=252, Sharpe would be sqrt(252/52) ≈ 2.2× larger.
2253        // We only assert the result is finite and present — correctness of
2254        // the specific ratio is covered by infer_bars_per_year unit behaviour.
2255        assert!(by_dow.contains_key(&Weekday::Mon));
2256        let s = by_dow[&Weekday::Mon].sharpe_ratio;
2257        assert!(
2258            s.is_finite() || s == f64::MAX,
2259            "Sharpe should be finite, got {s}"
2260        );
2261    }
2262
2263    #[test]
2264    fn infer_bars_per_year_approximates_weekly_for_monday_subset() {
2265        // Direct unit test for infer_bars_per_year.
2266        // 104 weekly Monday points over ~2 calendar years → ≈ 52 bpy
2267        let base = ts("2023-01-02");
2268        let week_secs = 7 * 86400i64;
2269        let pts: Vec<EquityPoint> = (0..104)
2270            .map(|i| equity_point(base + i * week_secs, 10000.0, 0.0))
2271            .collect();
2272        let bpy = infer_bars_per_year(&pts, 252.0);
2273        // 103 return periods over ~2 years ≈ 51.5; accept 48–56 as reasonable
2274        assert!(bpy > 48.0 && bpy < 56.0, "expected ~52, got {bpy}");
2275    }
2276
2277    // ─── Phase 3 — Trade Tagging & Subgroup Analysis ─────────────────────────
2278
2279    /// Create a tagged trade by going through the real `Position::close` path
2280    /// so that tag propagation is exercised end-to-end in tests.
2281    fn make_tagged_trade(pnl: f64, tags: &[&str]) -> Trade {
2282        let entry_signal = tags
2283            .iter()
2284            .fold(Signal::long(0, 100.0), |sig, &t| sig.tag(t));
2285        // quantity=10, entry_price=100 → entry_value=1000
2286        // exit_price chosen so that pnl = (exit - 100) * 10
2287        let exit_price = 100.0 + pnl / 10.0;
2288        let exit_ts = 86400i64;
2289        let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, entry_signal);
2290        pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2291    }
2292
2293    /// Like `make_tagged_trade` but for a short position.
2294    fn make_tagged_short_trade(pnl: f64, tags: &[&str]) -> Trade {
2295        let entry_signal = tags
2296            .iter()
2297            .fold(Signal::short(0, 100.0), |sig, &t| sig.tag(t));
2298        // For a short: pnl = (entry - exit) * qty
2299        // entry_price=100, qty=10 → pnl = (100 - exit) * 10 → exit = 100 - pnl/10
2300        let exit_price = 100.0 - pnl / 10.0;
2301        let exit_ts = 86400i64;
2302        let pos = Position::new(PositionSide::Short, 0, 100.0, 10.0, 0.0, entry_signal);
2303        pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2304    }
2305
2306    // ── Signal::tag builder ───────────────────────────────────────────────────
2307
2308    #[test]
2309    fn signal_tag_builder_appends_tag() {
2310        let sig = Signal::long(0, 100.0).tag("breakout");
2311        assert_eq!(sig.tags, vec!["breakout"]);
2312    }
2313
2314    #[test]
2315    fn signal_tag_builder_chains_multiple_tags() {
2316        let sig = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2317        assert_eq!(sig.tags, vec!["breakout", "high_volume"]);
2318    }
2319
2320    #[test]
2321    fn signal_tag_builder_preserves_order() {
2322        let sig = Signal::long(0, 100.0).tag("a").tag("b").tag("c");
2323        assert_eq!(sig.tags, vec!["a", "b", "c"]);
2324    }
2325
2326    #[test]
2327    fn signal_constructors_start_with_empty_tags() {
2328        assert!(Signal::long(0, 0.0).tags.is_empty());
2329        assert!(Signal::short(0, 0.0).tags.is_empty());
2330        assert!(Signal::exit(0, 0.0).tags.is_empty());
2331        assert!(Signal::hold().tags.is_empty());
2332    }
2333
2334    // ── Tag propagation via Position::close ───────────────────────────────────
2335
2336    #[test]
2337    fn position_close_propagates_entry_signal_tags_to_trade() {
2338        let entry_signal = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2339        let pos = Position::new(
2340            crate::backtesting::position::PositionSide::Long,
2341            0,
2342            100.0,
2343            10.0,
2344            0.0,
2345            entry_signal,
2346        );
2347        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2348        assert_eq!(trade.tags, vec!["breakout", "high_volume"]);
2349    }
2350
2351    #[test]
2352    fn position_close_propagates_empty_tags_when_none_set() {
2353        let entry_signal = Signal::long(0, 100.0);
2354        let pos = Position::new(
2355            crate::backtesting::position::PositionSide::Long,
2356            0,
2357            100.0,
2358            10.0,
2359            0.0,
2360            entry_signal,
2361        );
2362        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2363        assert!(trade.tags.is_empty());
2364    }
2365
2366    // ── trades_by_tag ─────────────────────────────────────────────────────────
2367
2368    #[test]
2369    fn trades_by_tag_returns_matching_trades() {
2370        let result = make_result(
2371            vec![
2372                make_tagged_trade(100.0, &["breakout"]),
2373                make_tagged_trade(-50.0, &["reversal"]),
2374                make_tagged_trade(200.0, &["breakout", "high_volume"]),
2375            ],
2376            vec![equity_point(0, 10000.0, 0.0)],
2377        );
2378        let tagged = result.trades_by_tag("breakout");
2379        assert_eq!(tagged.len(), 2);
2380        assert!((tagged[0].pnl - 100.0).abs() < 1e-9);
2381        assert!((tagged[1].pnl - 200.0).abs() < 1e-9);
2382    }
2383
2384    #[test]
2385    fn trades_by_tag_returns_empty_for_missing_tag() {
2386        let result = make_result(
2387            vec![make_tagged_trade(100.0, &["breakout"])],
2388            vec![equity_point(0, 10000.0, 0.0)],
2389        );
2390        assert!(result.trades_by_tag("nonexistent").is_empty());
2391    }
2392
2393    #[test]
2394    fn trades_by_tag_returns_empty_when_no_trades_tagged() {
2395        let result = make_result(
2396            vec![make_trade(100.0, 10.0, true)],
2397            vec![equity_point(0, 10000.0, 0.0)],
2398        );
2399        assert!(result.trades_by_tag("breakout").is_empty());
2400    }
2401
2402    #[test]
2403    fn trades_by_tag_multi_tag_trade_matches_each_tag() {
2404        let result = make_result(
2405            vec![make_tagged_trade(100.0, &["a", "b", "c"])],
2406            vec![equity_point(0, 10000.0, 0.0)],
2407        );
2408        assert_eq!(result.trades_by_tag("a").len(), 1);
2409        assert_eq!(result.trades_by_tag("b").len(), 1);
2410        assert_eq!(result.trades_by_tag("c").len(), 1);
2411        assert_eq!(result.trades_by_tag("d").len(), 0);
2412    }
2413
2414    // ── all_tags ──────────────────────────────────────────────────────────────
2415
2416    #[test]
2417    fn all_tags_returns_sorted_deduped_tags() {
2418        let result = make_result(
2419            vec![
2420                make_tagged_trade(10.0, &["z_tag", "a_tag"]),
2421                make_tagged_trade(10.0, &["m_tag", "a_tag"]),
2422            ],
2423            vec![equity_point(0, 10000.0, 0.0)],
2424        );
2425        let tags = result.all_tags();
2426        assert_eq!(tags, vec!["a_tag", "m_tag", "z_tag"]);
2427    }
2428
2429    #[test]
2430    fn all_tags_returns_empty_when_no_tagged_trades() {
2431        let result = make_result(
2432            vec![make_trade(100.0, 10.0, true)],
2433            vec![equity_point(0, 10000.0, 0.0)],
2434        );
2435        assert!(result.all_tags().is_empty());
2436    }
2437
2438    #[test]
2439    fn all_tags_returns_empty_when_no_trades() {
2440        let result = make_result(vec![], vec![equity_point(0, 10000.0, 0.0)]);
2441        assert!(result.all_tags().is_empty());
2442    }
2443
2444    // ── metrics_by_tag ────────────────────────────────────────────────────────
2445
2446    #[test]
2447    fn metrics_by_tag_returns_empty_metrics_for_missing_tag() {
2448        let result = make_result(
2449            vec![make_tagged_trade(100.0, &["breakout"])],
2450            vec![equity_point(0, 10000.0, 0.0)],
2451        );
2452        let metrics = result.metrics_by_tag("nonexistent");
2453        assert_eq!(metrics.total_trades, 0);
2454        assert_eq!(metrics.win_rate, 0.0);
2455    }
2456
2457    #[test]
2458    fn metrics_by_tag_counts_only_tagged_trades() {
2459        let result = make_result(
2460            vec![
2461                make_tagged_trade(100.0, &["breakout"]),
2462                make_tagged_trade(200.0, &["breakout"]),
2463                make_tagged_trade(-50.0, &["reversal"]),
2464            ],
2465            vec![equity_point(0, 10000.0, 0.0)],
2466        );
2467        let metrics = result.metrics_by_tag("breakout");
2468        assert_eq!(metrics.total_trades, 2);
2469        assert_eq!(metrics.long_trades, 2);
2470    }
2471
2472    #[test]
2473    fn metrics_by_tag_win_rate_all_profitable() {
2474        let result = make_result(
2475            vec![
2476                make_tagged_trade(100.0, &["win"]),
2477                make_tagged_trade(200.0, &["win"]),
2478            ],
2479            vec![equity_point(0, 10000.0, 0.0)],
2480        );
2481        let metrics = result.metrics_by_tag("win");
2482        assert!(
2483            (metrics.win_rate - 1.0).abs() < 1e-9,
2484            "expected 100% win rate"
2485        );
2486    }
2487
2488    #[test]
2489    fn metrics_by_tag_win_rate_half_profitable() {
2490        let result = make_result(
2491            vec![
2492                make_tagged_trade(100.0, &["mixed"]),
2493                make_tagged_trade(-100.0, &["mixed"]),
2494            ],
2495            vec![equity_point(0, 10000.0, 0.0)],
2496        );
2497        let metrics = result.metrics_by_tag("mixed");
2498        assert!(
2499            (metrics.win_rate - 0.5).abs() < 1e-9,
2500            "expected 50% win rate, got {}",
2501            metrics.win_rate
2502        );
2503    }
2504
2505    #[test]
2506    fn metrics_by_tag_total_return_reflects_tagged_pnl() {
2507        // Two breakout trades: +$100, +$200 → total P&L $300 on $10,000 capital = 3%
2508        let result = make_result(
2509            vec![
2510                make_tagged_trade(100.0, &["breakout"]),
2511                make_tagged_trade(200.0, &["breakout"]),
2512                make_tagged_trade(-500.0, &["other"]),
2513            ],
2514            vec![equity_point(0, 10000.0, 0.0)],
2515        );
2516        let metrics = result.metrics_by_tag("breakout");
2517        // total_return_pct = (final_equity - initial) / initial * 100
2518        // = 300 / 10000 * 100 = 3.0%
2519        assert!(
2520            (metrics.total_return_pct - 3.0).abs() < 0.01,
2521            "expected 3%, got {}",
2522            metrics.total_return_pct
2523        );
2524    }
2525
2526    // L-3: mixed-side trades under the same tag
2527    #[test]
2528    fn metrics_by_tag_mixed_long_short_counts_correctly() {
2529        let long_trade = make_tagged_trade(100.0, &["strategy"]);
2530        let short_trade = make_tagged_short_trade(50.0, &["strategy"]);
2531        assert!(long_trade.is_long());
2532        assert!(short_trade.is_short());
2533
2534        let result = make_result(
2535            vec![long_trade, short_trade],
2536            vec![equity_point(0, 10000.0, 0.0)],
2537        );
2538        let metrics = result.metrics_by_tag("strategy");
2539        assert_eq!(metrics.total_trades, 2);
2540        assert_eq!(metrics.long_trades, 1);
2541        assert_eq!(metrics.short_trades, 1);
2542        assert!(
2543            (metrics.win_rate - 1.0).abs() < 1e-9,
2544            "both trades are profitable"
2545        );
2546    }
2547
2548    // L-4: duplicate tags on a single signal are stored as-is; all_tags deduplicates
2549    #[test]
2550    fn all_tags_deduplicates_within_single_trade() {
2551        let sig = Signal::long(0, 100.0).tag("dup").tag("dup");
2552        let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, sig);
2553        let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2554        assert_eq!(trade.tags, vec!["dup", "dup"]); // raw tags preserved on Trade
2555        let result = make_result(vec![trade], vec![equity_point(0, 10000.0, 0.0)]);
2556        assert_eq!(result.all_tags(), vec!["dup"]); // all_tags deduplicates
2557    }
2558
2559    // L-2: case sensitivity documented behaviour
2560    #[test]
2561    fn trades_by_tag_is_case_sensitive() {
2562        let result = make_result(
2563            vec![make_tagged_trade(100.0, &["Breakout"])],
2564            vec![equity_point(0, 10000.0, 0.0)],
2565        );
2566        assert_eq!(result.trades_by_tag("Breakout").len(), 1);
2567        assert_eq!(result.trades_by_tag("breakout").len(), 0);
2568        assert_eq!(result.trades_by_tag("BREAKOUT").len(), 0);
2569    }
2570}