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 commission: 0.0,
254 swap: 0.0,
255 gross_pnl: None,
256 });
257
258 if !is_full && let Some(tracked) = self.open_entries.get_mut(position_id) {
260 tracked.remaining_size = (tracked.remaining_size - close_size).max(0.0);
261 tracked.open_entry_value =
262 (tracked.open_entry_value - entry_price * close_size).max(0.0);
263 if tracked.remaining_size <= position_size_tolerance(tracked.original_size) {
264 self.open_entries.remove(position_id);
265 }
266 }
267 }
268}
269
270#[cfg(test)]
273mod tests {
274 use super::*;
275 use chrono::NaiveDate;
276 use qs_core::types::{Action, OrderType, Side, TargetSpec};
277 use std::collections::HashMap;
278
279 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
280 NaiveDate::from_ymd_opt(2026, 1, 1)
281 .unwrap()
282 .and_hms_opt(h, m, s)
283 .unwrap()
284 }
285
286 fn make_quote(symbol: &str, bid: f64, ask: f64, time: NaiveDateTime) -> PriceQuote {
287 PriceQuote {
288 symbol: symbol.into(),
289 ts: time,
290 bid,
291 ask,
292 }
293 }
294
295 #[test]
296 fn tracks_open_and_full_close_pnl() {
297 let mut engine = TradeEngine::new();
298 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
299
300 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
302 let effects = engine
303 .apply_action(
304 Action::Open {
305 symbol: "EURUSD".into(),
306 side: Side::Buy,
307 order_type: OrderType::Market,
308 price: Some(1.0850),
309 size: 1.0,
310 stoploss: Some(1.0800),
311 targets: vec![],
312 rules: vec![],
313 group: None,
314 trade_id: None,
315 },
316 ts(10, 0, 0),
317 )
318 .unwrap();
319 exec.process_effects(&effects, &engine, &open_quote);
320 assert_eq!(exec.open_count(), 1);
321
322 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 5, 0));
324 let effects = engine.on_price(&sl_quote);
325 exec.process_effects(&effects, &engine, &sl_quote);
326
327 assert_eq!(exec.open_count(), 0);
328 assert_eq!(exec.trade_log.len(), 1);
329
330 let trade = &exec.trade_log[0];
331 assert_eq!(trade.close_reason, CloseReason::Stoploss);
332 assert!((trade.pnl - (-0.0051)).abs() < 1e-10);
334 assert!((exec.balance - (10_000.0 - 0.0051)).abs() < 1e-10);
335 }
336
337 #[test]
338 fn tracks_partial_close() {
339 let mut engine = TradeEngine::new();
340 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
341
342 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
343 let effects = engine
344 .apply_action(
345 Action::Open {
346 symbol: "EURUSD".into(),
347 side: Side::Buy,
348 order_type: OrderType::Market,
349 price: Some(1.0850),
350 size: 2.0,
351 stoploss: Some(1.0800),
352 targets: vec![TargetSpec {
353 price: 1.0900,
354 close_ratio: 0.5,
355 }],
356 rules: vec![],
357 group: None,
358 trade_id: None,
359 },
360 ts(10, 0, 0),
361 )
362 .unwrap();
363 exec.process_effects(&effects, &engine, &open_quote);
364
365 let tp_quote = make_quote("EURUSD", 1.0900, 1.0902, ts(10, 5, 0));
367 let effects = engine.on_price(&tp_quote);
368 exec.process_effects(&effects, &engine, &tp_quote);
369
370 assert_eq!(exec.trade_log.len(), 1);
371 let partial = &exec.trade_log[0];
372 assert!((partial.pnl - 0.005).abs() < 1e-10);
374 assert_eq!(partial.close_reason, CloseReason::Target);
375
376 assert_eq!(exec.open_count(), 1);
378
379 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 10, 0));
381 let effects = engine.on_price(&sl_quote);
382 exec.process_effects(&effects, &engine, &sl_quote);
383
384 assert_eq!(exec.trade_log.len(), 2);
385 assert_eq!(exec.open_count(), 0);
386 let remaining = &exec.trade_log[1];
387 assert!((remaining.pnl - (-0.0051)).abs() < 1e-10);
389 }
390
391 #[test]
392 fn two_targets_partial_close_pnl() {
393 let mut engine = TradeEngine::new();
398 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
399
400 let open_quote = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
401 let effects = engine
402 .apply_action(
403 Action::Open {
404 symbol: "EURUSD".into(),
405 side: Side::Buy,
406 order_type: OrderType::Market,
407 price: Some(1.0850),
408 size: 2.0,
409 stoploss: Some(1.0800),
410 targets: vec![
411 TargetSpec {
412 price: 1.0900,
413 close_ratio: 0.3,
414 },
415 TargetSpec {
416 price: 1.0950,
417 close_ratio: 0.3,
418 },
419 ],
420 rules: vec![],
421 group: None,
422 trade_id: None,
423 },
424 ts(10, 0, 0),
425 )
426 .unwrap();
427 exec.process_effects(&effects, &engine, &open_quote);
428 assert_eq!(exec.open_count(), 1);
429
430 let tp1_quote = make_quote("EURUSD", 1.0900, 1.0902, ts(10, 5, 0));
432 let effects = engine.on_price(&tp1_quote);
433 exec.process_effects(&effects, &engine, &tp1_quote);
434
435 assert_eq!(exec.trade_log.len(), 1);
436 let tp1 = &exec.trade_log[0];
437 assert_eq!(tp1.close_reason, CloseReason::Target);
438 assert!(
440 (tp1.size - 0.6).abs() < 1e-10,
441 "TP1 size: expected 0.6, got {}",
442 tp1.size
443 );
444 assert!(
446 (tp1.pnl - 0.003).abs() < 1e-10,
447 "TP1 pnl: expected 0.003, got {}",
448 tp1.pnl
449 );
450 assert_eq!(exec.open_count(), 1);
451
452 let tp2_quote = make_quote("EURUSD", 1.0950, 1.0952, ts(10, 10, 0));
454 let effects = engine.on_price(&tp2_quote);
455 exec.process_effects(&effects, &engine, &tp2_quote);
456
457 assert_eq!(
458 exec.trade_log.len(),
459 2,
460 "Expected 2 trades after TP2, got {}",
461 exec.trade_log.len()
462 );
463 let tp2 = &exec.trade_log[1];
464 assert_eq!(tp2.close_reason, CloseReason::Target);
465 assert!(
467 (tp2.size - 0.6).abs() < 1e-10,
468 "TP2 size: expected 0.6, got {}",
469 tp2.size
470 );
471 assert!(
473 (tp2.pnl - 0.006).abs() < 1e-10,
474 "TP2 pnl: expected 0.006, got {}",
475 tp2.pnl
476 );
477 assert_eq!(exec.open_count(), 1);
478
479 let sl_quote = make_quote("EURUSD", 1.0799, 1.0801, ts(10, 15, 0));
481 let effects = engine.on_price(&sl_quote);
482 exec.process_effects(&effects, &engine, &sl_quote);
483
484 assert_eq!(exec.trade_log.len(), 3);
485 assert_eq!(exec.open_count(), 0);
486 let sl = &exec.trade_log[2];
487 assert_eq!(sl.close_reason, CloseReason::Stoploss);
488 assert!(
490 (sl.size - 0.8).abs() < 1e-10,
491 "SL size: expected 0.8, got {}",
492 sl.size
493 );
494 assert!(
496 (sl.pnl - (-0.00408)).abs() < 1e-10,
497 "SL pnl: expected -0.00408, got {}",
498 sl.pnl
499 );
500
501 let total_pnl: f64 = exec.trade_log.iter().map(|t| t.pnl).sum();
503 assert!(
504 (total_pnl - 0.00492).abs() < 1e-10,
505 "Total pnl: expected 0.00492, got {}",
506 total_pnl
507 );
508 }
509
510 #[test]
511 fn sell_position_pnl() {
512 let mut engine = TradeEngine::new();
513 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
514
515 let open_quote = make_quote("XAUUSD", 1999.0, 2000.0, ts(10, 0, 0));
516 let effects = engine
517 .apply_action(
518 Action::Open {
519 symbol: "XAUUSD".into(),
520 side: Side::Sell,
521 order_type: OrderType::Market,
522 price: Some(2000.0),
523 size: 1.0,
524 stoploss: None,
525 targets: vec![],
526 rules: vec![],
527 group: None,
528 trade_id: None,
529 },
530 ts(10, 0, 0),
531 )
532 .unwrap();
533 exec.process_effects(&effects, &engine, &open_quote);
534
535 let close_quote = make_quote("XAUUSD", 1979.0, 1980.0, ts(10, 5, 0));
537 engine.on_price(&close_quote); let effects = engine
539 .apply_action(
540 Action::ClosePosition {
541 position_id: exec.open_entries.keys().next().unwrap().clone(),
542 },
543 ts(10, 5, 0),
544 )
545 .unwrap();
546 exec.process_effects(&effects, &engine, &close_quote);
547
548 assert_eq!(exec.trade_log.len(), 1);
549 assert!((exec.trade_log[0].pnl - 20.0).abs() < 1e-10);
551 }
552
553 #[test]
554 fn scale_in_updates_entry() {
555 let mut engine = TradeEngine::new();
556 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
557
558 let q1 = make_quote("EURUSD", 1.0848, 1.0850, ts(10, 0, 0));
559 let effects = engine
560 .apply_action(
561 Action::Open {
562 symbol: "EURUSD".into(),
563 side: Side::Buy,
564 order_type: OrderType::Market,
565 price: Some(1.0800),
566 size: 1.0,
567 stoploss: None,
568 targets: vec![],
569 rules: vec![],
570 group: None,
571 trade_id: None,
572 },
573 ts(10, 0, 0),
574 )
575 .unwrap();
576 let id = match &effects[0] {
577 Effect::PositionOpened { id } => id.clone(),
578 _ => panic!(),
579 };
580 exec.process_effects(&effects, &engine, &q1);
581
582 let q2 = make_quote("EURUSD", 1.0898, 1.0900, ts(10, 5, 0));
584 let effects = engine
585 .apply_action(
586 Action::ScaleIn {
587 position_id: id.clone(),
588 price: Some(1.0900),
589 size: 1.0,
590 trade_id: None,
591 },
592 ts(10, 5, 0),
593 )
594 .unwrap();
595 exec.process_effects(&effects, &engine, &q2);
596
597 let entry = exec.open_entries.get(&id).unwrap();
599 assert!((entry.average_entry() - 1.0850).abs() < 1e-10); assert!((entry.original_size - 2.0).abs() < f64::EPSILON);
601 assert!((entry.remaining_size - 2.0).abs() < f64::EPSILON);
602 }
603
604 #[test]
605 fn partial_close_scale_in_and_final_close_conserve_cash_flow_pnl() {
606 let mut engine = TradeEngine::new();
607 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
608 let open_quote = make_quote("EURUSD", 100.0, 100.0, ts(10, 0, 0));
609 let effects = engine
610 .apply_action(
611 Action::Open {
612 symbol: "EURUSD".into(),
613 side: Side::Buy,
614 order_type: OrderType::Market,
615 price: Some(100.0),
616 size: 2.0,
617 stoploss: None,
618 targets: vec![],
619 rules: vec![],
620 group: None,
621 trade_id: None,
622 },
623 open_quote.ts,
624 )
625 .unwrap();
626 let id = match &effects[0] {
627 Effect::PositionOpened { id } => id.clone(),
628 effect => panic!("unexpected effect: {effect:?}"),
629 };
630 exec.process_effects(&effects, &engine, &open_quote);
631
632 let first_close = make_quote("EURUSD", 110.0, 110.0, ts(10, 1, 0));
633 engine.on_price(&first_close);
634 let effects = engine
635 .apply_action(
636 Action::ClosePartial {
637 position_id: id.clone(),
638 ratio: 0.5,
639 },
640 first_close.ts,
641 )
642 .unwrap();
643 exec.process_effects(&effects, &engine, &first_close);
644
645 let scale_quote = make_quote("EURUSD", 120.0, 120.0, ts(10, 2, 0));
646 let effects = engine
647 .apply_action(
648 Action::ScaleIn {
649 position_id: id.clone(),
650 price: Some(120.0),
651 size: 1.0,
652 trade_id: None,
653 },
654 scale_quote.ts,
655 )
656 .unwrap();
657 exec.process_effects(&effects, &engine, &scale_quote);
658
659 let final_close = make_quote("EURUSD", 130.0, 130.0, ts(10, 3, 0));
660 engine.on_price(&final_close);
661 let effects = engine
662 .apply_action(
663 Action::ClosePosition {
664 position_id: id.clone(),
665 },
666 final_close.ts,
667 )
668 .unwrap();
669 exec.process_effects(&effects, &engine, &final_close);
670
671 assert_eq!(exec.open_count(), 0);
672 assert_eq!(exec.trade_log.len(), 2);
673 assert_eq!(exec.trade_log[0].entry_price, 100.0);
674 assert_eq!(exec.trade_log[0].pnl, 10.0);
675 assert_eq!(exec.trade_log[1].entry_price, 110.0);
676 assert_eq!(exec.trade_log[1].pnl, 40.0);
677 assert_eq!(exec.realized_pnl(), 50.0);
678 }
679
680 #[test]
681 fn cross_symbol_close_uses_position_symbol_quote() {
682 let mut engine = TradeEngine::new();
686 let mut exec = BacktestExecutor::new(10_000.0, HashMap::new());
687
688 let xau_open = make_quote("XAUUSD", 4999.0, 5000.0, ts(10, 0, 0));
689 let effects = engine
690 .apply_action(
691 Action::Open {
692 symbol: "XAUUSD".into(),
693 side: Side::Buy,
694 order_type: OrderType::Market,
695 price: Some(5000.0),
696 size: 1.0,
697 stoploss: None,
698 targets: vec![],
699 rules: vec![],
700 group: None,
701 trade_id: None,
702 },
703 ts(10, 0, 0),
704 )
705 .unwrap();
706 exec.process_effects(&effects, &engine, &xau_open);
707 let pos_id = exec.open_entries.keys().next().unwrap().clone();
708
709 let gbpjpy_quote = make_quote("GBPJPY", 210.0, 210.5, ts(10, 1, 0));
710 engine.on_price(&gbpjpy_quote);
711
712 let xau_later = make_quote("XAUUSD", 5050.0, 5051.0, ts(10, 2, 0));
713 engine.on_price(&xau_later);
714
715 let close_effects = engine
716 .apply_action(
717 Action::ClosePosition {
718 position_id: pos_id,
719 },
720 ts(10, 3, 0),
721 )
722 .unwrap();
723 let gbpjpy_current = make_quote("GBPJPY", 211.0, 211.5, ts(10, 3, 0));
724 exec.process_effects(&close_effects, &engine, &gbpjpy_current);
725
726 assert_eq!(exec.trade_log.len(), 1);
727 let trade = &exec.trade_log[0];
728 assert!(
729 (trade.exit_price - 5050.0).abs() < 1e-10,
730 "Exit price should be XAUUSD bid 5050.0, got {}",
731 trade.exit_price
732 );
733 assert_eq!(trade.symbol, "XAUUSD");
734 }
735
736 #[test]
737 fn contract_size_affects_pnl_xauusd() {
738 let mut engine = TradeEngine::new();
739 let mut cs = HashMap::new();
740 cs.insert("XAUUSD".to_string(), 100.0);
741 let mut exec = BacktestExecutor::new(10_000.0, cs);
742
743 let open_quote = make_quote("XAUUSD", 4999.0, 5000.0, ts(10, 0, 0));
744 let effects = engine
745 .apply_action(
746 Action::Open {
747 symbol: "XAUUSD".into(),
748 side: Side::Buy,
749 order_type: OrderType::Market,
750 price: Some(5000.0),
751 size: 1.0,
752 stoploss: None,
753 targets: vec![],
754 rules: vec![],
755 group: None,
756 trade_id: None,
757 },
758 ts(10, 0, 0),
759 )
760 .unwrap();
761 exec.process_effects(&effects, &engine, &open_quote);
762
763 let close_quote = make_quote("XAUUSD", 5049.0, 5050.0, ts(10, 5, 0));
764 engine.on_price(&close_quote);
765 let effects = engine
766 .apply_action(
767 Action::ClosePosition {
768 position_id: exec.open_entries.keys().next().unwrap().clone(),
769 },
770 ts(10, 5, 0),
771 )
772 .unwrap();
773 exec.process_effects(&effects, &engine, &close_quote);
774
775 assert!((exec.trade_log[0].pnl - 4900.0).abs() < 1e-6);
776 }
777
778 #[test]
779 fn contract_size_affects_pnl_gbpjpy() {
780 let mut engine = TradeEngine::new();
781 let mut cs = HashMap::new();
782 cs.insert("GBPJPY".to_string(), 100_000.0);
783 let mut exec = BacktestExecutor::new(10_000.0, cs);
784
785 let open_quote = make_quote("GBPJPY", 209.0, 210.0, ts(10, 0, 0));
786 let effects = engine
787 .apply_action(
788 Action::Open {
789 symbol: "GBPJPY".into(),
790 side: Side::Buy,
791 order_type: OrderType::Market,
792 price: Some(210.0),
793 size: 0.01,
794 stoploss: None,
795 targets: vec![],
796 rules: vec![],
797 group: None,
798 trade_id: None,
799 },
800 ts(10, 0, 0),
801 )
802 .unwrap();
803 exec.process_effects(&effects, &engine, &open_quote);
804
805 let close_quote = make_quote("GBPJPY", 214.0, 215.0, ts(10, 5, 0));
806 engine.on_price(&close_quote);
807 let effects = engine
808 .apply_action(
809 Action::ClosePosition {
810 position_id: exec.open_entries.keys().next().unwrap().clone(),
811 },
812 ts(10, 5, 0),
813 )
814 .unwrap();
815 exec.process_effects(&effects, &engine, &close_quote);
816
817 assert!((exec.trade_log[0].pnl - 4000.0).abs() < 1e-6);
818 }
819}