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