1use 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
29mod 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 let opt = Option::<f64>::deserialize(deserializer)?;
52 Ok(opt.unwrap_or(0.0))
53 }
54}
55
56#[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 #[serde(default)]
73 pub group: Option<GroupId>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SubsetStats {
81 pub total_trades: usize,
83 pub winning_trades: usize,
85 pub losing_trades: usize,
87 pub breakeven_trades: usize,
89 pub total_pnl: f64,
91 pub gross_profit: f64,
93 pub gross_loss: f64,
95 pub win_rate: f64,
97 #[serde(with = "finite_f64")]
99 pub profit_factor: f64,
100 pub avg_win: f64,
102 pub avg_loss: f64,
104 #[serde(with = "finite_f64")]
106 pub win_loss_ratio: f64,
107 pub expectancy: f64,
109 pub largest_win: f64,
111 pub largest_loss: f64,
113}
114
115impl SubsetStats {
116 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct StreakStats {
215 pub max_consecutive_wins: u32,
217 pub max_consecutive_losses: u32,
219 pub current_streak: i32,
221}
222
223impl StreakStats {
224 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct RiskMetrics {
306 pub sharpe_ratio: Option<f64>,
308 pub sortino_ratio: Option<f64>,
310 pub calmar_ratio: Option<f64>,
312 pub return_on_max_drawdown: Option<f64>,
314 pub max_drawdown: f64,
316 pub max_drawdown_pct: f64,
318 pub max_drawdown_duration_secs: Option<i64>,
320}
321
322impl RiskMetrics {
323 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 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 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
370fn 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
386fn 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; }
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
410fn 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
432fn 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
447fn 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct DurationStats {
494 pub avg_duration_secs: i64,
496 pub min_duration_secs: i64,
498 pub max_duration_secs: i64,
500 pub avg_winner_duration_secs: i64,
502 pub avg_loser_duration_secs: i64,
504}
505
506impl DurationStats {
507 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct MonthlyReturn {
602 pub year: i32,
604 pub month: u32,
606 pub pnl: f64,
608 pub trade_count: usize,
610 pub ending_balance: f64,
612}
613
614fn compute_monthly_returns(trade_log: &[TradeResult], initial_balance: f64) -> Vec<MonthlyReturn> {
616 if trade_log.is_empty() {
617 return Vec::new();
618 }
619
620 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
690use chrono::Datelike;
692
693#[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 pub entry_price: f64,
705 pub avg_exit_price: f64,
707 pub original_size: f64,
709 pub close_count: usize,
711 pub net_pnl: f64,
713 pub close_reasons: Vec<CloseReason>,
715 pub open_ts: NaiveDateTime,
717 pub final_close_ts: NaiveDateTime,
719 pub duration_seconds: i64,
721}
722
723impl PositionSummary {
724 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 pub fn is_winner(&self) -> bool {
768 self.net_pnl > 0.0
769 }
770
771 pub fn is_loser(&self) -> bool {
773 self.net_pnl < 0.0
774 }
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct CloseReasonStats {
782 pub reason: CloseReason,
784 pub count: usize,
786 pub total_pnl: f64,
788 pub avg_pnl: f64,
790 pub percentage: f64,
792}
793
794fn 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
838pub struct BacktestResult {
839 pub initial_balance: f64,
842 pub final_balance: f64,
844 pub total_pnl: f64,
846 pub total_trades: usize,
848 pub winning_trades: usize,
850 pub losing_trades: usize,
852 pub win_rate: f64,
854 pub max_drawdown: f64,
856 pub max_drawdown_pct: f64,
858 #[serde(with = "finite_f64")]
860 pub profit_factor: f64,
861 pub equity_curve: Vec<(NaiveDateTime, f64)>,
863 pub trade_log: Vec<TradeResult>,
865
866 pub summary: SubsetStats,
868
869 pub per_symbol: BTreeMap<String, SubsetStats>,
871
872 pub per_group: BTreeMap<GroupId, SubsetStats>,
874
875 pub long_stats: SubsetStats,
877 pub short_stats: SubsetStats,
879
880 pub per_close_reason: Vec<CloseReasonStats>,
882
883 pub streaks: StreakStats,
885
886 pub risk_metrics: RiskMetrics,
888
889 pub duration_stats: Option<DurationStats>,
891
892 pub monthly_returns: Vec<MonthlyReturn>,
894
895 pub positions: Vec<PositionSummary>,
899
900 pub total_positions: usize,
902 pub winning_positions: usize,
904 pub losing_positions: usize,
906 pub position_win_rate: f64,
908
909 #[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 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 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 let all_refs: Vec<&TradeResult> = trade_log.iter().collect();
1000 let summary = SubsetStats::from_trades(&all_refs);
1001
1002 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 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 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 let per_close_reason = compute_close_reason_stats(&trade_log);
1035
1036 let streaks = StreakStats::from_trades(&all_refs);
1038
1039 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 let duration_stats = DurationStats::from_trades(&all_refs);
1051
1052 let monthly_returns = compute_monthly_returns(&trade_log, initial_balance);
1054
1055 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 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 pub fn from_future_artifacts(artifacts: FutureBacktestArtifacts) -> Self {
1132 Self::from_future_artifacts_with_options(artifacts, EvaluationOptions::default())
1133 }
1134
1135 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 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
1452fn 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
1471fn 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 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 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 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 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 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 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 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 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 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#[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 #[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 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 #[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 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 #[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); 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); assert_eq!(s.current_streak, 1);
1986 }
1987
1988 #[test]
1991 fn sharpe_ratio_positive() {
1992 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 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 assert!(result.risk_metrics.calmar_ratio.is_none());
2033 }
2034
2035 #[test]
2036 fn max_drawdown_duration_recovered() {
2037 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 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 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 let dur = result.risk_metrics.max_drawdown_duration_secs.unwrap();
2106 assert_eq!(dur, 4 * 86400);
2107 }
2108
2109 #[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); assert_eq!(ds.max_duration_secs, 14400); assert_eq!(ds.avg_duration_secs, 10800); 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 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 #[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 #[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 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 #[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 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); }
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 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 assert_eq!(result.total_trades, 2);
2679 assert!((result.win_rate - 0.5).abs() < f64::EPSILON);
2680
2681 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 assert_eq!(result.total_trades, 2);
2971 assert_eq!(result.winning_trades, 1);
2972 assert_eq!(result.losing_trades, 1);
2973
2974 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 #[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 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 let r1 = BacktestResult::from_trade_log(10_000.0, vec![]);
3169 let _ = format!("{}", r1);
3170
3171 let r2 = BacktestResult::from_trade_log(10_000.0, vec![make_trade(100.0, 11)]);
3173 let _ = format!("{}", r2);
3174
3175 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 #[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}