1use std::collections::HashMap;
20
21use chrono::NaiveDateTime;
22
23use qs_core::TradeEngine;
24use qs_core::types::{
25 CloseReason, Effect, GroupId, PositionId, PriceQuote, Side, position_size_tolerance,
26};
27
28use crate::report::TradeResult;
29
30#[derive(Debug, Clone)]
35struct OpenEntry {
36 symbol: String,
37 side: Side,
38 open_entry_value: f64,
40 original_size: f64,
42 remaining_size: f64,
44 open_ts: NaiveDateTime,
45 group: Option<GroupId>,
47}
48
49impl OpenEntry {
50 fn average_entry(&self) -> f64 {
51 if self.remaining_size <= position_size_tolerance(self.original_size) {
52 0.0
53 } else {
54 self.open_entry_value / self.remaining_size
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
66pub struct BacktestExecutor {
67 pub initial_balance: f64,
69 pub balance: f64,
71 pub trade_log: Vec<TradeResult>,
73 open_entries: HashMap<PositionId, OpenEntry>,
75 contract_sizes: HashMap<String, f64>,
78}
79
80impl BacktestExecutor {
81 pub fn new(initial_balance: f64, contract_sizes: HashMap<String, f64>) -> Self {
86 Self {
87 initial_balance,
88 balance: initial_balance,
89 trade_log: Vec::new(),
90 open_entries: HashMap::new(),
91 contract_sizes,
92 }
93 }
94
95 pub fn process_effects(
105 &mut self,
106 effects: &[Effect],
107 engine: &TradeEngine,
108 quote: &PriceQuote,
109 ) {
110 for effect in effects {
111 match effect {
112 Effect::PositionOpened { id } => {
114 if let Some(pos) = engine.get_position(id) {
115 let filled = pos.data.total_filled_size();
116 self.open_entries.insert(
117 id.clone(),
118 OpenEntry {
119 symbol: pos.data.symbol.clone(),
120 side: pos.data.side,
121 open_entry_value: pos.data.open_entry_value,
122 original_size: filled,
123 remaining_size: filled,
124 open_ts: pos.data.open_ts.unwrap_or(quote.ts),
125 group: pos.data.group.clone(),
126 },
127 );
128 }
129 }
130
131 Effect::PositionClosed { id, reason } => {
133 let close_quote = self.resolve_close_quote(id, engine, quote);
134 self.record_close(id, 1.0, *reason, &close_quote);
135 }
136
137 Effect::PartialClose { id, ratio, reason } => {
139 let close_quote = self.resolve_close_quote(id, engine, quote);
140 self.record_close(id, *ratio, *reason, &close_quote);
141 }
142
143 Effect::ScaledIn { id, fill } => {
145 if let Some(entry) = self.open_entries.get_mut(id) {
146 entry.open_entry_value += fill.price * fill.size;
147 entry.original_size += fill.size;
148 entry.remaining_size += fill.size;
149 }
150 }
151
152 _ => {}
154 }
155 }
156 }
157
158 fn resolve_close_quote(
165 &self,
166 position_id: &str,
167 engine: &TradeEngine,
168 fallback: &PriceQuote,
169 ) -> PriceQuote {
170 if let Some(entry) = self.open_entries.get(position_id)
171 && let Some(sym_quote) = engine.last_quote(&entry.symbol)
172 && sym_quote.symbol == entry.symbol
173 {
174 return sym_quote.clone();
175 }
176 fallback.clone()
177 }
178
179 pub fn realized_pnl(&self) -> f64 {
181 self.trade_log.iter().map(|t| t.pnl).sum()
182 }
183
184 pub fn open_count(&self) -> usize {
186 self.open_entries.len()
187 }
188
189 fn record_close(
193 &mut self,
194 position_id: &str,
195 close_ratio: f64,
196 reason: CloseReason,
197 quote: &PriceQuote,
198 ) {
199 let is_full = close_ratio >= 1.0 - position_size_tolerance(1.0)
202 || reason == CloseReason::Stoploss
203 || reason == CloseReason::TrailingStop
204 || reason == CloseReason::TimeExit
205 || reason == CloseReason::BreakevenStop;
206
207 let entry = if is_full {
208 self.open_entries.remove(position_id)
209 } else {
210 self.open_entries.get(position_id).cloned()
211 };
212
213 let Some(entry) = entry else {
214 return;
215 };
216
217 let exit_price = quote.close_price(entry.side);
218 let entry_price = entry.average_entry();
219 let close_size = if is_full {
223 entry.remaining_size
224 } else {
225 (entry.original_size * close_ratio).min(entry.remaining_size)
226 };
227
228 let cs = self
229 .contract_sizes
230 .get(&entry.symbol)
231 .copied()
232 .unwrap_or(1.0);
233
234 let pnl = match entry.side {
235 Side::Buy => (exit_price - entry_price) * close_size * cs,
236 Side::Sell => (entry_price - exit_price) * close_size * cs,
237 };
238
239 self.balance += pnl;
240
241 self.trade_log.push(TradeResult {
242 position_id: position_id.to_owned(),
243 symbol: entry.symbol.clone(),
244 side: entry.side,
245 entry_price,
246 exit_price,
247 size: close_size,
248 pnl,
249 open_ts: entry.open_ts,
250 close_ts: quote.ts,
251 close_reason: reason,
252 group: entry.group.clone(),
253 });
254
255 if !is_full && let Some(tracked) = self.open_entries.get_mut(position_id) {
257 tracked.remaining_size = (tracked.remaining_size - close_size).max(0.0);
258 tracked.open_entry_value =
259 (tracked.open_entry_value - entry_price * close_size).max(0.0);
260 if tracked.remaining_size <= position_size_tolerance(tracked.original_size) {
261 self.open_entries.remove(position_id);
262 }
263 }
264 }
265}
266
267#[cfg(test)]
270mod tests {
271 use super::*;
272 use chrono::NaiveDate;
273 use qs_core::types::{Action, OrderType, Side, TargetSpec};
274 use std::collections::HashMap;
275
276 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
277 NaiveDate::from_ymd_opt(2026, 1, 1)
278 .unwrap()
279 .and_hms_opt(h, m, s)
280 .unwrap()
281 }
282
283 fn make_quote(symbol: &str, bid: f64, ask: f64, time: NaiveDateTime) -> PriceQuote {
284 PriceQuote {
285 symbol: symbol.into(),
286 ts: time,
287 bid,
288 ask,
289 }
290 }
291
292 #[test]
293 fn tracks_open_and_full_close_pnl() {
294 let mut engine = TradeEngine::new();
295 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
296
297 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
299 let effects = engine
300 .apply_action(
301 Action::Open {
302 symbol: "EURUSD".into(),
303 side: Side::Buy,
304 order_type: OrderType::Market,
305 price: Some(1.0850),
306 size: 1.0,
307 stoploss: Some(1.0800),
308 targets: vec![],
309 rules: vec![],
310 group: None,
311 trade_id: None,
312 },
313 ts(10, 0, 0),
314 )
315 .unwrap();
316 exec.process_effects(&effects, &engine, &open_quote);
317 assert_eq!(exec.open_count(), 1);
318
319 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 5, 0));
321 let effects = engine.on_price(&sl_quote);
322 exec.process_effects(&effects, &engine, &sl_quote);
323
324 assert_eq!(exec.open_count(), 0);
325 assert_eq!(exec.trade_log.len(), 1);
326
327 let trade = &exec.trade_log[0];
328 assert_eq!(trade.close_reason, CloseReason::Stoploss);
329 assert!((trade.pnl - (-0.0051)).abs() < 1e-10);
331 assert!((exec.balance - (10_000.0 - 0.0051)).abs() < 1e-10);
332 }
333
334 #[test]
335 fn tracks_partial_close() {
336 let mut engine = TradeEngine::new();
337 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
338
339 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
340 let effects = engine
341 .apply_action(
342 Action::Open {
343 symbol: "EURUSD".into(),
344 side: Side::Buy,
345 order_type: OrderType::Market,
346 price: Some(1.0850),
347 size: 2.0,
348 stoploss: Some(1.0800),
349 targets: vec![TargetSpec {
350 price: 1.0900,
351 close_ratio: 0.5,
352 }],
353 rules: vec![],
354 group: None,
355 trade_id: None,
356 },
357 ts(10, 0, 0),
358 )
359 .unwrap();
360 exec.process_effects(&effects, &engine, &open_quote);
361
362 let tp_quote = make_quote("EURUSD", 1.0900, 1.0902, ts(10, 5, 0));
364 let effects = engine.on_price(&tp_quote);
365 exec.process_effects(&effects, &engine, &tp_quote);
366
367 assert_eq!(exec.trade_log.len(), 1);
368 let partial = &exec.trade_log[0];
369 assert!((partial.pnl - 0.005).abs() < 1e-10);
371 assert_eq!(partial.close_reason, CloseReason::Target);
372
373 assert_eq!(exec.open_count(), 1);
375
376 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 10, 0));
378 let effects = engine.on_price(&sl_quote);
379 exec.process_effects(&effects, &engine, &sl_quote);
380
381 assert_eq!(exec.trade_log.len(), 2);
382 assert_eq!(exec.open_count(), 0);
383 let remaining = &exec.trade_log[1];
384 assert!((remaining.pnl - (-0.0051)).abs() < 1e-10);
386 }
387
388 #[test]
389 fn two_targets_partial_close_pnl() {
390 let mut engine = TradeEngine::new();
395 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
396
397 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
398 let effects = engine
399 .apply_action(
400 Action::Open {
401 symbol: "EURUSD".into(),
402 side: Side::Buy,
403 order_type: OrderType::Market,
404 price: Some(1.0850),
405 size: 2.0,
406 stoploss: Some(1.0800),
407 targets: vec![
408 TargetSpec {
409 price: 1.0900,
410 close_ratio: 0.3,
411 },
412 TargetSpec {
413 price: 1.0950,
414 close_ratio: 0.3,
415 },
416 ],
417 rules: vec![],
418 group: None,
419 trade_id: None,
420 },
421 ts(10, 0, 0),
422 )
423 .unwrap();
424 exec.process_effects(&effects, &engine, &open_quote);
425 assert_eq!(exec.open_count(), 1);
426
427 let tp1_quote = make_quote("EURUSD", 1.0900, 1.0902, ts(10, 5, 0));
429 let effects = engine.on_price(&tp1_quote);
430 exec.process_effects(&effects, &engine, &tp1_quote);
431
432 assert_eq!(exec.trade_log.len(), 1);
433 let tp1 = &exec.trade_log[0];
434 assert_eq!(tp1.close_reason, CloseReason::Target);
435 assert!(
437 (tp1.size - 0.6).abs() < 1e-10,
438 "TP1 size: expected 0.6, got {}",
439 tp1.size
440 );
441 assert!(
443 (tp1.pnl - 0.003).abs() < 1e-10,
444 "TP1 pnl: expected 0.003, got {}",
445 tp1.pnl
446 );
447 assert_eq!(exec.open_count(), 1);
448
449 let tp2_quote = make_quote("EURUSD", 1.0950, 1.0952, ts(10, 10, 0));
451 let effects = engine.on_price(&tp2_quote);
452 exec.process_effects(&effects, &engine, &tp2_quote);
453
454 assert_eq!(
455 exec.trade_log.len(),
456 2,
457 "Expected 2 trades after TP2, got {}",
458 exec.trade_log.len()
459 );
460 let tp2 = &exec.trade_log[1];
461 assert_eq!(tp2.close_reason, CloseReason::Target);
462 assert!(
464 (tp2.size - 0.6).abs() < 1e-10,
465 "TP2 size: expected 0.6, got {}",
466 tp2.size
467 );
468 assert!(
470 (tp2.pnl - 0.006).abs() < 1e-10,
471 "TP2 pnl: expected 0.006, got {}",
472 tp2.pnl
473 );
474 assert_eq!(exec.open_count(), 1);
475
476 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 15, 0));
478 let effects = engine.on_price(&sl_quote);
479 exec.process_effects(&effects, &engine, &sl_quote);
480
481 assert_eq!(exec.trade_log.len(), 3);
482 assert_eq!(exec.open_count(), 0);
483 let sl = &exec.trade_log[2];
484 assert_eq!(sl.close_reason, CloseReason::Stoploss);
485 assert!(
487 (sl.size - 0.8).abs() < 1e-10,
488 "SL size: expected 0.8, got {}",
489 sl.size
490 );
491 assert!(
493 (sl.pnl - (-0.00408)).abs() < 1e-10,
494 "SL pnl: expected -0.00408, got {}",
495 sl.pnl
496 );
497
498 let total_pnl: f64 = exec.trade_log.iter().map(|t| t.pnl).sum();
500 assert!(
501 (total_pnl - 0.00492).abs() < 1e-10,
502 "Total pnl: expected 0.00492, got {}",
503 total_pnl
504 );
505 }
506
507 #[test]
508 fn sell_position_pnl() {
509 let mut engine = TradeEngine::new();
510 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
511
512 let open_quote = make_quote("XAUUSD", 1999.0, 2000.0, ts(10, 0, 0));
513 let effects = engine
514 .apply_action(
515 Action::Open {
516 symbol: "XAUUSD".into(),
517 side: Side::Sell,
518 order_type: OrderType::Market,
519 price: Some(2000.0),
520 size: 1.0,
521 stoploss: None,
522 targets: vec![],
523 rules: vec![],
524 group: None,
525 trade_id: None,
526 },
527 ts(10, 0, 0),
528 )
529 .unwrap();
530 exec.process_effects(&effects, &engine, &open_quote);
531
532 let close_quote = make_quote("XAUUSD", 1979.0, 1980.0, ts(10, 5, 0));
534 engine.on_price(&close_quote); let effects = engine
536 .apply_action(
537 Action::ClosePosition {
538 position_id: exec.open_entries.keys().next().unwrap().clone(),
539 },
540 ts(10, 5, 0),
541 )
542 .unwrap();
543 exec.process_effects(&effects, &engine, &close_quote);
544
545 assert_eq!(exec.trade_log.len(), 1);
546 assert!((exec.trade_log[0].pnl - 20.0).abs() < 1e-10);
548 }
549
550 #[test]
551 fn scale_in_updates_entry() {
552 let mut engine = TradeEngine::new();
553 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
554
555 let q1 = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
556 let effects = engine
557 .apply_action(
558 Action::Open {
559 symbol: "EURUSD".into(),
560 side: Side::Buy,
561 order_type: OrderType::Market,
562 price: Some(1.0800),
563 size: 1.0,
564 stoploss: None,
565 targets: vec![],
566 rules: vec![],
567 group: None,
568 trade_id: None,
569 },
570 ts(10, 0, 0),
571 )
572 .unwrap();
573 let id = match &effects[0] {
574 Effect::PositionOpened { id } => id.clone(),
575 _ => panic!(),
576 };
577 exec.process_effects(&effects, &engine, &q1);
578
579 let q2 = make_quote("EURUSD", 1.0898, 1.0900, ts(10, 5, 0));
581 let effects = engine
582 .apply_action(
583 Action::ScaleIn {
584 position_id: id.clone(),
585 price: Some(1.0900),
586 size: 1.0,
587 trade_id: None,
588 },
589 ts(10, 5, 0),
590 )
591 .unwrap();
592 exec.process_effects(&effects, &engine, &q2);
593
594 let entry = exec.open_entries.get(&id).unwrap();
596 assert!((entry.average_entry() - 1.0850).abs() < 1e-10); assert!((entry.original_size - 2.0).abs() < f64::EPSILON);
598 assert!((entry.remaining_size - 2.0).abs() < f64::EPSILON);
599 }
600
601 #[test]
602 fn partial_close_scale_in_and_final_close_conserve_cash_flow_pnl() {
603 let mut engine = TradeEngine::new();
604 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
605 let open_quote = make_quote("EURUSD", 100.0, 100.0, ts(10, 0, 0));
606 let effects = engine
607 .apply_action(
608 Action::Open {
609 symbol: "EURUSD".into(),
610 side: Side::Buy,
611 order_type: OrderType::Market,
612 price: Some(100.0),
613 size: 2.0,
614 stoploss: None,
615 targets: vec![],
616 rules: vec![],
617 group: None,
618 trade_id: None,
619 },
620 open_quote.ts,
621 )
622 .unwrap();
623 let id = match &effects[0] {
624 Effect::PositionOpened { id } => id.clone(),
625 effect => panic!("unexpected effect: {effect:?}"),
626 };
627 exec.process_effects(&effects, &engine, &open_quote);
628
629 let first_close = make_quote("EURUSD", 110.0, 110.0, ts(10, 1, 0));
630 engine.on_price(&first_close);
631 let effects = engine
632 .apply_action(
633 Action::ClosePartial {
634 position_id: id.clone(),
635 ratio: 0.5,
636 },
637 first_close.ts,
638 )
639 .unwrap();
640 exec.process_effects(&effects, &engine, &first_close);
641
642 let scale_quote = make_quote("EURUSD", 120.0, 120.0, ts(10, 2, 0));
643 let effects = engine
644 .apply_action(
645 Action::ScaleIn {
646 position_id: id.clone(),
647 price: Some(120.0),
648 size: 1.0,
649 trade_id: None,
650 },
651 scale_quote.ts,
652 )
653 .unwrap();
654 exec.process_effects(&effects, &engine, &scale_quote);
655
656 let final_close = make_quote("EURUSD", 130.0, 130.0, ts(10, 3, 0));
657 engine.on_price(&final_close);
658 let effects = engine
659 .apply_action(
660 Action::ClosePosition {
661 position_id: id.clone(),
662 },
663 final_close.ts,
664 )
665 .unwrap();
666 exec.process_effects(&effects, &engine, &final_close);
667
668 assert_eq!(exec.open_count(), 0);
669 assert_eq!(exec.trade_log.len(), 2);
670 assert_eq!(exec.trade_log[0].entry_price, 100.0);
671 assert_eq!(exec.trade_log[0].pnl, 10.0);
672 assert_eq!(exec.trade_log[1].entry_price, 110.0);
673 assert_eq!(exec.trade_log[1].pnl, 40.0);
674 assert_eq!(exec.realized_pnl(), 50.0);
675 }
676
677 #[test]
678 fn cross_symbol_close_uses_position_symbol_quote() {
679 let mut engine = TradeEngine::new();
683 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
684
685 let xau_open = make_quote("XAUUSD", 4999.0, 5000.0, ts(10, 0, 0));
686 let effects = engine
687 .apply_action(
688 Action::Open {
689 symbol: "XAUUSD".into(),
690 side: Side::Buy,
691 order_type: OrderType::Market,
692 price: Some(5000.0),
693 size: 1.0,
694 stoploss: None,
695 targets: vec![],
696 rules: vec![],
697 group: None,
698 trade_id: None,
699 },
700 ts(10, 0, 0),
701 )
702 .unwrap();
703 exec.process_effects(&effects, &engine, &xau_open);
704 let pos_id = exec.open_entries.keys().next().unwrap().clone();
705
706 let gbpjpy_quote = make_quote("GBPJPY", 210.0, 210.5, ts(10, 1, 0));
707 engine.on_price(&gbpjpy_quote);
708
709 let xau_later = make_quote("XAUUSD", 5050.0, 5051.0, ts(10, 2, 0));
710 engine.on_price(&xau_later);
711
712 let close_effects = engine
713 .apply_action(
714 Action::ClosePosition {
715 position_id: pos_id,
716 },
717 ts(10, 3, 0),
718 )
719 .unwrap();
720 let gbpjpy_current = make_quote("GBPJPY", 211.0, 211.5, ts(10, 3, 0));
721 exec.process_effects(&close_effects, &engine, &gbpjpy_current);
722
723 assert_eq!(exec.trade_log.len(), 1);
724 let trade = &exec.trade_log[0];
725 assert!(
726 (trade.exit_price - 5050.0).abs() < 1e-10,
727 "Exit price should be XAUUSD bid 5050.0, got {}",
728 trade.exit_price
729 );
730 assert_eq!(trade.symbol, "XAUUSD");
731 }
732
733 #[test]
734 fn contract_size_affects_pnl_xauusd() {
735 let mut engine = TradeEngine::new();
736 let mut cs = HashMap::new();
737 cs.insert("XAUUSD".to_string(), 100.0);
738 let mut exec = BacktestExecutor::new(10_000.0, cs);
739
740 let open_quote = make_quote("XAUUSD", 4999.0, 5000.0, ts(10, 0, 0));
741 let effects = engine
742 .apply_action(
743 Action::Open {
744 symbol: "XAUUSD".into(),
745 side: Side::Buy,
746 order_type: OrderType::Market,
747 price: Some(5000.0),
748 size: 1.0,
749 stoploss: None,
750 targets: vec![],
751 rules: vec![],
752 group: None,
753 trade_id: None,
754 },
755 ts(10, 0, 0),
756 )
757 .unwrap();
758 exec.process_effects(&effects, &engine, &open_quote);
759
760 let close_quote = make_quote("XAUUSD", 5049.0, 5050.0, ts(10, 5, 0));
761 engine.on_price(&close_quote);
762 let effects = engine
763 .apply_action(
764 Action::ClosePosition {
765 position_id: exec.open_entries.keys().next().unwrap().clone(),
766 },
767 ts(10, 5, 0),
768 )
769 .unwrap();
770 exec.process_effects(&effects, &engine, &close_quote);
771
772 assert!((exec.trade_log[0].pnl - 4900.0).abs() < 1e-6);
773 }
774
775 #[test]
776 fn contract_size_affects_pnl_gbpjpy() {
777 let mut engine = TradeEngine::new();
778 let mut cs = HashMap::new();
779 cs.insert("GBPJPY".to_string(), 100_000.0);
780 let mut exec = BacktestExecutor::new(10_000.0, cs);
781
782 let open_quote = make_quote("GBPJPY", 209.0, 210.0, ts(10, 0, 0));
783 let effects = engine
784 .apply_action(
785 Action::Open {
786 symbol: "GBPJPY".into(),
787 side: Side::Buy,
788 order_type: OrderType::Market,
789 price: Some(210.0),
790 size: 0.01,
791 stoploss: None,
792 targets: vec![],
793 rules: vec![],
794 group: None,
795 trade_id: None,
796 },
797 ts(10, 0, 0),
798 )
799 .unwrap();
800 exec.process_effects(&effects, &engine, &open_quote);
801
802 let close_quote = make_quote("GBPJPY", 214.0, 215.0, ts(10, 5, 0));
803 engine.on_price(&close_quote);
804 let effects = engine
805 .apply_action(
806 Action::ClosePosition {
807 position_id: exec.open_entries.keys().next().unwrap().clone(),
808 },
809 ts(10, 5, 0),
810 )
811 .unwrap();
812 exec.process_effects(&effects, &engine, &close_quote);
813
814 assert!((exec.trade_log[0].pnl - 4000.0).abs() < 1e-6);
815 }
816}