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