Skip to main content

qs_backtest/
report.rs

1//! Backtest reporting — trade log, aggregate statistics, and enhanced analytics.
2//!
3//! This module provides per-trade results, per-position summaries, and rich
4//! aggregate statistics including risk-adjusted metrics (Sharpe, Sortino, Calmar),
5//! streak analysis, duration stats, monthly returns, and breakdowns by symbol,
6//! group, side, and close reason.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9
10use chrono::NaiveDateTime;
11use serde::{Deserialize, Serialize};
12
13use qs_core::CostKind;
14use qs_core::types::{CloseReason, GroupId, PositionId, Side};
15
16use crate::artifacts::{
17    CloseEvent, CompletedPosition, CostEvent, ExecutionMetadata, FutureBacktestArtifacts,
18    NetPnlOutcome, OpenPositionSnapshot, PendingOrderLifecycleEvent, PendingOrderLifecycleState,
19    PendingOrderSnapshot, RecordedFill,
20};
21use crate::evaluation::{
22    EvaluationOptions, EvaluationReport, EvaluationRequest, ExcursionInput,
23    ExecutionDiagnosticsInput, LifecycleCounts, OutcomeClassification, PositionCostsInput,
24    PositionDimensions, PositionOutcome, PositionSide, evaluate,
25};
26use crate::ledger::{ActionDisposition, ActionDispositionStatus};
27use crate::mtm::MtmOutputSummary;
28use crate::portfolio::EquityPoint;
29
30// ─── Serde helper for f64 fields that may be INFINITY or NaN ────────────────
31
32/// Serializes non-finite f64 (INFINITY, NEG_INFINITY, NaN) as JSON null.
33mod finite_f64 {
34    use serde::{self, Deserialize, Deserializer, Serializer};
35
36    pub fn serialize<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
37    where
38        S: Serializer,
39    {
40        if value.is_finite() {
41            serializer.serialize_f64(*value)
42        } else {
43            serializer.serialize_none()
44        }
45    }
46
47    pub fn deserialize<'de, D>(deserializer: D) -> Result<f64, D::Error>
48    where
49        D: Deserializer<'de>,
50    {
51        // Accept either a number or null (null → 0.0).
52        let opt = Option::<f64>::deserialize(deserializer)?;
53        Ok(opt.unwrap_or(0.0))
54    }
55}
56
57// ─── TradeResult ────────────────────────────────────────────────────────────
58
59/// Result of a single closed trade (or partial close).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct TradeResult {
62    pub position_id: PositionId,
63    pub symbol: String,
64    pub side: Side,
65    pub entry_price: f64,
66    pub exit_price: f64,
67    pub size: f64,
68    /// Realized profit and loss for this close, already net of `commission` and `swap`.
69    pub pnl: f64,
70    /// Account-currency commission settled on this row and already subtracted from `pnl`.
71    ///
72    /// This close's own exit commission always settles here. The row that fully closes a position additionally settles that position's entry commission, because entry commission belongs to the position rather than to any one close. Summing this column over a position therefore reproduces [`CompletedPosition::commission_total`], while an individual row of a partially closed position carries only its own exit commission.
73    #[serde(default)]
74    pub commission: f64,
75    /// Account-currency swap settled on this row and already subtracted from `pnl`.
76    ///
77    /// Swap accrues to a position rather than to a close, so it settles entirely on the row that fully closes the position and is zero on every other row.
78    #[serde(default)]
79    pub swap: f64,
80    /// Profit and loss before `commission` and `swap`, present only when either was settled.
81    #[serde(default)]
82    pub gross_pnl: Option<f64>,
83    pub open_ts: NaiveDateTime,
84    pub close_ts: NaiveDateTime,
85    pub close_reason: CloseReason,
86    /// Group this position belonged to (for per-group reporting).
87    #[serde(default)]
88    pub group: Option<GroupId>,
89}
90
91// ─── SubsetStats ────────────────────────────────────────────────────────────
92
93/// Reusable statistics block computed from any subset of trades.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SubsetStats {
96    /// Number of trades in this subset.
97    pub total_trades: usize,
98    /// Trades with positive P&L.
99    pub winning_trades: usize,
100    /// Trades with negative P&L.
101    pub losing_trades: usize,
102    /// Trades with exactly zero P&L.
103    pub breakeven_trades: usize,
104    /// Sum of all P&L.
105    pub total_pnl: f64,
106    /// Sum of positive P&L.
107    pub gross_profit: f64,
108    /// Sum of absolute negative P&L.
109    pub gross_loss: f64,
110    /// winning / total (0.0 if no trades).
111    pub win_rate: f64,
112    /// gross_profit / gross_loss (INFINITY if no losers, 0.0 if no trades).
113    #[serde(with = "finite_f64")]
114    pub profit_factor: f64,
115    /// gross_profit / winning_trades (0.0 if no winners).
116    pub avg_win: f64,
117    /// gross_loss / losing_trades (0.0 if no losers).
118    pub avg_loss: f64,
119    /// avg_win / avg_loss (INFINITY if no losers, 0.0 if no winners).
120    #[serde(with = "finite_f64")]
121    pub win_loss_ratio: f64,
122    /// (win_rate * avg_win) - (loss_rate * avg_loss). Expected P&L per trade.
123    pub expectancy: f64,
124    /// Largest single winning trade P&L.
125    pub largest_win: f64,
126    /// Largest single losing trade (as positive number).
127    pub largest_loss: f64,
128    /// Commission settled on this subset's rows.
129    ///
130    /// A subset that contains every row of a position reports that position's complete commission, because the row that fully closes a position settles its entry commission. A subset that splits a position reports only the commission settled on the rows it kept.
131    #[serde(default)]
132    pub commission: f64,
133    /// Swap settled on this subset's rows, under the same subset rule as `commission`.
134    #[serde(default)]
135    pub swap: f64,
136    /// Subset profit and loss before `commission` and `swap`, present only when either was settled.
137    #[serde(default)]
138    pub gross_pnl: Option<f64>,
139}
140
141impl SubsetStats {
142    /// Compute statistics from a slice of trade references.
143    pub fn from_trades(trades: &[&TradeResult]) -> Self {
144        let total_trades = trades.len();
145        let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
146        let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
147        let breakeven_trades = trades.iter().filter(|t| t.pnl == 0.0).count();
148
149        let total_pnl: f64 = trades.iter().map(|t| t.pnl).sum();
150        let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
151        let gross_loss: f64 = trades
152            .iter()
153            .filter(|t| t.pnl < 0.0)
154            .map(|t| t.pnl.abs())
155            .sum();
156
157        let win_rate = if total_trades > 0 {
158            winning_trades as f64 / total_trades as f64
159        } else {
160            0.0
161        };
162
163        let profit_factor = if gross_loss > 0.0 {
164            gross_profit / gross_loss
165        } else if gross_profit > 0.0 {
166            f64::INFINITY
167        } else {
168            0.0
169        };
170
171        let avg_win = if winning_trades > 0 {
172            gross_profit / winning_trades as f64
173        } else {
174            0.0
175        };
176
177        let avg_loss = if losing_trades > 0 {
178            gross_loss / losing_trades as f64
179        } else {
180            0.0
181        };
182
183        let win_loss_ratio = if avg_loss > 0.0 {
184            avg_win / avg_loss
185        } else if avg_win > 0.0 {
186            f64::INFINITY
187        } else {
188            0.0
189        };
190
191        let loss_rate = if total_trades > 0 {
192            losing_trades as f64 / total_trades as f64
193        } else {
194            0.0
195        };
196        let expectancy = (win_rate * avg_win) - (loss_rate * avg_loss);
197
198        let largest_win = trades
199            .iter()
200            .filter(|t| t.pnl > 0.0)
201            .map(|t| t.pnl)
202            .fold(0.0_f64, f64::max);
203
204        let largest_loss = trades
205            .iter()
206            .filter(|t| t.pnl < 0.0)
207            .map(|t| t.pnl.abs())
208            .fold(0.0_f64, f64::max);
209
210        let commission: f64 = trades.iter().map(|t| t.commission).sum();
211        let swap: f64 = trades.iter().map(|t| t.swap).sum();
212        let gross_pnl = (commission != 0.0 || swap != 0.0).then_some(total_pnl + commission + swap);
213
214        Self {
215            total_trades,
216            winning_trades,
217            losing_trades,
218            breakeven_trades,
219            total_pnl,
220            gross_profit,
221            gross_loss,
222            win_rate,
223            profit_factor,
224            avg_win,
225            avg_loss,
226            win_loss_ratio,
227            expectancy,
228            largest_win,
229            largest_loss,
230            commission,
231            swap,
232            gross_pnl,
233        }
234    }
235
236    /// Compute statistics from an owned slice (convenience wrapper).
237    pub fn from_trade_slice(trades: &[TradeResult]) -> Self {
238        let refs: Vec<&TradeResult> = trades.iter().collect();
239        Self::from_trades(&refs)
240    }
241}
242
243// ─── StreakStats ─────────────────────────────────────────────────────────────
244
245/// Consecutive win/loss streak analysis.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct StreakStats {
248    /// Maximum consecutive winning trades.
249    pub max_consecutive_wins: u32,
250    /// Maximum consecutive losing trades.
251    pub max_consecutive_losses: u32,
252    /// Current streak (positive = wins, negative = losses, 0 = no trades or breakeven).
253    pub current_streak: i32,
254}
255
256impl StreakStats {
257    /// Compute streak statistics from a chronologically-ordered trade log.
258    pub fn from_trades(trades: &[&TradeResult]) -> Self {
259        let mut current_streak: i32 = 0;
260        let mut max_wins: u32 = 0;
261        let mut max_losses: u32 = 0;
262
263        for trade in trades {
264            if trade.pnl > 0.0 {
265                if current_streak > 0 {
266                    current_streak += 1;
267                } else {
268                    current_streak = 1;
269                }
270                max_wins = max_wins.max(current_streak as u32);
271            } else if trade.pnl < 0.0 {
272                if current_streak < 0 {
273                    current_streak -= 1;
274                } else {
275                    current_streak = -1;
276                }
277                max_losses = max_losses.max(current_streak.unsigned_abs());
278            } else {
279                // Breakeven resets streak.
280                current_streak = 0;
281            }
282        }
283
284        Self {
285            max_consecutive_wins: max_wins,
286            max_consecutive_losses: max_losses,
287            current_streak,
288        }
289    }
290
291    /// Compute FutureQuote streaks from completed campaigns in deterministic close order.
292    pub fn from_completed_positions(positions: &[CompletedPosition]) -> Self {
293        let mut ordered: Vec<&CompletedPosition> = positions.iter().collect();
294        ordered.sort_by(|left, right| {
295            left.close_ts
296                .cmp(&right.close_ts)
297                .then_with(|| left.position_id.cmp(&right.position_id))
298                .then_with(|| left.open_ts.cmp(&right.open_ts))
299        });
300
301        let mut current_streak: i32 = 0;
302        let mut max_wins: u32 = 0;
303        let mut max_losses: u32 = 0;
304        for position in ordered {
305            match position.outcome {
306                NetPnlOutcome::Win => {
307                    current_streak = if current_streak > 0 {
308                        current_streak + 1
309                    } else {
310                        1
311                    };
312                    max_wins = max_wins.max(current_streak as u32);
313                }
314                NetPnlOutcome::Loss => {
315                    current_streak = if current_streak < 0 {
316                        current_streak - 1
317                    } else {
318                        -1
319                    };
320                    max_losses = max_losses.max(current_streak.unsigned_abs());
321                }
322                NetPnlOutcome::Breakeven => current_streak = 0,
323            }
324        }
325
326        Self {
327            max_consecutive_wins: max_wins,
328            max_consecutive_losses: max_losses,
329            current_streak,
330        }
331    }
332}
333
334// ─── RiskMetrics ────────────────────────────────────────────────────────────
335
336/// Risk-adjusted return metrics.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct RiskMetrics {
339    /// Annualized Sharpe ratio. `None` if fewer than 2 trades.
340    pub sharpe_ratio: Option<f64>,
341    /// Annualized Sortino ratio (penalizes only downside). `None` if fewer than 2 trades or no downside.
342    pub sortino_ratio: Option<f64>,
343    /// Calmar ratio: annualized_return / max_drawdown_pct. `None` if max_drawdown is zero or duration < 1 day.
344    pub calmar_ratio: Option<f64>,
345    /// total_pnl / max_drawdown. `None` if max_drawdown is zero.
346    pub return_on_max_drawdown: Option<f64>,
347    /// Largest peak-to-trough drawdown in absolute terms.
348    pub max_drawdown: f64,
349    /// Largest peak-to-trough drawdown as percentage of peak.
350    pub max_drawdown_pct: f64,
351    /// Duration of the longest drawdown period (seconds).
352    pub max_drawdown_duration_secs: Option<i64>,
353}
354
355impl RiskMetrics {
356    /// Compute risk metrics from the trade log, equity curve, and drawdown values.
357    fn compute(
358        trade_log: &[TradeResult],
359        initial_balance: f64,
360        max_drawdown: f64,
361        max_drawdown_pct: f64,
362        equity_curve: &[(NaiveDateTime, f64)],
363        total_pnl: f64,
364    ) -> Self {
365        let return_on_max_drawdown = if max_drawdown > 0.0 {
366            Some(total_pnl / max_drawdown)
367        } else {
368            None
369        };
370
371        // Compute per-trade returns (relative to balance before the trade).
372        let mut balance = initial_balance;
373        let mut returns = Vec::with_capacity(trade_log.len());
374        for trade in trade_log {
375            let ret = if balance.abs() > f64::EPSILON {
376                trade.pnl / balance
377            } else {
378                0.0
379            };
380            returns.push(ret);
381            balance += trade.pnl;
382        }
383
384        let sharpe_ratio = compute_sharpe(&returns, trade_log);
385        let sortino_ratio = compute_sortino(&returns, trade_log);
386        let calmar_ratio = compute_calmar(trade_log, initial_balance, total_pnl, max_drawdown_pct);
387
388        // Max drawdown duration.
389        let max_drawdown_duration_secs = compute_max_dd_duration(equity_curve, initial_balance);
390
391        Self {
392            sharpe_ratio,
393            sortino_ratio,
394            calmar_ratio,
395            return_on_max_drawdown,
396            max_drawdown,
397            max_drawdown_pct,
398            max_drawdown_duration_secs,
399        }
400    }
401}
402
403/// Annualized Sharpe: mean(returns) / std(returns) * sqrt(trades_per_year).
404fn compute_sharpe(returns: &[f64], trade_log: &[TradeResult]) -> Option<f64> {
405    if returns.len() < 2 {
406        return None;
407    }
408    let n = returns.len() as f64;
409    let mean = returns.iter().sum::<f64>() / n;
410    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
411    let std_dev = variance.sqrt();
412    if std_dev < f64::EPSILON {
413        return None;
414    }
415    let trades_per_year = annualization_factor(trade_log)?;
416    Some((mean / std_dev) * trades_per_year.sqrt())
417}
418
419/// Annualized Sortino: mean(returns) / downside_dev * sqrt(trades_per_year).
420fn compute_sortino(returns: &[f64], trade_log: &[TradeResult]) -> Option<f64> {
421    if returns.len() < 2 {
422        return None;
423    }
424    let n = returns.len() as f64;
425    let mean = returns.iter().sum::<f64>() / n;
426    let downside_sq_sum: f64 = returns
427        .iter()
428        .filter(|&&r| r < 0.0)
429        .map(|r| r.powi(2))
430        .sum();
431    let downside_count = returns.iter().filter(|&&r| r < 0.0).count();
432    if downside_count == 0 {
433        return None; // No downside deviation — Sortino undefined.
434    }
435    let downside_dev = (downside_sq_sum / n).sqrt();
436    if downside_dev < f64::EPSILON {
437        return None;
438    }
439    let trades_per_year = annualization_factor(trade_log)?;
440    Some((mean / downside_dev) * trades_per_year.sqrt())
441}
442
443/// Calmar: annualized_return / max_drawdown_pct.
444fn compute_calmar(
445    trade_log: &[TradeResult],
446    initial_balance: f64,
447    total_pnl: f64,
448    max_drawdown_pct: f64,
449) -> Option<f64> {
450    if trade_log.len() < 2 || max_drawdown_pct < f64::EPSILON {
451        return None;
452    }
453    let first_ts = trade_log.first()?.open_ts;
454    let last_ts = trade_log.last()?.close_ts;
455    let duration = last_ts - first_ts;
456    let days = duration.num_seconds() as f64 / 86400.0;
457    if days < 1.0 {
458        return None;
459    }
460    let years = days / 365.25;
461    let annualized_return = (total_pnl / initial_balance) / years;
462    Some(annualized_return / max_drawdown_pct)
463}
464
465/// Estimate trades per year from the backtest span and trade count.
466fn annualization_factor(trade_log: &[TradeResult]) -> Option<f64> {
467    if trade_log.len() < 2 {
468        return None;
469    }
470    let first_ts = trade_log.first()?.open_ts;
471    let last_ts = trade_log.last()?.close_ts;
472    let duration = last_ts - first_ts;
473    let days = duration.num_seconds() as f64 / 86400.0;
474    if days < f64::EPSILON {
475        return None;
476    }
477    Some(trade_log.len() as f64 / (days / 365.25))
478}
479
480/// Compute max drawdown duration from the equity curve.
481fn compute_max_dd_duration(
482    equity_curve: &[(NaiveDateTime, f64)],
483    initial_balance: f64,
484) -> Option<i64> {
485    if equity_curve.is_empty() {
486        return None;
487    }
488
489    let mut peak = initial_balance;
490    let mut peak_ts = equity_curve[0].0;
491    let mut max_dd_dur_secs: i64 = 0;
492
493    for &(ts, bal) in equity_curve {
494        if bal >= peak {
495            // Recovered or new peak — measure duration of the drawdown that just ended.
496            let dur = (ts - peak_ts).num_seconds();
497            if dur > max_dd_dur_secs {
498                max_dd_dur_secs = dur;
499            }
500            peak = bal;
501            peak_ts = ts;
502        }
503    }
504
505    // Check unrecovered drawdown at end.
506    if let Some(&(last_ts, last_bal)) = equity_curve.last()
507        && last_bal < peak
508    {
509        let dur = (last_ts - peak_ts).num_seconds();
510        if dur > max_dd_dur_secs {
511            max_dd_dur_secs = dur;
512        }
513    }
514
515    if max_dd_dur_secs > 0 {
516        Some(max_dd_dur_secs)
517    } else {
518        None
519    }
520}
521
522// ─── DurationStats ──────────────────────────────────────────────────────────
523
524/// Trade or completed-campaign holding time statistics (all values in seconds).
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct DurationStats {
527    /// Average holding time across all trades (seconds).
528    pub avg_duration_secs: i64,
529    /// Shortest trade duration (seconds).
530    pub min_duration_secs: i64,
531    /// Longest trade duration (seconds).
532    pub max_duration_secs: i64,
533    /// Average holding time for winning trades (seconds).
534    pub avg_winner_duration_secs: i64,
535    /// Average holding time for losing trades (seconds).
536    pub avg_loser_duration_secs: i64,
537}
538
539impl DurationStats {
540    /// Compute duration statistics from trades. Returns `None` if no trades.
541    pub fn from_trades(trades: &[&TradeResult]) -> Option<Self> {
542        if trades.is_empty() {
543            return None;
544        }
545
546        let durations: Vec<i64> = trades
547            .iter()
548            .map(|t| (t.close_ts - t.open_ts).num_seconds())
549            .collect();
550
551        let total: i64 = durations.iter().sum();
552        let avg_duration_secs = total / durations.len() as i64;
553        let min_duration_secs = *durations.iter().min().unwrap();
554        let max_duration_secs = *durations.iter().max().unwrap();
555
556        let winner_durations: Vec<i64> = trades
557            .iter()
558            .filter(|t| t.pnl > 0.0)
559            .map(|t| (t.close_ts - t.open_ts).num_seconds())
560            .collect();
561        let avg_winner_duration_secs = if winner_durations.is_empty() {
562            0
563        } else {
564            winner_durations.iter().sum::<i64>() / winner_durations.len() as i64
565        };
566
567        let loser_durations: Vec<i64> = trades
568            .iter()
569            .filter(|t| t.pnl < 0.0)
570            .map(|t| (t.close_ts - t.open_ts).num_seconds())
571            .collect();
572        let avg_loser_duration_secs = if loser_durations.is_empty() {
573            0
574        } else {
575            loser_durations.iter().sum::<i64>() / loser_durations.len() as i64
576        };
577
578        Some(Self {
579            avg_duration_secs,
580            min_duration_secs,
581            max_duration_secs,
582            avg_winner_duration_secs,
583            avg_loser_duration_secs,
584        })
585    }
586
587    /// Compute FutureQuote holding times once per completed campaign.
588    pub fn from_completed_positions(positions: &[CompletedPosition]) -> Option<Self> {
589        if positions.is_empty() {
590            return None;
591        }
592
593        let duration =
594            |position: &CompletedPosition| (position.close_ts - position.open_ts).num_seconds();
595        let durations: Vec<i64> = positions.iter().map(duration).collect();
596        let winner_durations: Vec<i64> = positions
597            .iter()
598            .filter(|position| position.outcome == NetPnlOutcome::Win)
599            .map(duration)
600            .collect();
601        let loser_durations: Vec<i64> = positions
602            .iter()
603            .filter(|position| position.outcome == NetPnlOutcome::Loss)
604            .map(duration)
605            .collect();
606        let average = |values: &[i64]| {
607            if values.is_empty() {
608                0
609            } else {
610                values.iter().sum::<i64>() / values.len() as i64
611            }
612        };
613
614        Some(Self {
615            avg_duration_secs: average(&durations),
616            min_duration_secs: *durations
617                .iter()
618                .min()
619                .expect("completed positions are non-empty"),
620            max_duration_secs: *durations
621                .iter()
622                .max()
623                .expect("completed positions are non-empty"),
624            avg_winner_duration_secs: average(&winner_durations),
625            avg_loser_duration_secs: average(&loser_durations),
626        })
627    }
628}
629
630// ─── MonthlyReturn ──────────────────────────────────────────────────────────
631
632/// P&L summary for one calendar month.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct MonthlyReturn {
635    /// Year (e.g. 2026).
636    pub year: i32,
637    /// Month (1–12).
638    pub month: u32,
639    /// Sum of P&L for rows closed in this month.
640    pub pnl: f64,
641    /// Number of close events (Legacy) or completed campaigns (FutureQuote).
642    pub trade_count: usize,
643    /// Balance at end of month.
644    pub ending_balance: f64,
645}
646
647/// Compute monthly returns from a chronologically-ordered trade log.
648fn compute_monthly_returns(trade_log: &[TradeResult], initial_balance: f64) -> Vec<MonthlyReturn> {
649    if trade_log.is_empty() {
650        return Vec::new();
651    }
652
653    // Group by (year, month).
654    let mut groups: Vec<((i32, u32), Vec<&TradeResult>)> = Vec::new();
655    for trade in trade_log {
656        let key = (trade.close_ts.date().year(), trade.close_ts.date().month());
657        if let Some(last) = groups.last_mut()
658            && last.0 == key
659        {
660            last.1.push(trade);
661            continue;
662        }
663        groups.push((key, vec![trade]));
664    }
665
666    let mut balance = initial_balance;
667    groups
668        .into_iter()
669        .map(|((year, month), trades)| {
670            let pnl: f64 = trades.iter().map(|t| t.pnl).sum();
671            let trade_count = trades.len();
672            balance += pnl;
673            MonthlyReturn {
674                year,
675                month,
676                pnl,
677                trade_count,
678                ending_balance: balance,
679            }
680        })
681        .collect()
682}
683
684fn compute_monthly_returns_from_completed(
685    positions: &[CompletedPosition],
686    initial_balance: f64,
687) -> Vec<MonthlyReturn> {
688    let mut ordered: Vec<&CompletedPosition> = positions.iter().collect();
689    ordered.sort_by(|left, right| {
690        left.close_ts
691            .cmp(&right.close_ts)
692            .then_with(|| left.position_id.cmp(&right.position_id))
693            .then_with(|| left.net_pnl.total_cmp(&right.net_pnl))
694    });
695
696    let mut groups: BTreeMap<(i32, u32), (f64, usize)> = BTreeMap::new();
697    for position in ordered {
698        let key = (
699            position.close_ts.date().year(),
700            position.close_ts.date().month(),
701        );
702        let (pnl, count) = groups.entry(key).or_default();
703        *pnl += position.net_pnl;
704        *count += 1;
705    }
706
707    let mut balance = initial_balance;
708    groups
709        .into_iter()
710        .map(|((year, month), (pnl, trade_count))| {
711            balance += pnl;
712            MonthlyReturn {
713                year,
714                month,
715                pnl,
716                trade_count,
717                ending_balance: balance,
718            }
719        })
720        .collect()
721}
722
723// We need chrono's Datelike for year()/month().
724use chrono::Datelike;
725
726// ─── PositionSummary ────────────────────────────────────────────────────────
727
728/// Aggregated result for one position across all its close events.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct PositionSummary {
731    pub position_id: PositionId,
732    pub symbol: String,
733    pub side: Side,
734    pub group: Option<GroupId>,
735    /// Campaign entry basis, size-weighted across the basis recorded at each close event.
736    /// This preserves a shared basis while reflecting average-cost changes after scale-ins.
737    pub entry_price: f64,
738    /// Size-weighted average exit price across all closes.
739    pub avg_exit_price: f64,
740    /// Total size across all close events.
741    pub original_size: f64,
742    /// Number of close events (partial + final).
743    pub close_count: usize,
744    /// Net P&L across all close events.
745    pub net_pnl: f64,
746    /// Ordered list of close reasons.
747    pub close_reasons: Vec<CloseReason>,
748    /// When the position was opened.
749    pub open_ts: NaiveDateTime,
750    /// When the last close event occurred.
751    pub final_close_ts: NaiveDateTime,
752    /// Total holding duration in seconds.
753    pub duration_seconds: i64,
754}
755
756impl PositionSummary {
757    /// Build a summary from all trade results for one position.
758    pub fn from_trades(trades: &[&TradeResult]) -> Self {
759        assert!(
760            !trades.is_empty(),
761            "PositionSummary requires at least one trade"
762        );
763
764        let first = trades[0];
765        let net_pnl: f64 = trades.iter().map(|t| t.pnl).sum();
766        let original_size: f64 = trades.iter().map(|t| t.size).sum();
767
768        let entry_price = if original_size > 0.0 {
769            trades.iter().map(|t| t.entry_price * t.size).sum::<f64>() / original_size
770        } else {
771            first.entry_price
772        };
773        let avg_exit_price = if original_size > 0.0 {
774            trades.iter().map(|t| t.exit_price * t.size).sum::<f64>() / original_size
775        } else {
776            0.0
777        };
778
779        let final_close_ts = trades.iter().map(|t| t.close_ts).max().unwrap();
780        let close_reasons: Vec<CloseReason> = trades.iter().map(|t| t.close_reason).collect();
781
782        Self {
783            position_id: first.position_id.clone(),
784            symbol: first.symbol.clone(),
785            side: first.side,
786            group: first.group.clone(),
787            entry_price,
788            avg_exit_price,
789            original_size,
790            close_count: trades.len(),
791            net_pnl,
792            close_reasons,
793            open_ts: first.open_ts,
794            final_close_ts,
795            duration_seconds: (final_close_ts - first.open_ts).num_seconds(),
796        }
797    }
798
799    /// Net position result: positive P&L.
800    pub fn is_winner(&self) -> bool {
801        self.net_pnl > 0.0
802    }
803
804    /// Net position result: negative P&L.
805    pub fn is_loser(&self) -> bool {
806        self.net_pnl < 0.0
807    }
808}
809
810// ─── CloseReasonStats ───────────────────────────────────────────────────────
811
812/// Statistics for one close reason.
813#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct CloseReasonStats {
815    /// The close reason.
816    pub reason: CloseReason,
817    /// How many trades closed for this reason.
818    pub count: usize,
819    /// Sum of P&L for trades with this reason.
820    pub total_pnl: f64,
821    /// Average P&L per trade for this reason.
822    pub avg_pnl: f64,
823    /// Fraction of all trades that closed for this reason.
824    pub percentage: f64,
825}
826
827/// Compute per-close-reason statistics.
828fn compute_close_reason_stats(trade_log: &[TradeResult]) -> Vec<CloseReasonStats> {
829    if trade_log.is_empty() {
830        return Vec::new();
831    }
832
833    let total_count = trade_log.len();
834    let mut by_reason: HashMap<CloseReason, Vec<f64>> = HashMap::new();
835    for trade in trade_log {
836        by_reason
837            .entry(trade.close_reason)
838            .or_default()
839            .push(trade.pnl);
840    }
841
842    let mut stats: Vec<CloseReasonStats> = by_reason
843        .into_iter()
844        .map(|(reason, pnls)| {
845            let count = pnls.len();
846            let total_pnl: f64 = pnls.iter().sum();
847            CloseReasonStats {
848                reason,
849                count,
850                total_pnl,
851                avg_pnl: total_pnl / count as f64,
852                percentage: count as f64 / total_count as f64,
853            }
854        })
855        .collect();
856
857    // Sort by count descending, then reason name for deterministic ties.
858    stats.sort_by(|left, right| {
859        right
860            .count
861            .cmp(&left.count)
862            .then_with(|| left.reason.to_string().cmp(&right.reason.to_string()))
863    });
864    stats
865}
866
867// ─── BacktestResult ─────────────────────────────────────────────────────────
868
869/// Aggregate backtest statistics produced by [`BacktestRunner`](crate::runner::BacktestRunner).
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct BacktestResult {
872    // ── Existing fields (preserved for backward compatibility) ───────
873    /// Starting account balance.
874    pub initial_balance: f64,
875    /// Final account balance (initial + total realized P&L).
876    pub final_balance: f64,
877    /// Sum of all realized P&L.
878    pub total_pnl: f64,
879    /// Number of completed trades (full + partial closes).
880    pub total_trades: usize,
881    /// Number of trades with positive P&L.
882    pub winning_trades: usize,
883    /// Number of trades with negative P&L.
884    pub losing_trades: usize,
885    /// `winning_trades / total_trades` (0.0 if no trades).
886    pub win_rate: f64,
887    /// Largest peak-to-trough drawdown in absolute terms.
888    pub max_drawdown: f64,
889    /// Largest peak-to-trough drawdown as a percentage of the peak.
890    pub max_drawdown_pct: f64,
891    /// Sum of winning P&L / abs(sum of losing P&L). `f64::INFINITY` if no losers.
892    #[serde(with = "finite_f64")]
893    pub profit_factor: f64,
894    /// Equity value at each trade close: `(timestamp, balance)`.
895    pub equity_curve: Vec<(NaiveDateTime, f64)>,
896    /// Full trade log (one entry per close event).
897    pub trade_log: Vec<TradeResult>,
898
899    /// Full aggregate stats in SubsetStats form.
900    pub summary: SubsetStats,
901
902    /// Stats broken down by symbol.
903    pub per_symbol: BTreeMap<String, SubsetStats>,
904
905    /// Stats broken down by group (empty if no positions were grouped).
906    pub per_group: BTreeMap<GroupId, SubsetStats>,
907
908    /// Stats for long (Buy) trades.
909    pub long_stats: SubsetStats,
910    /// Stats for short (Sell) trades.
911    pub short_stats: SubsetStats,
912
913    /// Breakdown by close reason, sorted by count descending.
914    pub per_close_reason: Vec<CloseReasonStats>,
915
916    /// Consecutive win/loss streak analysis.
917    pub streaks: StreakStats,
918
919    /// Risk-adjusted return metrics (Sharpe, Sortino, Calmar, drawdown duration).
920    pub risk_metrics: RiskMetrics,
921
922    /// Trade holding time statistics. `None` if no trades.
923    pub duration_stats: Option<DurationStats>,
924
925    /// Monthly P&L breakdown.
926    pub monthly_returns: Vec<MonthlyReturn>,
927
928    // ── Per-position aggregation ────────────────────────────────────
929    /// Per-position summaries. Legacy reports aggregate close-event rows; FutureQuote
930    /// reports include only campaigns present in `completed_positions`.
931    pub positions: Vec<PositionSummary>,
932
933    /// Number of unique legacy positions or completed FutureQuote campaigns.
934    pub total_positions: usize,
935    /// Winning legacy positions or epsilon-classified FutureQuote campaigns.
936    pub winning_positions: usize,
937    /// Losing legacy positions or epsilon-classified FutureQuote campaigns.
938    pub losing_positions: usize,
939    /// Position-level win rate: winning_positions / total_positions.
940    pub position_win_rate: f64,
941
942    // ── FutureQuoteV1 additive artifacts ─────────────────────────────
943    #[serde(default)]
944    pub future_format_version: Option<u32>,
945    #[serde(default)]
946    pub execution_metadata: Option<ExecutionMetadata>,
947    #[serde(default)]
948    pub recorded_fills: Vec<RecordedFill>,
949    #[serde(default)]
950    pub action_dispositions: Vec<ActionDisposition>,
951    #[serde(default)]
952    pub close_events: Vec<CloseEvent>,
953    #[serde(default)]
954    pub completed_positions: Vec<CompletedPosition>,
955    #[serde(default)]
956    pub open_position_snapshots: Vec<OpenPositionSnapshot>,
957    #[serde(default)]
958    pub pending_order_snapshots: Vec<PendingOrderSnapshot>,
959    #[serde(default)]
960    pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
961    #[serde(default)]
962    pub mtm_equity_curve: Vec<EquityPoint>,
963    #[serde(default)]
964    pub mtm_output_summary: MtmOutputSummary,
965    #[serde(default)]
966    pub mtm_max_drawdown: Option<f64>,
967    #[serde(default)]
968    pub mtm_max_drawdown_pct: Option<f64>,
969    #[serde(default)]
970    pub provider_evaluation: Option<EvaluationReport>,
971    /// Unfiltered normalized outcomes used to build provider evaluation.
972    #[serde(default)]
973    pub provider_positions: Vec<PositionOutcome>,
974    /// Total account-currency commission charged across every entry and exit fill.
975    #[serde(default)]
976    pub total_commission: f64,
977    /// Total account-currency swap charged across every rollover.
978    #[serde(default)]
979    pub total_swap: f64,
980    /// Realized profit and loss before commission and swap, present only when a cost was charged.
981    #[serde(default)]
982    pub gross_pnl: Option<f64>,
983    /// Commission and swap charges in application order.
984    #[serde(default)]
985    pub cost_events: Vec<CostEvent>,
986}
987
988impl BacktestResult {
989    /// Build aggregate statistics from a trade log.
990    pub fn from_trade_log(initial_balance: f64, trade_log: Vec<TradeResult>) -> Self {
991        let total_pnl: f64 = trade_log.iter().map(|t| t.pnl).sum();
992        let final_balance = initial_balance + total_pnl;
993        let total_trades = trade_log.len();
994
995        let winning_trades = trade_log.iter().filter(|t| t.pnl > 0.0).count();
996        let losing_trades = trade_log.iter().filter(|t| t.pnl < 0.0).count();
997
998        let win_rate = if total_trades > 0 {
999            winning_trades as f64 / total_trades as f64
1000        } else {
1001            0.0
1002        };
1003
1004        let gross_profit: f64 = trade_log
1005            .iter()
1006            .filter(|t| t.pnl > 0.0)
1007            .map(|t| t.pnl)
1008            .sum();
1009        let gross_loss: f64 = trade_log
1010            .iter()
1011            .filter(|t| t.pnl < 0.0)
1012            .map(|t| t.pnl.abs())
1013            .sum();
1014        let profit_factor = if gross_loss > 0.0 {
1015            gross_profit / gross_loss
1016        } else if gross_profit > 0.0 {
1017            f64::INFINITY
1018        } else {
1019            0.0
1020        };
1021
1022        // Build equity curve and compute max drawdown.
1023        let mut balance = initial_balance;
1024        let mut equity_curve = Vec::with_capacity(trade_log.len());
1025        let mut peak = initial_balance;
1026        let mut max_drawdown = 0.0_f64;
1027        let mut max_drawdown_pct = 0.0_f64;
1028
1029        for trade in &trade_log {
1030            balance += trade.pnl;
1031            equity_curve.push((trade.close_ts, balance));
1032
1033            if balance > peak {
1034                peak = balance;
1035            }
1036            let dd = peak - balance;
1037            if dd > max_drawdown {
1038                max_drawdown = dd;
1039            }
1040            let dd_pct = if peak > 0.0 { dd / peak } else { 0.0 };
1041            if dd_pct > max_drawdown_pct {
1042                max_drawdown_pct = dd_pct;
1043            }
1044        }
1045
1046        // ── SubsetStats (overall summary) ───────────────────────────
1047        let all_refs: Vec<&TradeResult> = trade_log.iter().collect();
1048        let summary = SubsetStats::from_trades(&all_refs);
1049
1050        // ── Per-symbol breakdown ────────────────────────────────────
1051        let mut by_symbol: HashMap<String, Vec<&TradeResult>> = HashMap::new();
1052        for trade in &trade_log {
1053            by_symbol
1054                .entry(trade.symbol.clone())
1055                .or_default()
1056                .push(trade);
1057        }
1058        let per_symbol: BTreeMap<String, SubsetStats> = by_symbol
1059            .iter()
1060            .map(|(sym, trades)| (sym.clone(), SubsetStats::from_trades(trades)))
1061            .collect();
1062
1063        // ── Per-group breakdown ─────────────────────────────────────
1064        let mut by_group: HashMap<GroupId, Vec<&TradeResult>> = HashMap::new();
1065        for trade in &trade_log {
1066            if let Some(ref g) = trade.group {
1067                by_group.entry(g.clone()).or_default().push(trade);
1068            }
1069        }
1070        let per_group: BTreeMap<GroupId, SubsetStats> = by_group
1071            .iter()
1072            .map(|(g, trades)| (g.clone(), SubsetStats::from_trades(trades)))
1073            .collect();
1074
1075        // ── Per-side breakdown ──────────────────────────────────────
1076        let longs: Vec<&TradeResult> = trade_log.iter().filter(|t| t.side == Side::Buy).collect();
1077        let shorts: Vec<&TradeResult> = trade_log.iter().filter(|t| t.side == Side::Sell).collect();
1078        let long_stats = SubsetStats::from_trades(&longs);
1079        let short_stats = SubsetStats::from_trades(&shorts);
1080
1081        // ── Per-close-reason breakdown ──────────────────────────────
1082        let per_close_reason = compute_close_reason_stats(&trade_log);
1083
1084        // ── Streak analysis ─────────────────────────────────────────
1085        let streaks = StreakStats::from_trades(&all_refs);
1086
1087        // ── Risk metrics ────────────────────────────────────────────
1088        let risk_metrics = RiskMetrics::compute(
1089            &trade_log,
1090            initial_balance,
1091            max_drawdown,
1092            max_drawdown_pct,
1093            &equity_curve,
1094            total_pnl,
1095        );
1096
1097        // ── Duration stats ──────────────────────────────────────────
1098        let duration_stats = DurationStats::from_trades(&all_refs);
1099
1100        // ── Monthly returns ─────────────────────────────────────────
1101        let monthly_returns = compute_monthly_returns(&trade_log, initial_balance);
1102
1103        // ── Position summaries ──────────────────────────────────────
1104        let mut by_position: HashMap<PositionId, Vec<&TradeResult>> = HashMap::new();
1105        for trade in &trade_log {
1106            by_position
1107                .entry(trade.position_id.clone())
1108                .or_default()
1109                .push(trade);
1110        }
1111        let mut positions: Vec<PositionSummary> = by_position
1112            .values()
1113            .map(|trades| PositionSummary::from_trades(trades))
1114            .collect();
1115        // Stable lifecycle tie-breaks keep full-result serialization deterministic.
1116        positions.sort_by(|left, right| {
1117            left.open_ts
1118                .cmp(&right.open_ts)
1119                .then_with(|| left.final_close_ts.cmp(&right.final_close_ts))
1120                .then_with(|| left.position_id.cmp(&right.position_id))
1121        });
1122
1123        let total_positions = positions.len();
1124        let winning_positions = positions.iter().filter(|p| p.is_winner()).count();
1125        let losing_positions = positions.iter().filter(|p| p.is_loser()).count();
1126        let position_win_rate = if total_positions > 0 {
1127            winning_positions as f64 / total_positions as f64
1128        } else {
1129            0.0
1130        };
1131
1132        Self {
1133            initial_balance,
1134            final_balance,
1135            total_pnl,
1136            total_trades,
1137            winning_trades,
1138            losing_trades,
1139            win_rate,
1140            max_drawdown,
1141            max_drawdown_pct,
1142            profit_factor,
1143            equity_curve,
1144            trade_log,
1145            summary,
1146            per_symbol,
1147            per_group,
1148            long_stats,
1149            short_stats,
1150            per_close_reason,
1151            streaks,
1152            risk_metrics,
1153            duration_stats,
1154            monthly_returns,
1155            positions,
1156            total_positions,
1157            winning_positions,
1158            losing_positions,
1159            position_win_rate,
1160            future_format_version: None,
1161            execution_metadata: None,
1162            recorded_fills: Vec::new(),
1163            action_dispositions: Vec::new(),
1164            close_events: Vec::new(),
1165            completed_positions: Vec::new(),
1166            open_position_snapshots: Vec::new(),
1167            pending_order_snapshots: Vec::new(),
1168            pending_order_lifecycle: Vec::new(),
1169            mtm_equity_curve: Vec::new(),
1170            mtm_output_summary: MtmOutputSummary::default(),
1171            mtm_max_drawdown: None,
1172            mtm_max_drawdown_pct: None,
1173            provider_evaluation: None,
1174            provider_positions: Vec::new(),
1175            total_commission: 0.0,
1176            total_swap: 0.0,
1177            gross_pnl: None,
1178            cost_events: Vec::new(),
1179        }
1180    }
1181
1182    /// Build legacy close-event rows plus completed-position FutureQuoteV1 statistics
1183    /// and additive artifacts using the backward-compatible all-sections report.
1184    pub fn from_future_artifacts(artifacts: FutureBacktestArtifacts) -> Self {
1185        Self::from_future_artifacts_with_options(artifacts, EvaluationOptions::default())
1186    }
1187
1188    /// Build a FutureQuoteV1 result and apply typed provider-evaluation selection.
1189    pub fn from_future_artifacts_with_options(
1190        artifacts: FutureBacktestArtifacts,
1191        evaluation_options: EvaluationOptions,
1192    ) -> Self {
1193        let trade_log = future_trade_log(&artifacts);
1194        let (provider_evaluation, provider_positions) =
1195            evaluate_future_positions(&artifacts, evaluation_options);
1196        let settled: BTreeSet<&str> = artifacts
1197            .completed_positions
1198            .iter()
1199            .filter(|position| !position.close_events.is_empty())
1200            .map(|position| position.position_id.as_str())
1201            .collect();
1202        let mut result = Self::from_trade_log(artifacts.execution.initial_balance, trade_log);
1203        result.apply_cost_events(&artifacts.cost_events, &settled);
1204        result.replace_position_statistics(&artifacts.completed_positions);
1205        result.future_format_version = Some(artifacts.format_version);
1206        result.execution_metadata = Some(artifacts.execution);
1207        result.recorded_fills = artifacts.fills;
1208        result.action_dispositions = artifacts.lifecycle.as_slice().to_vec();
1209        result.close_events = artifacts.close_events;
1210        result.completed_positions = artifacts.completed_positions;
1211        result.open_position_snapshots = artifacts.open_positions;
1212        result.pending_order_snapshots = artifacts.pending_orders;
1213        result.pending_order_lifecycle = artifacts.pending_order_lifecycle;
1214        result.mtm_equity_curve = artifacts.equity_curve;
1215        result.mtm_output_summary = artifacts.mtm_output_summary;
1216        result.mtm_max_drawdown = artifacts.max_drawdown;
1217        result.mtm_max_drawdown_pct = artifacts.max_drawdown_pct;
1218        result.provider_evaluation = Some(provider_evaluation);
1219        result.provider_positions = provider_positions;
1220        result.cost_events = artifacts.cost_events;
1221        result
1222    }
1223
1224    /// Fold commission and swap totals into the realized result.
1225    ///
1226    /// Exit commission is already inside each trade's profit and loss, so only entry commission and swap still have to be applied to the run totals. The call is a no-op when nothing was charged, which keeps cost-free runs identical to runs produced before costs existed.
1227    fn apply_cost_events(&mut self, cost_events: &[CostEvent], settled: &BTreeSet<&str>) {
1228        if cost_events.is_empty() {
1229            return;
1230        }
1231        let mut commission = 0.0;
1232        let mut swap = 0.0;
1233        let mut outside_trade_log = 0.0;
1234        for event in cost_events {
1235            // A position that closed fully settles its entry commission and swap on its final trade row, so only a position still open when the run ended still has charges outside the trade log.
1236            let unsettled = !settled.contains(event.position_id.as_str());
1237            match event.kind {
1238                CostKind::EntryCommission => {
1239                    commission += event.amount;
1240                    if unsettled {
1241                        outside_trade_log += event.amount;
1242                    }
1243                }
1244                CostKind::ExitCommission => commission += event.amount,
1245                CostKind::Swap => {
1246                    swap += event.amount;
1247                    if unsettled {
1248                        outside_trade_log += event.amount;
1249                    }
1250                }
1251            }
1252        }
1253        self.total_commission = commission;
1254        self.total_swap = swap;
1255        self.total_pnl -= outside_trade_log;
1256        self.gross_pnl = Some(self.total_pnl + commission + swap);
1257        self.final_balance = self.initial_balance + self.total_pnl;
1258    }
1259
1260    fn replace_position_statistics(&mut self, completed_positions: &[CompletedPosition]) {
1261        self.positions = completed_positions
1262            .iter()
1263            .map(position_summary_from_completed)
1264            .collect();
1265        self.positions.sort_by(|left, right| {
1266            left.open_ts
1267                .cmp(&right.open_ts)
1268                .then_with(|| left.final_close_ts.cmp(&right.final_close_ts))
1269                .then_with(|| left.position_id.cmp(&right.position_id))
1270        });
1271        self.streaks = StreakStats::from_completed_positions(completed_positions);
1272        self.duration_stats = DurationStats::from_completed_positions(completed_positions);
1273        self.monthly_returns =
1274            compute_monthly_returns_from_completed(completed_positions, self.initial_balance);
1275        self.total_positions = completed_positions.len();
1276        self.winning_positions = completed_positions
1277            .iter()
1278            .filter(|position| position.outcome == NetPnlOutcome::Win)
1279            .count();
1280        self.losing_positions = completed_positions
1281            .iter()
1282            .filter(|position| position.outcome == NetPnlOutcome::Loss)
1283            .count();
1284        self.position_win_rate = if self.total_positions > 0 {
1285            self.winning_positions as f64 / self.total_positions as f64
1286        } else {
1287            0.0
1288        };
1289    }
1290}
1291
1292fn position_summary_from_completed(position: &CompletedPosition) -> PositionSummary {
1293    let closed_size = position
1294        .close_events
1295        .iter()
1296        .map(|event| event.size)
1297        .sum::<f64>();
1298    let avg_exit_price = if closed_size > 0.0 {
1299        position
1300            .close_events
1301            .iter()
1302            .map(|event| event.price * event.size)
1303            .sum::<f64>()
1304            / closed_size
1305    } else {
1306        0.0
1307    };
1308    let close_reasons = if position.close_events.is_empty() {
1309        position.close_reasons.clone()
1310    } else {
1311        position
1312            .close_events
1313            .iter()
1314            .map(|event| event.reason)
1315            .collect()
1316    };
1317
1318    PositionSummary {
1319        position_id: position.position_id.clone(),
1320        symbol: position.symbol.clone(),
1321        side: position.side,
1322        group: position.group.clone(),
1323        entry_price: position.average_entry_price,
1324        avg_exit_price,
1325        original_size: position.entry_size,
1326        close_count: position.close_events.len(),
1327        net_pnl: position.net_pnl,
1328        close_reasons,
1329        open_ts: position.open_ts,
1330        final_close_ts: position.close_ts,
1331        duration_seconds: (position.close_ts - position.open_ts).num_seconds(),
1332    }
1333}
1334
1335/// Entry commission and swap charged to one position, which belong to the position rather than to any single close.
1336#[derive(Debug, Clone, Copy, Default)]
1337struct PositionCostSettlement {
1338    commission: f64,
1339    swap: f64,
1340}
1341
1342/// Costs that a completed position settles on its final close row, keyed by the identifier of that row.
1343///
1344/// Only fully closed positions appear. A position still open when the run ended has no final row to settle against, so its charges stay on the run totals alone.
1345fn final_row_settlements(
1346    artifacts: &FutureBacktestArtifacts,
1347) -> BTreeMap<&str, PositionCostSettlement> {
1348    let mut by_position: BTreeMap<&str, PositionCostSettlement> = BTreeMap::new();
1349    for event in &artifacts.cost_events {
1350        let entry = by_position.entry(event.position_id.as_str()).or_default();
1351        match event.kind {
1352            CostKind::EntryCommission => entry.commission += event.amount,
1353            CostKind::Swap => entry.swap += event.amount,
1354            // An exit commission is already inside its own close event's profit and loss.
1355            CostKind::ExitCommission => {}
1356        }
1357    }
1358    let mut by_row = BTreeMap::new();
1359    for position in &artifacts.completed_positions {
1360        let Some(settlement) = by_position.get(position.position_id.as_str()) else {
1361            continue;
1362        };
1363        let Some(final_close) = position.close_events.last() else {
1364            continue;
1365        };
1366        by_row.insert(final_close.id.as_str(), *settlement);
1367    }
1368    by_row
1369}
1370
1371fn future_trade_log(artifacts: &FutureBacktestArtifacts) -> Vec<TradeResult> {
1372    let settlements = final_row_settlements(artifacts);
1373    let mut rows = Vec::with_capacity(artifacts.close_events.len());
1374    for event in &artifacts.close_events {
1375        let completed = artifacts
1376            .completed_positions
1377            .iter()
1378            .find(|position| position.position_id == event.position_id);
1379        let open = artifacts
1380            .open_positions
1381            .iter()
1382            .find(|position| position.position_id == event.position_id);
1383        let entry_price = event
1384            .entry_price
1385            .or_else(|| completed.map(|position| position.average_entry_price))
1386            .or_else(|| open.map(|position| position.average_entry_price))
1387            .unwrap_or(event.price);
1388        let open_ts = completed
1389            .map(|position| position.open_ts)
1390            .or_else(|| open.and_then(|position| position.open_ts))
1391            .unwrap_or(event.ts);
1392        let group = completed
1393            .and_then(|position| position.group.clone())
1394            .or_else(|| open.and_then(|position| position.group.clone()));
1395        let settled = settlements
1396            .get(event.id.as_str())
1397            .copied()
1398            .unwrap_or_default();
1399        let commission = event.commission + settled.commission;
1400        let swap = settled.swap;
1401        let pnl = event.pnl - settled.commission - settled.swap;
1402        rows.push(TradeResult {
1403            position_id: event.position_id.clone(),
1404            symbol: event.symbol.clone(),
1405            side: event.side,
1406            entry_price,
1407            exit_price: event.price,
1408            size: event.size,
1409            pnl,
1410            commission,
1411            swap,
1412            gross_pnl: (commission != 0.0 || swap != 0.0).then_some(pnl + commission + swap),
1413            open_ts,
1414            close_ts: event.ts,
1415            close_reason: event.reason,
1416            group,
1417        });
1418    }
1419    rows.sort_by(|left, right| {
1420        left.close_ts
1421            .cmp(&right.close_ts)
1422            .then_with(|| left.position_id.cmp(&right.position_id))
1423            .then_with(|| {
1424                left.close_reason
1425                    .to_string()
1426                    .cmp(&right.close_reason.to_string())
1427            })
1428            .then_with(|| left.size.total_cmp(&right.size))
1429            .then_with(|| left.pnl.total_cmp(&right.pnl))
1430    });
1431    rows
1432}
1433
1434fn evaluate_future_positions(
1435    artifacts: &FutureBacktestArtifacts,
1436    options: EvaluationOptions,
1437) -> (EvaluationReport, Vec<PositionOutcome>) {
1438    let positions: Vec<PositionOutcome> = artifacts
1439        .completed_positions
1440        .iter()
1441        .map(|position| {
1442            let initial_risk = position.initial_risk();
1443            let excursions = initial_risk.and_then(|risk| {
1444                (risk > 0.0).then_some(ExcursionInput {
1445                    favorable_r: position.mfe.map(|value| value / risk),
1446                    adverse_r: position.mae.map(|value| value / risk),
1447                })
1448            });
1449            let fills: Vec<_> = artifacts
1450                .fills
1451                .iter()
1452                .filter(|fill| fill.position_id == position.position_id)
1453                .collect();
1454            let execution = (!fills.is_empty()).then(|| {
1455                let latency_ms = fills
1456                    .iter()
1457                    .map(|fill| {
1458                        (fill.execution_ts.unwrap_or(fill.quote_ts) - fill.effective_ts)
1459                            .num_milliseconds() as f64
1460                    })
1461                    .sum::<f64>()
1462                    / fills.len() as f64;
1463                let slippage_bps = fills
1464                    .iter()
1465                    .filter(|fill| fill.fill.quote_price.is_finite() && fill.fill.quote_price > 0.0)
1466                    .map(|fill| {
1467                        let raw = (fill.fill.price - fill.fill.quote_price) / fill.fill.quote_price
1468                            * 10_000.0;
1469                        let adverse_sign = match (fill.fill.purpose.is_entry(), fill.fill.side) {
1470                            (true, Side::Buy) | (false, Side::Sell) => 1.0,
1471                            (true, Side::Sell) | (false, Side::Buy) => -1.0,
1472                        };
1473                        raw * adverse_sign
1474                    })
1475                    .sum::<f64>()
1476                    / fills.len() as f64;
1477                ExecutionDiagnosticsInput {
1478                    slippage_bps: Some(slippage_bps),
1479                    latency_ms: Some(latency_ms),
1480                    fill_ratio: position_fill_ratio(position, artifacts),
1481                }
1482            });
1483            PositionOutcome {
1484                id: position.position_id.clone(),
1485                trade_id: position.trade_id.clone(),
1486                ordinal: position.close_ts.and_utc().timestamp_millis(),
1487                dimensions: PositionDimensions {
1488                    symbol: position.symbol.clone(),
1489                    side: match position.side {
1490                        Side::Buy => PositionSide::Long,
1491                        Side::Sell => PositionSide::Short,
1492                    },
1493                    group: position.group.clone(),
1494                    close_reasons: position
1495                        .close_reasons
1496                        .iter()
1497                        .map(ToString::to_string)
1498                        .collect(),
1499                    // Every position of a run shares the run's labels, which is what lets a breakdown group positions across runs by parameter or window; a label of the position itself, such as its instance, joins them.
1500                    tags: {
1501                        let mut tags = artifacts.execution.run_tags.clone();
1502                        if let Some(own) =
1503                            artifacts.execution.position_tags.get(&position.position_id)
1504                        {
1505                            tags.extend(own.clone());
1506                        }
1507                        tags
1508                    },
1509                },
1510                outcome: position.net_pnl,
1511                outcome_classification: Some(match position.outcome {
1512                    NetPnlOutcome::Win => OutcomeClassification::Win,
1513                    NetPnlOutcome::Loss => OutcomeClassification::Loss,
1514                    NetPnlOutcome::Breakeven => OutcomeClassification::Breakeven,
1515                }),
1516                r_multiple: position.realized_r,
1517                excursions,
1518                execution,
1519                costs: (position.commission_total != 0.0 || position.swap_total != 0.0).then_some(
1520                    PositionCostsInput {
1521                        commission: position.commission_total,
1522                        swap: position.swap_total,
1523                    },
1524                ),
1525            }
1526        })
1527        .collect();
1528    let entry_dispositions: Vec<_> = artifacts
1529        .lifecycle
1530        .iter()
1531        .filter(|disposition| disposition.action_kind.as_deref() == Some("entry"))
1532        .collect();
1533    let accepted = entry_dispositions
1534        .iter()
1535        .filter(|disposition| disposition.status == ActionDispositionStatus::Applied)
1536        .count() as u64;
1537    let rejected = entry_dispositions.len() as u64 - accepted;
1538    let lifecycle = LifecycleCounts {
1539        candidates: entry_dispositions.len() as u64,
1540        accepted,
1541        opened: artifacts
1542            .fills
1543            .iter()
1544            .filter(|fill| fill.fill.purpose.is_entry())
1545            .map(|fill| fill.position_id.as_str())
1546            .collect::<std::collections::HashSet<_>>()
1547            .len() as u64,
1548        completed: artifacts.completed_positions.len() as u64,
1549        rejected,
1550        filled: artifacts
1551            .pending_order_lifecycle
1552            .iter()
1553            .filter(|event| event.state == PendingOrderLifecycleState::Filled)
1554            .count() as u64,
1555        cancelled: artifacts
1556            .pending_order_lifecycle
1557            .iter()
1558            .filter(|event| event.state == PendingOrderLifecycleState::Cancelled)
1559            .count() as u64,
1560        unfilled_at_end: artifacts
1561            .pending_order_lifecycle
1562            .iter()
1563            .filter(|event| event.state == PendingOrderLifecycleState::UnfilledAtEnd)
1564            .count() as u64,
1565        open_at_end: artifacts.open_positions.len() as u64,
1566    };
1567    let report = evaluate(&EvaluationRequest {
1568        positions: positions.clone(),
1569        lifecycle: Some(lifecycle),
1570        options,
1571    });
1572    (report, positions)
1573}
1574
1575fn position_fill_ratio(
1576    position: &CompletedPosition,
1577    artifacts: &FutureBacktestArtifacts,
1578) -> Option<f64> {
1579    let entry_fills: Vec<_> = artifacts
1580        .fills
1581        .iter()
1582        .filter(|fill| fill.position_id == position.position_id && fill.fill.purpose.is_entry())
1583        .collect();
1584    if entry_fills.is_empty() {
1585        return None;
1586    }
1587
1588    let total_filled = entry_fills
1589        .iter()
1590        .map(|fill| fill.size)
1591        .filter(|size| size.is_finite() && *size > 0.0)
1592        .sum::<f64>();
1593    if total_filled <= 0.0 {
1594        return None;
1595    }
1596
1597    let pending_fill = artifacts.pending_order_lifecycle.iter().find(|event| {
1598        event.position_id == position.position_id
1599            && event.state == PendingOrderLifecycleState::Filled
1600    });
1601    let Some(pending_fill) = pending_fill else {
1602        // Market entry and scale-in fills are fill-or-reject in FutureQuoteV1.
1603        return Some(1.0);
1604    };
1605    let pending_filled = pending_fill.filled_size.filter(|size| size.is_finite())?;
1606    if !pending_fill.requested_size.is_finite() || pending_fill.requested_size <= 0.0 {
1607        return None;
1608    }
1609    let other_filled = (total_filled - pending_filled).max(0.0);
1610    let requested = pending_fill.requested_size + other_filled;
1611    (requested > 0.0).then_some(total_filled / requested)
1612}
1613
1614// ─── Display ────────────────────────────────────────────────────────────────
1615
1616/// Format duration seconds into a human-readable string (e.g. "3d 14h 5m").
1617fn fmt_duration(secs: i64) -> String {
1618    if secs < 0 {
1619        return format!("-{}", fmt_duration(-secs));
1620    }
1621    let days = secs / 86400;
1622    let hours = (secs % 86400) / 3600;
1623    let minutes = (secs % 3600) / 60;
1624    if days > 0 {
1625        format!("{}d {}h {}m", days, hours, minutes)
1626    } else if hours > 0 {
1627        format!("{}h {}m", hours, minutes)
1628    } else {
1629        format!("{}m", minutes)
1630    }
1631}
1632
1633/// Format a SubsetStats one-line summary for breakdown sections.
1634fn fmt_subset_line(label: &str, stats: &SubsetStats) -> String {
1635    format!(
1636        "{:<14}: {} trades, P&L: {:+.2}, WR: {:.1}%, PF: {:.2}",
1637        label,
1638        stats.total_trades,
1639        stats.total_pnl,
1640        stats.win_rate * 100.0,
1641        stats.profit_factor,
1642    )
1643}
1644
1645impl std::fmt::Display for BacktestResult {
1646    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1647        let be = self.summary.breakeven_trades;
1648
1649        writeln!(f, "═══ Backtest Result ═══")?;
1650        writeln!(
1651            f,
1652            "Balance      : {:.2} -> {:.2}",
1653            self.initial_balance, self.final_balance
1654        )?;
1655        writeln!(f, "Total P&L    : {:+.2}", self.total_pnl)?;
1656        writeln!(f, "Trades       : {}", self.total_trades)?;
1657
1658        if be > 0 {
1659            writeln!(
1660                f,
1661                "Win / Lose   : {} / {} / {} (BE)",
1662                self.winning_trades, self.losing_trades, be
1663            )?;
1664        } else {
1665            writeln!(
1666                f,
1667                "Win / Lose   : {} / {}",
1668                self.winning_trades, self.losing_trades
1669            )?;
1670        }
1671
1672        writeln!(f, "Win Rate     : {:.1}%", self.win_rate * 100.0)?;
1673        writeln!(f, "Profit Factor: {:.2}", self.profit_factor)?;
1674        writeln!(f, "Expectancy   : {:.2} per trade", self.summary.expectancy)?;
1675
1676        // ── Risk Metrics ────────────────────────────────────────────
1677        writeln!(f)?;
1678        writeln!(f, "-- Risk Metrics --")?;
1679
1680        match self.risk_metrics.sharpe_ratio {
1681            Some(v) => writeln!(f, "Sharpe Ratio       : {:.2}", v)?,
1682            None => writeln!(f, "Sharpe Ratio       : N/A")?,
1683        }
1684        match self.risk_metrics.sortino_ratio {
1685            Some(v) => writeln!(f, "Sortino Ratio      : {:.2}", v)?,
1686            None => writeln!(f, "Sortino Ratio      : N/A")?,
1687        }
1688        match self.risk_metrics.calmar_ratio {
1689            Some(v) => writeln!(f, "Calmar Ratio       : {:.2}", v)?,
1690            None => writeln!(f, "Calmar Ratio       : N/A")?,
1691        }
1692        writeln!(
1693            f,
1694            "Max Drawdown       : {:.2} ({:.1}%)",
1695            self.max_drawdown,
1696            self.max_drawdown_pct * 100.0
1697        )?;
1698        match self.risk_metrics.max_drawdown_duration_secs {
1699            Some(s) => writeln!(f, "Max DD Duration    : {}", fmt_duration(s))?,
1700            None => writeln!(f, "Max DD Duration    : N/A")?,
1701        }
1702        match self.risk_metrics.return_on_max_drawdown {
1703            Some(v) => writeln!(f, "Return / Max DD    : {:.2}", v)?,
1704            None => writeln!(f, "Return / Max DD    : N/A")?,
1705        }
1706
1707        // ── Win / Loss Analysis ─────────────────────────────────────
1708        writeln!(f)?;
1709        writeln!(f, "-- Win / Loss Analysis --")?;
1710        writeln!(
1711            f,
1712            "Avg Win    : {:.2}    Largest Win  : {:.2}",
1713            self.summary.avg_win, self.summary.largest_win
1714        )?;
1715        writeln!(
1716            f,
1717            "Avg Loss   : {:.2}    Largest Loss : {:.2}",
1718            self.summary.avg_loss, self.summary.largest_loss
1719        )?;
1720        writeln!(
1721            f,
1722            "Win/Loss   : {:.2}     Expectancy   : {:.2}",
1723            self.summary.win_loss_ratio, self.summary.expectancy
1724        )?;
1725        writeln!(
1726            f,
1727            "Max Consec Wins  : {}",
1728            self.streaks.max_consecutive_wins
1729        )?;
1730        writeln!(
1731            f,
1732            "Max Consec Losses: {}",
1733            self.streaks.max_consecutive_losses
1734        )?;
1735
1736        // ── Side Breakdown ──────────────────────────────────────────
1737        writeln!(f)?;
1738        writeln!(f, "-- Side Breakdown --")?;
1739        writeln!(f, "{}", fmt_subset_line("Long", &self.long_stats))?;
1740        writeln!(f, "{}", fmt_subset_line("Short", &self.short_stats))?;
1741
1742        // ── Symbol Breakdown ────────────────────────────────────────
1743        if !self.per_symbol.is_empty() {
1744            writeln!(f)?;
1745            writeln!(f, "-- Symbol Breakdown --")?;
1746            let mut symbols: Vec<_> = self.per_symbol.iter().collect();
1747            symbols.sort_by(|a, b| {
1748                b.1.total_trades
1749                    .cmp(&a.1.total_trades)
1750                    .then_with(|| a.0.cmp(b.0))
1751            });
1752            for (sym, stats) in &symbols {
1753                writeln!(f, "{}", fmt_subset_line(sym, stats))?;
1754            }
1755        }
1756
1757        // ── Group Breakdown ─────────────────────────────────────────
1758        if !self.per_group.is_empty() {
1759            writeln!(f)?;
1760            writeln!(f, "-- Group Breakdown --")?;
1761            let mut groups: Vec<_> = self.per_group.iter().collect();
1762            groups.sort_by(|a, b| {
1763                b.1.total_trades
1764                    .cmp(&a.1.total_trades)
1765                    .then_with(|| a.0.cmp(b.0))
1766            });
1767            for (grp, stats) in &groups {
1768                writeln!(f, "{}", fmt_subset_line(grp, stats))?;
1769            }
1770        }
1771
1772        // ── Close Reasons ───────────────────────────────────────────
1773        if !self.per_close_reason.is_empty() {
1774            writeln!(f)?;
1775            writeln!(f, "-- Close Reasons --")?;
1776            for cr in &self.per_close_reason {
1777                writeln!(
1778                    f,
1779                    "{:<14}: {:>3} ({:>4.1}%), P&L: {:+.2}",
1780                    cr.reason.to_string(),
1781                    cr.count,
1782                    cr.percentage * 100.0,
1783                    cr.total_pnl,
1784                )?;
1785            }
1786        }
1787
1788        // ── Duration ────────────────────────────────────────────────
1789        if let Some(ref ds) = self.duration_stats {
1790            writeln!(f)?;
1791            writeln!(f, "-- Duration --")?;
1792            writeln!(
1793                f,
1794                "Avg Duration     : {}",
1795                fmt_duration(ds.avg_duration_secs)
1796            )?;
1797            writeln!(
1798                f,
1799                "Avg Winner Dur   : {}",
1800                fmt_duration(ds.avg_winner_duration_secs)
1801            )?;
1802            writeln!(
1803                f,
1804                "Avg Loser Dur    : {}",
1805                fmt_duration(ds.avg_loser_duration_secs)
1806            )?;
1807            writeln!(
1808                f,
1809                "Shortest         : {}",
1810                fmt_duration(ds.min_duration_secs)
1811            )?;
1812            writeln!(
1813                f,
1814                "Longest          : {}",
1815                fmt_duration(ds.max_duration_secs)
1816            )?;
1817        }
1818
1819        // ── Monthly Returns ─────────────────────────────────────────
1820        if !self.monthly_returns.is_empty() {
1821            writeln!(f)?;
1822            writeln!(f, "-- Monthly Returns --")?;
1823            for mr in &self.monthly_returns {
1824                writeln!(
1825                    f,
1826                    "{:04}-{:02} : {:+.2} ({} trades)",
1827                    mr.year, mr.month, mr.pnl, mr.trade_count,
1828                )?;
1829            }
1830        }
1831
1832        // ── Position Summary ────────────────────────────────────────
1833        if self.total_positions > 0 {
1834            writeln!(f)?;
1835            writeln!(f, "-- Position Summary --")?;
1836            writeln!(f, "Total Positions  : {}", self.total_positions)?;
1837            writeln!(
1838                f,
1839                "Win / Lose       : {} / {}",
1840                self.winning_positions, self.losing_positions
1841            )?;
1842            writeln!(
1843                f,
1844                "Position WR      : {:.1}%",
1845                self.position_win_rate * 100.0
1846            )?;
1847        }
1848
1849        Ok(())
1850    }
1851}
1852
1853// ─── Tests ──────────────────────────────────────────────────────────────────
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858    use crate::evaluation::{EvaluationSection, GroupFilter, PositionFilter};
1859    use chrono::NaiveDate;
1860    use std::collections::BTreeSet;
1861
1862    fn ts(year: i32, month: u32, day: u32, h: u32, m: u32, s: u32) -> NaiveDateTime {
1863        NaiveDate::from_ymd_opt(year, month, day)
1864            .unwrap()
1865            .and_hms_opt(h, m, s)
1866            .unwrap()
1867    }
1868
1869    fn ts_hms(h: u32, m: u32, s: u32) -> NaiveDateTime {
1870        ts(2026, 1, 1, h, m, s)
1871    }
1872
1873    fn make_trade(pnl: f64, close_h: u32) -> TradeResult {
1874        TradeResult {
1875            position_id: "p1".into(),
1876            symbol: "EURUSD".into(),
1877            side: Side::Buy,
1878            entry_price: 1.0850,
1879            exit_price: 1.0850 + pnl,
1880            size: 1.0,
1881            pnl,
1882            open_ts: ts_hms(10, 0, 0),
1883            close_ts: ts_hms(close_h, 0, 0),
1884            close_reason: if pnl > 0.0 {
1885                CloseReason::Target
1886            } else if pnl < 0.0 {
1887                CloseReason::Stoploss
1888            } else {
1889                CloseReason::Manual
1890            },
1891            group: None,
1892            commission: 0.0,
1893            swap: 0.0,
1894            gross_pnl: None,
1895        }
1896    }
1897
1898    #[allow(
1899        clippy::too_many_arguments,
1900        reason = "keeping fixture fields explicit is clearer than rewriting the many stable call sites"
1901    )]
1902    fn make_trade_full(
1903        pos_id: &str,
1904        symbol: &str,
1905        side: Side,
1906        pnl: f64,
1907        open_ts: NaiveDateTime,
1908        close_ts: NaiveDateTime,
1909        reason: CloseReason,
1910        group: Option<GroupId>,
1911    ) -> TradeResult {
1912        TradeResult {
1913            position_id: pos_id.into(),
1914            symbol: symbol.into(),
1915            side,
1916            entry_price: 1.0850,
1917            exit_price: 1.0850 + pnl,
1918            size: 1.0,
1919            pnl,
1920            open_ts,
1921            close_ts,
1922            close_reason: reason,
1923            group,
1924            commission: 0.0,
1925            swap: 0.0,
1926            gross_pnl: None,
1927        }
1928    }
1929
1930    // ── Backward compatibility tests (existing, preserved) ──────────
1931
1932    #[test]
1933    fn empty_trade_log() {
1934        let result = BacktestResult::from_trade_log(10_000.0, vec![]);
1935        assert_eq!(result.total_trades, 0);
1936        assert!((result.final_balance - 10_000.0).abs() < f64::EPSILON);
1937        assert!((result.win_rate - 0.0).abs() < f64::EPSILON);
1938        assert!((result.max_drawdown - 0.0).abs() < f64::EPSILON);
1939        // Enhanced fields on empty.
1940        assert_eq!(result.summary.total_trades, 0);
1941        assert!(result.duration_stats.is_none());
1942        assert!(result.monthly_returns.is_empty());
1943        assert_eq!(result.total_positions, 0);
1944        assert!((result.position_win_rate - 0.0).abs() < f64::EPSILON);
1945        assert_eq!(result.streaks.max_consecutive_wins, 0);
1946        assert_eq!(result.streaks.max_consecutive_losses, 0);
1947        assert!(result.risk_metrics.sharpe_ratio.is_none());
1948    }
1949
1950    #[test]
1951    fn basic_stats() {
1952        let trades = vec![
1953            make_trade(100.0, 11),
1954            make_trade(-50.0, 12),
1955            make_trade(200.0, 13),
1956            make_trade(-30.0, 14),
1957        ];
1958        let result = BacktestResult::from_trade_log(10_000.0, trades);
1959        assert_eq!(result.total_trades, 4);
1960        assert_eq!(result.winning_trades, 2);
1961        assert_eq!(result.losing_trades, 2);
1962        assert!((result.total_pnl - 220.0).abs() < f64::EPSILON);
1963        assert!((result.final_balance - 10_220.0).abs() < f64::EPSILON);
1964        assert!((result.win_rate - 0.5).abs() < f64::EPSILON);
1965        assert!((result.profit_factor - 3.75).abs() < f64::EPSILON);
1966    }
1967
1968    #[test]
1969    fn drawdown_calculation() {
1970        let trades = vec![
1971            make_trade(100.0, 11),
1972            make_trade(-200.0, 12),
1973            make_trade(50.0, 13),
1974            make_trade(-100.0, 14),
1975            make_trade(500.0, 15),
1976        ];
1977        let result = BacktestResult::from_trade_log(10_000.0, trades);
1978        assert!((result.max_drawdown - 250.0).abs() < f64::EPSILON);
1979        assert_eq!(result.equity_curve.len(), 5);
1980    }
1981
1982    #[test]
1983    fn all_winners() {
1984        let trades = vec![make_trade(100.0, 11), make_trade(200.0, 12)];
1985        let result = BacktestResult::from_trade_log(10_000.0, trades);
1986        assert!((result.win_rate - 1.0).abs() < f64::EPSILON);
1987        assert!(result.profit_factor.is_infinite());
1988        assert!((result.max_drawdown - 0.0).abs() < f64::EPSILON);
1989    }
1990
1991    // ── SubsetStats tests ───────────────────────────────────────────
1992
1993    #[test]
1994    fn subset_stats_basic() {
1995        let t1 = make_trade(100.0, 11);
1996        let t2 = make_trade(-50.0, 12);
1997        let t3 = make_trade(200.0, 13);
1998        let t4 = make_trade(-30.0, 14);
1999        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4];
2000
2001        let s = SubsetStats::from_trades(&refs);
2002        assert_eq!(s.total_trades, 4);
2003        assert_eq!(s.winning_trades, 2);
2004        assert_eq!(s.losing_trades, 2);
2005        assert_eq!(s.breakeven_trades, 0);
2006        assert!((s.total_pnl - 220.0).abs() < f64::EPSILON);
2007        assert!((s.gross_profit - 300.0).abs() < f64::EPSILON);
2008        assert!((s.gross_loss - 80.0).abs() < f64::EPSILON);
2009        assert!((s.win_rate - 0.5).abs() < f64::EPSILON);
2010        assert!((s.profit_factor - 3.75).abs() < f64::EPSILON);
2011        assert!((s.avg_win - 150.0).abs() < f64::EPSILON);
2012        assert!((s.avg_loss - 40.0).abs() < f64::EPSILON);
2013        assert!((s.win_loss_ratio - 3.75).abs() < f64::EPSILON);
2014        // expectancy = 0.5*150 - 0.5*40 = 55
2015        assert!((s.expectancy - 55.0).abs() < f64::EPSILON);
2016    }
2017
2018    #[test]
2019    fn subset_stats_all_winners() {
2020        let t1 = make_trade(100.0, 11);
2021        let t2 = make_trade(200.0, 12);
2022        let refs: Vec<&TradeResult> = vec![&t1, &t2];
2023
2024        let s = SubsetStats::from_trades(&refs);
2025        assert_eq!(s.losing_trades, 0);
2026        assert!((s.avg_loss - 0.0).abs() < f64::EPSILON);
2027        assert!(s.win_loss_ratio.is_infinite());
2028        assert!(s.profit_factor.is_infinite());
2029    }
2030
2031    #[test]
2032    fn subset_stats_all_losers() {
2033        let t1 = make_trade(-100.0, 11);
2034        let t2 = make_trade(-200.0, 12);
2035        let refs: Vec<&TradeResult> = vec![&t1, &t2];
2036
2037        let s = SubsetStats::from_trades(&refs);
2038        assert_eq!(s.winning_trades, 0);
2039        assert!((s.avg_win - 0.0).abs() < f64::EPSILON);
2040        assert!((s.win_loss_ratio - 0.0).abs() < f64::EPSILON);
2041        assert!((s.profit_factor - 0.0).abs() < f64::EPSILON);
2042    }
2043
2044    #[test]
2045    fn subset_stats_empty() {
2046        let s = SubsetStats::from_trades(&[]);
2047        assert_eq!(s.total_trades, 0);
2048        assert!((s.total_pnl - 0.0).abs() < f64::EPSILON);
2049        assert!((s.win_rate - 0.0).abs() < f64::EPSILON);
2050        assert!((s.expectancy - 0.0).abs() < f64::EPSILON);
2051    }
2052
2053    #[test]
2054    fn subset_stats_largest_win_loss() {
2055        let t1 = make_trade(50.0, 11);
2056        let t2 = make_trade(200.0, 12);
2057        let t3 = make_trade(-30.0, 13);
2058        let t4 = make_trade(-100.0, 14);
2059        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4];
2060
2061        let s = SubsetStats::from_trades(&refs);
2062        assert!((s.largest_win - 200.0).abs() < f64::EPSILON);
2063        assert!((s.largest_loss - 100.0).abs() < f64::EPSILON);
2064    }
2065
2066    #[test]
2067    fn subset_stats_breakeven_trades() {
2068        let t1 = make_trade(100.0, 11);
2069        let t2 = make_trade(0.0, 12);
2070        let t3 = make_trade(-50.0, 13);
2071        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3];
2072
2073        let s = SubsetStats::from_trades(&refs);
2074        assert_eq!(s.breakeven_trades, 1);
2075        assert_eq!(s.winning_trades, 1);
2076        assert_eq!(s.losing_trades, 1);
2077    }
2078
2079    // ── StreakStats tests ───────────────────────────────────────────
2080
2081    #[test]
2082    fn streaks_alternating() {
2083        let t1 = make_trade(100.0, 11);
2084        let t2 = make_trade(-50.0, 12);
2085        let t3 = make_trade(100.0, 13);
2086        let t4 = make_trade(-50.0, 14);
2087        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4];
2088
2089        let s = StreakStats::from_trades(&refs);
2090        assert_eq!(s.max_consecutive_wins, 1);
2091        assert_eq!(s.max_consecutive_losses, 1);
2092    }
2093
2094    #[test]
2095    fn streaks_consecutive_wins() {
2096        let t1 = make_trade(100.0, 11);
2097        let t2 = make_trade(50.0, 12);
2098        let t3 = make_trade(80.0, 13);
2099        let t4 = make_trade(-50.0, 14);
2100        let t5 = make_trade(100.0, 15);
2101        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4, &t5];
2102
2103        let s = StreakStats::from_trades(&refs);
2104        assert_eq!(s.max_consecutive_wins, 3);
2105        assert_eq!(s.max_consecutive_losses, 1);
2106    }
2107
2108    #[test]
2109    fn streaks_consecutive_losses() {
2110        let t1 = make_trade(-10.0, 11);
2111        let t2 = make_trade(-20.0, 12);
2112        let t3 = make_trade(-30.0, 13);
2113        let t4 = make_trade(-40.0, 14);
2114        let t5 = make_trade(100.0, 15);
2115        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4, &t5];
2116
2117        let s = StreakStats::from_trades(&refs);
2118        assert_eq!(s.max_consecutive_wins, 1);
2119        assert_eq!(s.max_consecutive_losses, 4);
2120    }
2121
2122    #[test]
2123    fn streaks_all_winners() {
2124        let t1 = make_trade(100.0, 11);
2125        let t2 = make_trade(200.0, 12);
2126        let t3 = make_trade(300.0, 13);
2127        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3];
2128
2129        let s = StreakStats::from_trades(&refs);
2130        assert_eq!(s.max_consecutive_wins, 3);
2131        assert_eq!(s.max_consecutive_losses, 0);
2132        assert_eq!(s.current_streak, 3);
2133    }
2134
2135    #[test]
2136    fn streaks_empty() {
2137        let s = StreakStats::from_trades(&[]);
2138        assert_eq!(s.max_consecutive_wins, 0);
2139        assert_eq!(s.max_consecutive_losses, 0);
2140        assert_eq!(s.current_streak, 0);
2141    }
2142
2143    #[test]
2144    fn streaks_breakeven_resets() {
2145        let t1 = make_trade(100.0, 11);
2146        let t2 = make_trade(200.0, 12);
2147        let t3 = make_trade(0.0, 13); // breakeven resets
2148        let t4 = make_trade(100.0, 14);
2149        let refs: Vec<&TradeResult> = vec![&t1, &t2, &t3, &t4];
2150
2151        let s = StreakStats::from_trades(&refs);
2152        assert_eq!(s.max_consecutive_wins, 2); // not 3
2153        assert_eq!(s.current_streak, 1);
2154    }
2155
2156    // ── RiskMetrics tests ───────────────────────────────────────────
2157
2158    #[test]
2159    fn sharpe_ratio_positive() {
2160        // Consistent small wins should produce positive Sharpe.
2161        let trades: Vec<TradeResult> = (0..20)
2162            .map(|i| {
2163                make_trade_full(
2164                    &format!("p{}", i),
2165                    "EURUSD",
2166                    Side::Buy,
2167                    10.0 + (i as f64),
2168                    ts(2026, 1, 1, 10, 0, 0),
2169                    ts(2026, 1, 1 + (i as u32 / 5), 11 + (i as u32 % 12), 0, 0),
2170                    CloseReason::Target,
2171                    None,
2172                )
2173            })
2174            .collect();
2175        let result = BacktestResult::from_trade_log(10_000.0, trades);
2176        assert!(result.risk_metrics.sharpe_ratio.is_some());
2177        assert!(result.risk_metrics.sharpe_ratio.unwrap() > 0.0);
2178    }
2179
2180    #[test]
2181    fn sharpe_ratio_insufficient_data() {
2182        let trades = vec![make_trade(100.0, 11)];
2183        let result = BacktestResult::from_trade_log(10_000.0, trades);
2184        assert!(result.risk_metrics.sharpe_ratio.is_none());
2185    }
2186
2187    #[test]
2188    fn sortino_ratio_no_downside() {
2189        let trades = vec![make_trade(100.0, 11), make_trade(200.0, 12)];
2190        let result = BacktestResult::from_trade_log(10_000.0, trades);
2191        // No negative returns → Sortino undefined.
2192        assert!(result.risk_metrics.sortino_ratio.is_none());
2193    }
2194
2195    #[test]
2196    fn calmar_ratio_zero_drawdown() {
2197        let trades = vec![make_trade(100.0, 11), make_trade(200.0, 12)];
2198        let result = BacktestResult::from_trade_log(10_000.0, trades);
2199        // No drawdown → Calmar undefined.
2200        assert!(result.risk_metrics.calmar_ratio.is_none());
2201    }
2202
2203    #[test]
2204    fn max_drawdown_duration_recovered() {
2205        // Win, then lose (creates drawdown), then win big (recovers).
2206        let trades = vec![
2207            make_trade_full(
2208                "p1",
2209                "EURUSD",
2210                Side::Buy,
2211                100.0,
2212                ts(2026, 1, 1, 10, 0, 0),
2213                ts(2026, 1, 1, 11, 0, 0),
2214                CloseReason::Target,
2215                None,
2216            ),
2217            make_trade_full(
2218                "p2",
2219                "EURUSD",
2220                Side::Buy,
2221                -200.0,
2222                ts(2026, 1, 1, 11, 0, 0),
2223                ts(2026, 1, 2, 11, 0, 0),
2224                CloseReason::Stoploss,
2225                None,
2226            ),
2227            make_trade_full(
2228                "p3",
2229                "EURUSD",
2230                Side::Buy,
2231                300.0,
2232                ts(2026, 1, 2, 11, 0, 0),
2233                ts(2026, 1, 5, 11, 0, 0),
2234                CloseReason::Target,
2235                None,
2236            ),
2237        ];
2238        let result = BacktestResult::from_trade_log(10_000.0, trades);
2239        // Drawdown from day 1 11:00 (peak after first trade) to day 5 11:00 (recovery).
2240        assert!(result.risk_metrics.max_drawdown_duration_secs.is_some());
2241        let dur = result.risk_metrics.max_drawdown_duration_secs.unwrap();
2242        assert!(dur > 0);
2243    }
2244
2245    #[test]
2246    fn max_drawdown_duration_unrecovered() {
2247        // Win then lose — never recovers.
2248        let trades = vec![
2249            make_trade_full(
2250                "p1",
2251                "EURUSD",
2252                Side::Buy,
2253                100.0,
2254                ts(2026, 1, 1, 10, 0, 0),
2255                ts(2026, 1, 1, 11, 0, 0),
2256                CloseReason::Target,
2257                None,
2258            ),
2259            make_trade_full(
2260                "p2",
2261                "EURUSD",
2262                Side::Buy,
2263                -200.0,
2264                ts(2026, 1, 1, 11, 0, 0),
2265                ts(2026, 1, 5, 11, 0, 0),
2266                CloseReason::Stoploss,
2267                None,
2268            ),
2269        ];
2270        let result = BacktestResult::from_trade_log(10_000.0, trades);
2271        assert!(result.risk_metrics.max_drawdown_duration_secs.is_some());
2272        // Duration should span from peak_ts (day 1 11:00) to last trade (day 5 11:00) = 4 days.
2273        let dur = result.risk_metrics.max_drawdown_duration_secs.unwrap();
2274        assert_eq!(dur, 4 * 86400);
2275    }
2276
2277    // ── DurationStats tests ─────────────────────────────────────────
2278
2279    #[test]
2280    fn duration_stats_basic() {
2281        let t1 = make_trade_full(
2282            "p1",
2283            "EURUSD",
2284            Side::Buy,
2285            100.0,
2286            ts(2026, 1, 1, 10, 0, 0),
2287            ts(2026, 1, 1, 12, 0, 0),
2288            CloseReason::Target,
2289            None,
2290        );
2291        let t2 = make_trade_full(
2292            "p2",
2293            "EURUSD",
2294            Side::Buy,
2295            -50.0,
2296            ts(2026, 1, 1, 10, 0, 0),
2297            ts(2026, 1, 1, 14, 0, 0),
2298            CloseReason::Stoploss,
2299            None,
2300        );
2301        let refs: Vec<&TradeResult> = vec![&t1, &t2];
2302
2303        let ds = DurationStats::from_trades(&refs).unwrap();
2304        assert_eq!(ds.min_duration_secs, 7200); // 2h
2305        assert_eq!(ds.max_duration_secs, 14400); // 4h
2306        assert_eq!(ds.avg_duration_secs, 10800); // 3h
2307        assert_eq!(ds.avg_winner_duration_secs, 7200);
2308        assert_eq!(ds.avg_loser_duration_secs, 14400);
2309    }
2310
2311    #[test]
2312    fn duration_stats_single_trade() {
2313        let t1 = make_trade_full(
2314            "p1",
2315            "EURUSD",
2316            Side::Buy,
2317            100.0,
2318            ts(2026, 1, 1, 10, 0, 0),
2319            ts(2026, 1, 1, 11, 0, 0),
2320            CloseReason::Target,
2321            None,
2322        );
2323        let refs: Vec<&TradeResult> = vec![&t1];
2324
2325        let ds = DurationStats::from_trades(&refs).unwrap();
2326        assert_eq!(ds.avg_duration_secs, 3600);
2327        assert_eq!(ds.min_duration_secs, 3600);
2328        assert_eq!(ds.max_duration_secs, 3600);
2329    }
2330
2331    #[test]
2332    fn duration_stats_empty() {
2333        assert!(DurationStats::from_trades(&[]).is_none());
2334    }
2335
2336    #[test]
2337    fn duration_stats_winner_vs_loser() {
2338        // Winners held shorter, losers longer.
2339        let t1 = make_trade_full(
2340            "p1",
2341            "EURUSD",
2342            Side::Buy,
2343            100.0,
2344            ts(2026, 1, 1, 10, 0, 0),
2345            ts(2026, 1, 1, 10, 30, 0),
2346            CloseReason::Target,
2347            None,
2348        );
2349        let t2 = make_trade_full(
2350            "p2",
2351            "EURUSD",
2352            Side::Buy,
2353            -50.0,
2354            ts(2026, 1, 1, 10, 0, 0),
2355            ts(2026, 1, 1, 16, 0, 0),
2356            CloseReason::Stoploss,
2357            None,
2358        );
2359        let refs: Vec<&TradeResult> = vec![&t1, &t2];
2360
2361        let ds = DurationStats::from_trades(&refs).unwrap();
2362        assert!(ds.avg_winner_duration_secs < ds.avg_loser_duration_secs);
2363    }
2364
2365    // ── MonthlyReturn tests ─────────────────────────────────────────
2366
2367    #[test]
2368    fn monthly_returns_single_month() {
2369        let trades = vec![
2370            make_trade_full(
2371                "p1",
2372                "EURUSD",
2373                Side::Buy,
2374                100.0,
2375                ts(2026, 1, 5, 10, 0, 0),
2376                ts(2026, 1, 10, 10, 0, 0),
2377                CloseReason::Target,
2378                None,
2379            ),
2380            make_trade_full(
2381                "p2",
2382                "EURUSD",
2383                Side::Buy,
2384                -30.0,
2385                ts(2026, 1, 12, 10, 0, 0),
2386                ts(2026, 1, 15, 10, 0, 0),
2387                CloseReason::Stoploss,
2388                None,
2389            ),
2390        ];
2391        let monthly = compute_monthly_returns(&trades, 10_000.0);
2392        assert_eq!(monthly.len(), 1);
2393        assert_eq!(monthly[0].year, 2026);
2394        assert_eq!(monthly[0].month, 1);
2395        assert!((monthly[0].pnl - 70.0).abs() < f64::EPSILON);
2396        assert_eq!(monthly[0].trade_count, 2);
2397    }
2398
2399    #[test]
2400    fn monthly_returns_multi_month() {
2401        let trades = vec![
2402            make_trade_full(
2403                "p1",
2404                "EURUSD",
2405                Side::Buy,
2406                100.0,
2407                ts(2026, 1, 5, 10, 0, 0),
2408                ts(2026, 1, 10, 10, 0, 0),
2409                CloseReason::Target,
2410                None,
2411            ),
2412            make_trade_full(
2413                "p2",
2414                "EURUSD",
2415                Side::Buy,
2416                200.0,
2417                ts(2026, 2, 5, 10, 0, 0),
2418                ts(2026, 2, 10, 10, 0, 0),
2419                CloseReason::Target,
2420                None,
2421            ),
2422            make_trade_full(
2423                "p3",
2424                "EURUSD",
2425                Side::Buy,
2426                -50.0,
2427                ts(2026, 3, 5, 10, 0, 0),
2428                ts(2026, 3, 10, 10, 0, 0),
2429                CloseReason::Stoploss,
2430                None,
2431            ),
2432        ];
2433        let monthly = compute_monthly_returns(&trades, 10_000.0);
2434        assert_eq!(monthly.len(), 3);
2435        assert_eq!(monthly[0].month, 1);
2436        assert_eq!(monthly[1].month, 2);
2437        assert_eq!(monthly[2].month, 3);
2438    }
2439
2440    #[test]
2441    fn monthly_returns_ending_balance() {
2442        let trades = vec![
2443            make_trade_full(
2444                "p1",
2445                "EURUSD",
2446                Side::Buy,
2447                100.0,
2448                ts(2026, 1, 5, 10, 0, 0),
2449                ts(2026, 1, 10, 10, 0, 0),
2450                CloseReason::Target,
2451                None,
2452            ),
2453            make_trade_full(
2454                "p2",
2455                "EURUSD",
2456                Side::Buy,
2457                200.0,
2458                ts(2026, 2, 5, 10, 0, 0),
2459                ts(2026, 2, 10, 10, 0, 0),
2460                CloseReason::Target,
2461                None,
2462            ),
2463        ];
2464        let monthly = compute_monthly_returns(&trades, 10_000.0);
2465        assert!((monthly[0].ending_balance - 10_100.0).abs() < f64::EPSILON);
2466        assert!((monthly[1].ending_balance - 10_300.0).abs() < f64::EPSILON);
2467    }
2468
2469    // ── Per-breakdown tests ─────────────────────────────────────────
2470
2471    #[test]
2472    fn per_symbol_breakdown() {
2473        let trades = vec![
2474            make_trade_full(
2475                "p1",
2476                "EURUSD",
2477                Side::Buy,
2478                100.0,
2479                ts(2026, 1, 1, 10, 0, 0),
2480                ts(2026, 1, 1, 11, 0, 0),
2481                CloseReason::Target,
2482                None,
2483            ),
2484            make_trade_full(
2485                "p2",
2486                "XAUUSD",
2487                Side::Buy,
2488                -50.0,
2489                ts(2026, 1, 1, 10, 0, 0),
2490                ts(2026, 1, 1, 12, 0, 0),
2491                CloseReason::Stoploss,
2492                None,
2493            ),
2494            make_trade_full(
2495                "p3",
2496                "EURUSD",
2497                Side::Buy,
2498                200.0,
2499                ts(2026, 1, 1, 10, 0, 0),
2500                ts(2026, 1, 1, 13, 0, 0),
2501                CloseReason::Target,
2502                None,
2503            ),
2504        ];
2505        let result = BacktestResult::from_trade_log(10_000.0, trades);
2506        assert_eq!(result.per_symbol.len(), 2);
2507
2508        let eu = result.per_symbol.get("EURUSD").unwrap();
2509        assert_eq!(eu.total_trades, 2);
2510        assert!((eu.total_pnl - 300.0).abs() < f64::EPSILON);
2511
2512        let xau = result.per_symbol.get("XAUUSD").unwrap();
2513        assert_eq!(xau.total_trades, 1);
2514        assert!((xau.total_pnl - -50.0).abs() < f64::EPSILON);
2515    }
2516
2517    #[test]
2518    fn per_side_breakdown() {
2519        let trades = vec![
2520            make_trade_full(
2521                "p1",
2522                "EURUSD",
2523                Side::Buy,
2524                100.0,
2525                ts(2026, 1, 1, 10, 0, 0),
2526                ts(2026, 1, 1, 11, 0, 0),
2527                CloseReason::Target,
2528                None,
2529            ),
2530            make_trade_full(
2531                "p2",
2532                "EURUSD",
2533                Side::Sell,
2534                -50.0,
2535                ts(2026, 1, 1, 10, 0, 0),
2536                ts(2026, 1, 1, 12, 0, 0),
2537                CloseReason::Stoploss,
2538                None,
2539            ),
2540            make_trade_full(
2541                "p3",
2542                "EURUSD",
2543                Side::Buy,
2544                200.0,
2545                ts(2026, 1, 1, 10, 0, 0),
2546                ts(2026, 1, 1, 13, 0, 0),
2547                CloseReason::Target,
2548                None,
2549            ),
2550        ];
2551        let result = BacktestResult::from_trade_log(10_000.0, trades);
2552        assert_eq!(result.long_stats.total_trades, 2);
2553        assert_eq!(result.short_stats.total_trades, 1);
2554        assert!((result.long_stats.total_pnl - 300.0).abs() < f64::EPSILON);
2555        assert!((result.short_stats.total_pnl - -50.0).abs() < f64::EPSILON);
2556    }
2557
2558    #[test]
2559    fn per_close_reason_breakdown() {
2560        let trades = vec![
2561            make_trade_full(
2562                "p1",
2563                "EURUSD",
2564                Side::Buy,
2565                100.0,
2566                ts(2026, 1, 1, 10, 0, 0),
2567                ts(2026, 1, 1, 11, 0, 0),
2568                CloseReason::Target,
2569                None,
2570            ),
2571            make_trade_full(
2572                "p2",
2573                "EURUSD",
2574                Side::Buy,
2575                80.0,
2576                ts(2026, 1, 1, 10, 0, 0),
2577                ts(2026, 1, 1, 12, 0, 0),
2578                CloseReason::Target,
2579                None,
2580            ),
2581            make_trade_full(
2582                "p3",
2583                "EURUSD",
2584                Side::Buy,
2585                -50.0,
2586                ts(2026, 1, 1, 10, 0, 0),
2587                ts(2026, 1, 1, 13, 0, 0),
2588                CloseReason::Stoploss,
2589                None,
2590            ),
2591            make_trade_full(
2592                "p4",
2593                "EURUSD",
2594                Side::Buy,
2595                30.0,
2596                ts(2026, 1, 1, 10, 0, 0),
2597                ts(2026, 1, 1, 14, 0, 0),
2598                CloseReason::TrailingStop,
2599                None,
2600            ),
2601        ];
2602        let result = BacktestResult::from_trade_log(10_000.0, trades);
2603
2604        assert_eq!(result.per_close_reason.len(), 3);
2605        // Sorted by count descending — Target (2), then SL (1) and Trailing (1).
2606        assert_eq!(result.per_close_reason[0].reason, CloseReason::Target);
2607        assert_eq!(result.per_close_reason[0].count, 2);
2608        assert_eq!(result.per_close_reason[1].reason, CloseReason::Stoploss);
2609        assert_eq!(result.per_close_reason[2].reason, CloseReason::TrailingStop);
2610        assert!((result.per_close_reason[0].percentage - 0.5).abs() < f64::EPSILON);
2611    }
2612
2613    #[test]
2614    fn per_group_breakdown() {
2615        let trades = vec![
2616            make_trade_full(
2617                "p1",
2618                "EURUSD",
2619                Side::Buy,
2620                100.0,
2621                ts(2026, 1, 1, 10, 0, 0),
2622                ts(2026, 1, 1, 11, 0, 0),
2623                CloseReason::Target,
2624                Some("momentum".into()),
2625            ),
2626            make_trade_full(
2627                "p2",
2628                "EURUSD",
2629                Side::Buy,
2630                -50.0,
2631                ts(2026, 1, 1, 10, 0, 0),
2632                ts(2026, 1, 1, 12, 0, 0),
2633                CloseReason::Stoploss,
2634                Some("reversion".into()),
2635            ),
2636            make_trade_full(
2637                "p3",
2638                "EURUSD",
2639                Side::Buy,
2640                200.0,
2641                ts(2026, 1, 1, 10, 0, 0),
2642                ts(2026, 1, 1, 13, 0, 0),
2643                CloseReason::Target,
2644                Some("momentum".into()),
2645            ),
2646        ];
2647        let result = BacktestResult::from_trade_log(10_000.0, trades);
2648
2649        assert_eq!(result.per_group.len(), 2);
2650        let mom = result.per_group.get("momentum").unwrap();
2651        assert_eq!(mom.total_trades, 2);
2652        assert!((mom.total_pnl - 300.0).abs() < f64::EPSILON);
2653        let rev = result.per_group.get("reversion").unwrap();
2654        assert_eq!(rev.total_trades, 1);
2655    }
2656
2657    #[test]
2658    fn per_group_empty_when_no_groups() {
2659        let trades = vec![make_trade(100.0, 11), make_trade(-50.0, 12)];
2660        let result = BacktestResult::from_trade_log(10_000.0, trades);
2661        assert!(result.per_group.is_empty());
2662    }
2663
2664    // ── PositionSummary tests ───────────────────────────────────────
2665
2666    #[test]
2667    fn position_summary_single_close() {
2668        let t1 = make_trade_full(
2669            "p1",
2670            "EURUSD",
2671            Side::Buy,
2672            100.0,
2673            ts(2026, 1, 1, 10, 0, 0),
2674            ts(2026, 1, 1, 12, 0, 0),
2675            CloseReason::Target,
2676            None,
2677        );
2678        let refs: Vec<&TradeResult> = vec![&t1];
2679
2680        let ps = PositionSummary::from_trades(&refs);
2681        assert_eq!(ps.position_id, "p1");
2682        assert_eq!(ps.close_count, 1);
2683        assert!((ps.net_pnl - 100.0).abs() < f64::EPSILON);
2684        assert!(ps.is_winner());
2685        assert!(!ps.is_loser());
2686    }
2687
2688    #[test]
2689    fn position_summary_multiple_closes() {
2690        // TP1 wins, then SL loses — net positive.
2691        let t1 = TradeResult {
2692            position_id: "p1".into(),
2693            symbol: "EURUSD".into(),
2694            side: Side::Buy,
2695            entry_price: 1.0850,
2696            exit_price: 1.0900,
2697            size: 0.5,
2698            pnl: 25.0,
2699            open_ts: ts(2026, 1, 1, 10, 0, 0),
2700            close_ts: ts(2026, 1, 1, 11, 0, 0),
2701            close_reason: CloseReason::Target,
2702            group: None,
2703            commission: 0.0,
2704            swap: 0.0,
2705            gross_pnl: None,
2706        };
2707        let t2 = TradeResult {
2708            position_id: "p1".into(),
2709            symbol: "EURUSD".into(),
2710            side: Side::Buy,
2711            entry_price: 1.0850,
2712            exit_price: 1.0830,
2713            size: 0.5,
2714            pnl: -10.0,
2715            open_ts: ts(2026, 1, 1, 10, 0, 0),
2716            close_ts: ts(2026, 1, 1, 14, 0, 0),
2717            close_reason: CloseReason::Stoploss,
2718            group: None,
2719            commission: 0.0,
2720            swap: 0.0,
2721            gross_pnl: None,
2722        };
2723        let refs: Vec<&TradeResult> = vec![&t1, &t2];
2724        let ps = PositionSummary::from_trades(&refs);
2725
2726        assert_eq!(ps.close_count, 2);
2727        assert!((ps.entry_price - 1.0850).abs() < f64::EPSILON);
2728        assert!((ps.net_pnl - 15.0).abs() < f64::EPSILON);
2729        assert!((ps.original_size - 1.0).abs() < f64::EPSILON);
2730        assert!(ps.is_winner());
2731        assert_eq!(
2732            ps.close_reasons,
2733            vec![CloseReason::Target, CloseReason::Stoploss]
2734        );
2735        assert_eq!(ps.duration_seconds, 4 * 3600); // 10:00 to 14:00
2736    }
2737
2738    #[test]
2739    fn position_summary_weights_changing_close_time_entry_basis() {
2740        let first_partial_close = TradeResult {
2741            position_id: "scaled".into(),
2742            symbol: "TEST".into(),
2743            side: Side::Buy,
2744            entry_price: 100.0,
2745            exit_price: 110.0,
2746            size: 1.0,
2747            pnl: 10.0,
2748            open_ts: ts_hms(10, 0, 0),
2749            close_ts: ts_hms(11, 0, 0),
2750            close_reason: CloseReason::Target,
2751            group: None,
2752            commission: 0.0,
2753            swap: 0.0,
2754            gross_pnl: None,
2755        };
2756        let close_after_scale_in = TradeResult {
2757            position_id: "scaled".into(),
2758            symbol: "TEST".into(),
2759            side: Side::Buy,
2760            entry_price: 120.0,
2761            exit_price: 125.0,
2762            size: 3.0,
2763            pnl: 15.0,
2764            open_ts: ts_hms(10, 0, 0),
2765            close_ts: ts_hms(12, 0, 0),
2766            close_reason: CloseReason::Manual,
2767            group: None,
2768            commission: 0.0,
2769            swap: 0.0,
2770            gross_pnl: None,
2771        };
2772        let trades = [&first_partial_close, &close_after_scale_in];
2773
2774        let summary = PositionSummary::from_trades(&trades);
2775
2776        assert!((summary.entry_price - 115.0).abs() < f64::EPSILON);
2777        assert!((summary.avg_exit_price - 121.25).abs() < f64::EPSILON);
2778    }
2779
2780    #[test]
2781    fn position_summary_weighted_prices_conserve_pnl() {
2782        let first_partial_close = TradeResult {
2783            position_id: "scaled".into(),
2784            symbol: "TEST".into(),
2785            side: Side::Buy,
2786            entry_price: 100.0,
2787            exit_price: 110.0,
2788            size: 1.0,
2789            pnl: 10.0,
2790            open_ts: ts_hms(10, 0, 0),
2791            close_ts: ts_hms(11, 0, 0),
2792            close_reason: CloseReason::Target,
2793            group: None,
2794            commission: 0.0,
2795            swap: 0.0,
2796            gross_pnl: None,
2797        };
2798        let close_after_scale_in = TradeResult {
2799            position_id: "scaled".into(),
2800            symbol: "TEST".into(),
2801            side: Side::Buy,
2802            entry_price: 120.0,
2803            exit_price: 125.0,
2804            size: 3.0,
2805            pnl: 15.0,
2806            open_ts: ts_hms(10, 0, 0),
2807            close_ts: ts_hms(12, 0, 0),
2808            close_reason: CloseReason::Manual,
2809            group: None,
2810            commission: 0.0,
2811            swap: 0.0,
2812            gross_pnl: None,
2813        };
2814        let trades = [&first_partial_close, &close_after_scale_in];
2815
2816        let summary = PositionSummary::from_trades(&trades);
2817        let pnl_from_close_rows = trades
2818            .iter()
2819            .map(|trade| (trade.exit_price - trade.entry_price) * trade.size)
2820            .sum::<f64>();
2821        let pnl_from_summary =
2822            (summary.avg_exit_price - summary.entry_price) * summary.original_size;
2823
2824        assert!((summary.net_pnl - pnl_from_close_rows).abs() < f64::EPSILON);
2825        assert!((pnl_from_summary - pnl_from_close_rows).abs() < f64::EPSILON);
2826    }
2827
2828    #[test]
2829    fn position_win_rate_differs_from_trade_win_rate() {
2830        // Position p1: TP1 +$25, SL -$10 → net +$15 (position is a winner)
2831        // Trade-level: 1 win, 1 loss → 50% win rate
2832        // Position-level: 1 winner / 1 total → 100% win rate
2833        let trades = vec![
2834            TradeResult {
2835                position_id: "p1".into(),
2836                symbol: "EURUSD".into(),
2837                side: Side::Buy,
2838                entry_price: 1.085,
2839                exit_price: 1.090,
2840                size: 0.5,
2841                pnl: 25.0,
2842                open_ts: ts(2026, 1, 1, 10, 0, 0),
2843                close_ts: ts(2026, 1, 1, 11, 0, 0),
2844                close_reason: CloseReason::Target,
2845                group: None,
2846                commission: 0.0,
2847                swap: 0.0,
2848                gross_pnl: None,
2849            },
2850            TradeResult {
2851                position_id: "p1".into(),
2852                symbol: "EURUSD".into(),
2853                side: Side::Buy,
2854                entry_price: 1.085,
2855                exit_price: 1.083,
2856                size: 0.5,
2857                pnl: -10.0,
2858                open_ts: ts(2026, 1, 1, 10, 0, 0),
2859                close_ts: ts(2026, 1, 1, 14, 0, 0),
2860                close_reason: CloseReason::Stoploss,
2861                group: None,
2862                commission: 0.0,
2863                swap: 0.0,
2864                gross_pnl: None,
2865            },
2866        ];
2867        let result = BacktestResult::from_trade_log(10_000.0, trades);
2868
2869        // Trade-level.
2870        assert_eq!(result.total_trades, 2);
2871        assert!((result.win_rate - 0.5).abs() < f64::EPSILON);
2872
2873        // Position-level.
2874        assert_eq!(result.total_positions, 1);
2875        assert_eq!(result.winning_positions, 1);
2876        assert!((result.position_win_rate - 1.0).abs() < f64::EPSILON);
2877    }
2878
2879    fn completed_position(
2880        position_id: &str,
2881        pnl: f64,
2882        epsilon: f64,
2883        open_ts: NaiveDateTime,
2884        close_ts: NaiveDateTime,
2885    ) -> CompletedPosition {
2886        let reason = if pnl > 0.0 {
2887            CloseReason::Target
2888        } else {
2889            CloseReason::Stoploss
2890        };
2891        let close = CloseEvent::new(
2892            position_id,
2893            0,
2894            "ES",
2895            Side::Buy,
2896            close_ts,
2897            1.0,
2898            100.0 + pnl,
2899            pnl,
2900            reason,
2901        );
2902        CompletedPosition::from_close_events(
2903            position_id,
2904            "ES",
2905            Side::Buy,
2906            open_ts,
2907            close_ts,
2908            1.0,
2909            100.0,
2910            None,
2911            None,
2912            Vec::new(),
2913            vec![close],
2914            None,
2915            None,
2916            epsilon,
2917        )
2918    }
2919
2920    #[test]
2921    fn automatic_provider_report_applies_or_within_and_and_between_filters() {
2922        let mut matching_es =
2923            completed_position("es-long", 1.0, 0.001, ts_hms(9, 0, 0), ts_hms(10, 0, 0));
2924        matching_es.group = Some("trend".into());
2925
2926        let mut matching_nq =
2927            completed_position("nq-long", 2.0, 0.001, ts_hms(10, 0, 0), ts_hms(11, 0, 0));
2928        matching_nq.symbol = "NQ".into();
2929        matching_nq.group = Some("trend".into());
2930
2931        let mut wrong_side =
2932            completed_position("es-short", 3.0, 0.001, ts_hms(11, 0, 0), ts_hms(12, 0, 0));
2933        wrong_side.side = Side::Sell;
2934        wrong_side.group = Some("trend".into());
2935
2936        let mut wrong_group =
2937            completed_position("es-other", 4.0, 0.001, ts_hms(12, 0, 0), ts_hms(13, 0, 0));
2938        wrong_group.group = Some("countertrend".into());
2939
2940        let artifacts = FutureBacktestArtifacts {
2941            execution: ExecutionMetadata {
2942                initial_balance: 10_000.0,
2943                ..ExecutionMetadata::default()
2944            },
2945            completed_positions: vec![matching_es, matching_nq, wrong_side, wrong_group],
2946            ..FutureBacktestArtifacts::default()
2947        };
2948        let result = BacktestResult::from_future_artifacts_with_options(
2949            artifacts,
2950            EvaluationOptions {
2951                sections: BTreeSet::from([
2952                    EvaluationSection::Coverage,
2953                    EvaluationSection::PositionPerformance,
2954                ]),
2955                filter: PositionFilter {
2956                    symbols: vec!["ES".into(), "NQ".into()],
2957                    sides: vec![PositionSide::Long],
2958                    groups: vec![GroupFilter::Named("trend".into())],
2959                    close_reasons: vec!["Target".into(), "Manual".into()],
2960                    ..PositionFilter::default()
2961                },
2962                ..EvaluationOptions::default()
2963            },
2964        );
2965        let evaluation = result
2966            .provider_evaluation
2967            .expect("FutureQuote result includes provider evaluation");
2968        let coverage = evaluation.coverage.expect("coverage requested");
2969        let performance = evaluation
2970            .position_performance
2971            .expect("position performance requested");
2972
2973        assert_eq!(coverage.provided_positions, 4);
2974        assert_eq!(coverage.selected_positions, 2);
2975        assert_eq!(coverage.filtered_out_positions, 2);
2976        assert_eq!(performance.position_count, 2);
2977        assert_eq!(performance.total_outcome.value, Some(3.0));
2978        assert!(evaluation.r_metrics.is_none());
2979    }
2980
2981    #[test]
2982    fn future_position_statistics_exclude_partially_closed_open_campaigns() {
2983        let completed =
2984            completed_position("completed", -10.0, 0.001, ts_hms(9, 0, 0), ts_hms(11, 0, 0));
2985        let mut partial = CloseEvent::new(
2986            "still-open",
2987            0,
2988            "ES",
2989            Side::Buy,
2990            ts_hms(12, 0, 0),
2991            0.5,
2992            110.0,
2993            100.0,
2994            CloseReason::Target,
2995        );
2996        partial.remaining_size = Some(0.5);
2997        let open = OpenPositionSnapshot {
2998            position_id: "still-open".into(),
2999            symbol: "ES".into(),
3000            side: Side::Buy,
3001            open_ts: Some(ts_hms(10, 0, 0)),
3002            average_entry_price: 100.0,
3003            remaining_size: 0.5,
3004            realized_pnl: 100.0,
3005            ..OpenPositionSnapshot::default()
3006        };
3007        let artifacts = FutureBacktestArtifacts {
3008            execution: ExecutionMetadata {
3009                initial_balance: 10_000.0,
3010                pnl_epsilon: 0.001,
3011                ..ExecutionMetadata::default()
3012            },
3013            close_events: vec![completed.close_events[0].clone(), partial],
3014            completed_positions: vec![completed],
3015            open_positions: vec![open],
3016            ..FutureBacktestArtifacts::default()
3017        };
3018
3019        let result = BacktestResult::from_future_artifacts(artifacts);
3020
3021        assert_eq!(result.total_trades, 2);
3022        assert_eq!(result.trade_log.len(), 2);
3023        assert_eq!(result.close_events.len(), 2);
3024        assert!(
3025            result
3026                .trade_log
3027                .iter()
3028                .any(|row| row.position_id == "still-open")
3029        );
3030        assert_eq!(result.total_positions, 1);
3031        assert_eq!(result.winning_positions, 0);
3032        assert_eq!(result.losing_positions, 1);
3033        assert_eq!(result.position_win_rate, 0.0);
3034        assert_eq!(result.positions.len(), 1);
3035        assert_eq!(result.positions[0].position_id, "completed");
3036        assert_eq!(result.positions[0].net_pnl, -10.0);
3037        assert_eq!(result.streaks.max_consecutive_wins, 0);
3038        assert_eq!(result.streaks.max_consecutive_losses, 1);
3039        assert_eq!(result.streaks.current_streak, -1);
3040        let duration = result
3041            .duration_stats
3042            .expect("one completed campaign has duration stats");
3043        assert_eq!(duration.avg_duration_secs, 2 * 3600);
3044        assert_eq!(result.monthly_returns.len(), 1);
3045        assert_eq!(result.monthly_returns[0].trade_count, 1);
3046        assert_eq!(result.monthly_returns[0].pnl, -10.0);
3047    }
3048
3049    #[test]
3050    fn future_trade_reconstruction_uses_each_close_inventory_basis() {
3051        let mut first = CloseEvent::new(
3052            "campaign",
3053            0,
3054            "ES",
3055            Side::Buy,
3056            ts_hms(11, 0, 0),
3057            1.0,
3058            110.0,
3059            10.0,
3060            CloseReason::Manual,
3061        );
3062        first.entry_price = Some(100.0);
3063        let mut final_close = CloseEvent::new(
3064            "campaign",
3065            1,
3066            "ES",
3067            Side::Buy,
3068            ts_hms(12, 0, 0),
3069            2.0,
3070            130.0,
3071            40.0,
3072            CloseReason::Manual,
3073        );
3074        final_close.entry_price = Some(110.0);
3075        let completed = CompletedPosition::from_close_events(
3076            "campaign",
3077            "ES",
3078            Side::Buy,
3079            ts_hms(10, 0, 0),
3080            ts_hms(12, 0, 0),
3081            3.0,
3082            320.0 / 3.0,
3083            None,
3084            None,
3085            vec![],
3086            vec![first.clone(), final_close.clone()],
3087            None,
3088            None,
3089            crate::artifacts::DEFAULT_PNL_EPSILON,
3090        );
3091        let result = BacktestResult::from_future_artifacts(FutureBacktestArtifacts {
3092            execution: ExecutionMetadata {
3093                initial_balance: 10_000.0,
3094                ..ExecutionMetadata::default()
3095            },
3096            close_events: vec![first, final_close],
3097            completed_positions: vec![completed],
3098            ..FutureBacktestArtifacts::default()
3099        });
3100
3101        assert_eq!(result.trade_log[0].entry_price, 100.0);
3102        assert_eq!(result.trade_log[1].entry_price, 110.0);
3103        assert_eq!(result.total_pnl, 50.0);
3104    }
3105
3106    #[test]
3107    fn future_partial_tp_then_sl_is_one_breakeven_for_campaign_analytics() {
3108        let open_ts = ts(2026, 1, 31, 22, 0, 0);
3109        let partial_ts = ts(2026, 1, 31, 23, 0, 0);
3110        let close_ts = ts(2026, 2, 1, 2, 0, 0);
3111        let partial_tp = CloseEvent::new(
3112            "campaign",
3113            0,
3114            "ES",
3115            Side::Buy,
3116            partial_ts,
3117            0.5,
3118            150.0,
3119            50.0,
3120            CloseReason::Target,
3121        );
3122        let final_sl = CloseEvent::new(
3123            "campaign",
3124            1,
3125            "ES",
3126            Side::Buy,
3127            close_ts,
3128            0.5,
3129            50.0,
3130            -50.0,
3131            CloseReason::Stoploss,
3132        );
3133        let completed = CompletedPosition::from_close_events(
3134            "campaign",
3135            "ES",
3136            Side::Buy,
3137            open_ts,
3138            close_ts,
3139            1.0,
3140            100.0,
3141            None,
3142            None,
3143            Vec::new(),
3144            vec![partial_tp.clone(), final_sl.clone()],
3145            None,
3146            None,
3147            0.001,
3148        );
3149        assert_eq!(completed.outcome, NetPnlOutcome::Breakeven);
3150        let result = BacktestResult::from_future_artifacts(FutureBacktestArtifacts {
3151            execution: ExecutionMetadata {
3152                initial_balance: 10_000.0,
3153                pnl_epsilon: 0.001,
3154                ..ExecutionMetadata::default()
3155            },
3156            close_events: vec![partial_tp, final_sl],
3157            completed_positions: vec![completed],
3158            ..FutureBacktestArtifacts::default()
3159        });
3160
3161        // Close-event fields remain legacy-compatible.
3162        assert_eq!(result.total_trades, 2);
3163        assert_eq!(result.winning_trades, 1);
3164        assert_eq!(result.losing_trades, 1);
3165
3166        // Campaign analytics count only the completed net outcome.
3167        assert_eq!(result.total_positions, 1);
3168        assert_eq!(result.winning_positions, 0);
3169        assert_eq!(result.losing_positions, 0);
3170        assert_eq!(result.streaks.max_consecutive_wins, 0);
3171        assert_eq!(result.streaks.max_consecutive_losses, 0);
3172        assert_eq!(result.streaks.current_streak, 0);
3173        let duration = result
3174            .duration_stats
3175            .expect("one completed campaign has duration stats");
3176        assert_eq!(duration.avg_duration_secs, 4 * 3600);
3177        assert_eq!(duration.min_duration_secs, 4 * 3600);
3178        assert_eq!(duration.max_duration_secs, 4 * 3600);
3179        assert_eq!(duration.avg_winner_duration_secs, 0);
3180        assert_eq!(duration.avg_loser_duration_secs, 0);
3181        assert_eq!(result.monthly_returns.len(), 1);
3182        assert_eq!(result.monthly_returns[0].year, 2026);
3183        assert_eq!(result.monthly_returns[0].month, 2);
3184        assert_eq!(result.monthly_returns[0].trade_count, 1);
3185        assert_eq!(result.monthly_returns[0].pnl, 0.0);
3186        assert_eq!(result.monthly_returns[0].ending_balance, 10_000.0);
3187    }
3188
3189    #[test]
3190    fn future_position_and_provider_statistics_use_configured_breakeven_outcome() {
3191        let completed =
3192            completed_position("tiny", 0.0005, 0.001, ts_hms(9, 0, 0), ts_hms(11, 0, 0));
3193        assert_eq!(completed.outcome, NetPnlOutcome::Breakeven);
3194        let artifacts = FutureBacktestArtifacts {
3195            execution: ExecutionMetadata {
3196                initial_balance: 10_000.0,
3197                pnl_epsilon: 0.001,
3198                ..ExecutionMetadata::default()
3199            },
3200            close_events: completed.close_events.clone(),
3201            completed_positions: vec![completed],
3202            ..FutureBacktestArtifacts::default()
3203        };
3204
3205        let result = BacktestResult::from_future_artifacts(artifacts);
3206        let performance = &result
3207            .provider_evaluation
3208            .as_ref()
3209            .expect("future reports include provider evaluation")
3210            .position_performance
3211            .as_ref()
3212            .expect("position performance requested");
3213
3214        assert_eq!(result.total_positions, 1);
3215        assert_eq!(result.winning_positions, 0);
3216        assert_eq!(result.losing_positions, 0);
3217        assert_eq!(performance.wins, 0);
3218        assert_eq!(performance.losses, 0);
3219        assert_eq!(performance.breakeven, 1);
3220        assert_eq!(performance.total_outcome.value, Some(0.0005));
3221        assert_eq!(performance.gross_positive.value, Some(0.0));
3222    }
3223
3224    #[test]
3225    fn legacy_from_trade_log_keeps_exact_zero_position_classification() {
3226        let result = BacktestResult::from_trade_log(10_000.0, vec![make_trade(0.0005, 11)]);
3227
3228        assert_eq!(result.total_positions, 1);
3229        assert_eq!(result.winning_positions, 1);
3230        assert_eq!(result.losing_positions, 0);
3231        assert_eq!(result.position_win_rate, 1.0);
3232    }
3233
3234    // ── Integration tests ───────────────────────────────────────────
3235
3236    #[test]
3237    fn full_report_matches_summary() {
3238        let trades = vec![
3239            make_trade_full(
3240                "p1",
3241                "EURUSD",
3242                Side::Buy,
3243                100.0,
3244                ts(2026, 1, 1, 10, 0, 0),
3245                ts(2026, 1, 1, 11, 0, 0),
3246                CloseReason::Target,
3247                None,
3248            ),
3249            make_trade_full(
3250                "p2",
3251                "EURUSD",
3252                Side::Sell,
3253                -50.0,
3254                ts(2026, 1, 1, 10, 0, 0),
3255                ts(2026, 1, 1, 12, 0, 0),
3256                CloseReason::Stoploss,
3257                None,
3258            ),
3259            make_trade_full(
3260                "p3",
3261                "XAUUSD",
3262                Side::Buy,
3263                200.0,
3264                ts(2026, 1, 2, 10, 0, 0),
3265                ts(2026, 1, 2, 13, 0, 0),
3266                CloseReason::Target,
3267                None,
3268            ),
3269        ];
3270        let result = BacktestResult::from_trade_log(10_000.0, trades);
3271
3272        // Summary should match top-level fields.
3273        assert!((result.summary.total_pnl - result.total_pnl).abs() < f64::EPSILON);
3274        assert_eq!(result.summary.total_trades, result.total_trades);
3275        assert_eq!(result.summary.winning_trades, result.winning_trades);
3276        assert_eq!(result.summary.losing_trades, result.losing_trades);
3277        assert!((result.summary.win_rate - result.win_rate).abs() < f64::EPSILON);
3278        assert!((result.summary.profit_factor - result.profit_factor).abs() < f64::EPSILON);
3279    }
3280
3281    #[test]
3282    fn per_symbol_sums_to_overall() {
3283        let trades = vec![
3284            make_trade_full(
3285                "p1",
3286                "EURUSD",
3287                Side::Buy,
3288                100.0,
3289                ts(2026, 1, 1, 10, 0, 0),
3290                ts(2026, 1, 1, 11, 0, 0),
3291                CloseReason::Target,
3292                None,
3293            ),
3294            make_trade_full(
3295                "p2",
3296                "XAUUSD",
3297                Side::Buy,
3298                -50.0,
3299                ts(2026, 1, 1, 10, 0, 0),
3300                ts(2026, 1, 1, 12, 0, 0),
3301                CloseReason::Stoploss,
3302                None,
3303            ),
3304            make_trade_full(
3305                "p3",
3306                "GBPUSD",
3307                Side::Sell,
3308                80.0,
3309                ts(2026, 1, 1, 10, 0, 0),
3310                ts(2026, 1, 1, 13, 0, 0),
3311                CloseReason::Target,
3312                None,
3313            ),
3314        ];
3315        let result = BacktestResult::from_trade_log(10_000.0, trades);
3316
3317        let sym_total_trades: usize = result.per_symbol.values().map(|s| s.total_trades).sum();
3318        let sym_total_pnl: f64 = result.per_symbol.values().map(|s| s.total_pnl).sum();
3319
3320        assert_eq!(sym_total_trades, result.total_trades);
3321        assert!((sym_total_pnl - result.total_pnl).abs() < 1e-10);
3322    }
3323
3324    #[test]
3325    fn per_side_sums_to_overall() {
3326        let trades = vec![
3327            make_trade_full(
3328                "p1",
3329                "EURUSD",
3330                Side::Buy,
3331                100.0,
3332                ts(2026, 1, 1, 10, 0, 0),
3333                ts(2026, 1, 1, 11, 0, 0),
3334                CloseReason::Target,
3335                None,
3336            ),
3337            make_trade_full(
3338                "p2",
3339                "EURUSD",
3340                Side::Sell,
3341                -50.0,
3342                ts(2026, 1, 1, 10, 0, 0),
3343                ts(2026, 1, 1, 12, 0, 0),
3344                CloseReason::Stoploss,
3345                None,
3346            ),
3347        ];
3348        let result = BacktestResult::from_trade_log(10_000.0, trades);
3349
3350        let side_trades = result.long_stats.total_trades + result.short_stats.total_trades;
3351        let side_pnl = result.long_stats.total_pnl + result.short_stats.total_pnl;
3352
3353        assert_eq!(side_trades, result.total_trades);
3354        assert!((side_pnl - result.total_pnl).abs() < 1e-10);
3355    }
3356
3357    #[test]
3358    fn display_does_not_panic_with_new_fields() {
3359        // Empty.
3360        let r1 = BacktestResult::from_trade_log(10_000.0, vec![]);
3361        let _ = format!("{}", r1);
3362
3363        // Single trade.
3364        let r2 = BacktestResult::from_trade_log(10_000.0, vec![make_trade(100.0, 11)]);
3365        let _ = format!("{}", r2);
3366
3367        // Mixed.
3368        let trades = vec![
3369            make_trade_full(
3370                "p1",
3371                "EURUSD",
3372                Side::Buy,
3373                100.0,
3374                ts(2026, 1, 1, 10, 0, 0),
3375                ts(2026, 1, 1, 11, 0, 0),
3376                CloseReason::Target,
3377                Some("grp1".into()),
3378            ),
3379            make_trade_full(
3380                "p2",
3381                "XAUUSD",
3382                Side::Sell,
3383                -50.0,
3384                ts(2026, 1, 2, 10, 0, 0),
3385                ts(2026, 1, 2, 12, 0, 0),
3386                CloseReason::Stoploss,
3387                None,
3388            ),
3389        ];
3390        let r3 = BacktestResult::from_trade_log(10_000.0, trades);
3391        let output = format!("{}", r3);
3392        assert!(output.contains("Backtest Result"));
3393        assert!(output.contains("Risk Metrics"));
3394        assert!(output.contains("Side Breakdown"));
3395    }
3396
3397    #[test]
3398    fn serialized_breakdown_maps_use_stable_key_order() {
3399        let trades = vec![
3400            make_trade_full(
3401                "z",
3402                "ZZZ",
3403                Side::Buy,
3404                1.0,
3405                ts_hms(9, 0, 0),
3406                ts_hms(11, 0, 0),
3407                CloseReason::Target,
3408                Some("z-group".into()),
3409            ),
3410            make_trade_full(
3411                "a",
3412                "AAA",
3413                Side::Buy,
3414                1.0,
3415                ts_hms(9, 0, 0),
3416                ts_hms(12, 0, 0),
3417                CloseReason::Target,
3418                Some("a-group".into()),
3419            ),
3420        ];
3421        let result = BacktestResult::from_trade_log(10_000.0, trades);
3422
3423        let symbols = serde_json::to_string(&result.per_symbol).expect("symbols serialize");
3424        let groups = serde_json::to_string(&result.per_group).expect("groups serialize");
3425        assert!(symbols.find("AAA").unwrap() < symbols.find("ZZZ").unwrap());
3426        assert!(groups.find("a-group").unwrap() < groups.find("z-group").unwrap());
3427    }
3428
3429    #[test]
3430    fn mtm_output_summary_flows_from_artifacts_and_defaults_for_old_results() {
3431        let summary = MtmOutputSummary {
3432            policy: crate::mtm::MtmOutputPolicy::None,
3433            observed_points: 12,
3434            retained_points: 0,
3435            omitted_points: 12,
3436        };
3437        let result = BacktestResult::from_future_artifacts(FutureBacktestArtifacts {
3438            execution: ExecutionMetadata {
3439                initial_balance: 10_000.0,
3440                ..ExecutionMetadata::default()
3441            },
3442            mtm_output_summary: summary,
3443            ..FutureBacktestArtifacts::default()
3444        });
3445        assert_eq!(result.mtm_output_summary, summary);
3446
3447        let mut json = serde_json::to_value(&result).unwrap();
3448        json.as_object_mut().unwrap().remove("mtm_output_summary");
3449        let restored: BacktestResult = serde_json::from_value(json).unwrap();
3450        assert_eq!(restored.mtm_output_summary, MtmOutputSummary::default());
3451    }
3452
3453    #[test]
3454    fn serde_roundtrip_enhanced_result() {
3455        let trades = vec![
3456            make_trade_full(
3457                "p1",
3458                "EURUSD",
3459                Side::Buy,
3460                100.0,
3461                ts(2026, 1, 1, 10, 0, 0),
3462                ts(2026, 1, 1, 11, 0, 0),
3463                CloseReason::Target,
3464                None,
3465            ),
3466            make_trade_full(
3467                "p2",
3468                "XAUUSD",
3469                Side::Sell,
3470                -50.0,
3471                ts(2026, 1, 1, 10, 0, 0),
3472                ts(2026, 1, 1, 12, 0, 0),
3473                CloseReason::Stoploss,
3474                None,
3475            ),
3476        ];
3477        let result = BacktestResult::from_trade_log(10_000.0, trades);
3478
3479        let json = serde_json::to_string(&result).unwrap();
3480        let restored: BacktestResult = serde_json::from_str(&json).unwrap();
3481
3482        assert_eq!(restored.total_trades, result.total_trades);
3483        assert!((restored.total_pnl - result.total_pnl).abs() < f64::EPSILON);
3484        assert_eq!(restored.summary.total_trades, result.summary.total_trades);
3485        assert_eq!(restored.positions.len(), result.positions.len());
3486        assert_eq!(
3487            restored.per_close_reason.len(),
3488            result.per_close_reason.len()
3489        );
3490        assert_eq!(restored.monthly_returns.len(), result.monthly_returns.len());
3491    }
3492
3493    // ── fmt_duration helper tests ───────────────────────────────────
3494
3495    #[test]
3496    fn fmt_duration_basic() {
3497        assert_eq!(fmt_duration(0), "0m");
3498        assert_eq!(fmt_duration(300), "5m");
3499        assert_eq!(fmt_duration(3600), "1h 0m");
3500        assert_eq!(fmt_duration(3660), "1h 1m");
3501        assert_eq!(fmt_duration(86400), "1d 0h 0m");
3502        assert_eq!(fmt_duration(90061), "1d 1h 1m");
3503    }
3504}