Skip to main content

qs_backtest/
executor.rs

1//! Backtest executor — simulates fills and tracks P&L.
2//!
3//! The [`BacktestExecutor`] receives [`Effect`]s produced by the trade engine
4//! and translates them into simulated trade results (entries, exits, P&L).
5//! It maintains a map of open entries so that when a position closes it can
6//! compute realised profit/loss.
7//!
8//! ## Contract sizes
9//!
10//! P&L is calculated as `(exit - entry) * close_size * contract_size`.
11//! The `contract_size` (also called "point value") converts lot-denominated
12//! sizes into monetary units.  For forex, 1 standard lot = 100,000 base
13//! currency units, so the contract size is 100,000.  For gold (XAUUSD),
14//! 1 lot = 100 troy ounces, so the contract size is 100.
15//!
16//! When no contract size is provided for a symbol the multiplier defaults
17//! to `1.0`, preserving backward compatibility with existing tests.
18
19use 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// ─── Open entry tracking ────────────────────────────────────────────────────
31
32/// Snapshot of a position at the time it opened.  Kept until the position
33/// closes so that P&L can be computed.
34#[derive(Debug, Clone)]
35struct OpenEntry {
36    symbol: String,
37    side: Side,
38    /// Average-cost basis assigned only to inventory that is still open.
39    open_entry_value: f64,
40    /// Original position size at open (updated only by scale-ins).
41    original_size: f64,
42    /// Remaining size after partial closes.
43    remaining_size: f64,
44    open_ts: NaiveDateTime,
45    /// Group this position belongs to (propagated to TradeResult on close).
46    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// ─── BacktestExecutor ───────────────────────────────────────────────────────
60
61/// Simulates trade execution and tracks account balance / P&L.
62///
63/// The executor does **not** own the [`TradeEngine`] — instead it receives
64/// effects and a reference to the engine after each price update or action.
65#[derive(Debug, Clone)]
66pub struct BacktestExecutor {
67    /// Starting account balance.
68    pub initial_balance: f64,
69    /// Current account balance (initial + realised P&L).
70    pub balance: f64,
71    /// Per-close trade results collected over the backtest.
72    pub trade_log: Vec<TradeResult>,
73    /// Currently tracked open entries (position_id → entry snapshot).
74    open_entries: HashMap<PositionId, OpenEntry>,
75    /// Per-symbol contract size (point value) for P&L calculation.
76    /// Missing symbols default to 1.0.
77    contract_sizes: HashMap<String, f64>,
78}
79
80impl BacktestExecutor {
81    /// Create a new executor with the given starting balance and contract sizes.
82    ///
83    /// `contract_sizes` maps symbol name → contract size (e.g. 100_000 for forex).
84    /// Pass an empty map to get the legacy behaviour (multiplier = 1.0).
85    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    /// Process a batch of effects produced by the engine.
96    ///
97    /// `engine` is passed by reference so that the executor can look up
98    /// position details (e.g. entry price, side) when recording opens.
99    /// `quote` is the current market price used for close-price calculation.
100    ///
101    /// For close effects the executor resolves the position symbol and
102    /// prefers the engine's last known quote for that symbol.  This prevents
103    /// cross-symbol quote contamination in merged multi-symbol feeds.
104    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                // ── Position opened: record the entry ───────────────
113                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                // ── Position fully closed ───────────────────────────
132                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                // ── Partial close ───────────────────────────────────
138                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                // ── Scale-in: update the tracked entry ──────────────
144                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                // Other effects are informational — no P&L impact.
153                _ => {}
154            }
155        }
156    }
157
158    /// Resolve the best available quote for closing a position.
159    ///
160    /// Uses the engine's last known quote for the position symbol when
161    /// available.  Falls back to the caller-supplied quote when the engine
162    /// has no quote for that symbol (e.g. the position was just opened and
163    /// no tick has arrived yet).
164    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    /// Realised P&L so far.
180    pub fn realized_pnl(&self) -> f64 {
181        self.trade_log.iter().map(|t| t.pnl).sum()
182    }
183
184    /// Number of tracked open entries.
185    pub fn open_count(&self) -> usize {
186        self.open_entries.len()
187    }
188
189    // ── Internal ────────────────────────────────────────────────────────
190
191    /// Record a close (full or partial) and compute P&L.
192    fn record_close(
193        &mut self,
194        position_id: &str,
195        close_ratio: f64,
196        reason: CloseReason,
197        quote: &PriceQuote,
198    ) {
199        // For a full close (ratio == 1.0) we remove the entry; for partial
200        // we keep it and reduce the tracked size.
201        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        // close_ratio is always relative to the *original* position size,
220        // so compute close_size from original_size.  For full closes, use
221        // remaining_size to capture everything that's left.
222        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 partial, reduce the remaining size for future closes.
256        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// ─── Tests ──────────────────────────────────────────────────────────────────
268
269#[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        // Open a buy
298        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        // SL triggers
320        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        // P&L = (bid - entry) * size = (1.0799 - 1.0850) * 1.0 = -0.0051
330        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        // TP1 hit: partial close 50%
363        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        // P&L = (1.0900 - 1.0850) * (2.0 * 0.5) = 0.0050 * 1.0 = 0.005
370        assert!((partial.pnl - 0.005).abs() < 1e-10);
371        assert_eq!(partial.close_reason, CloseReason::Target);
372
373        // Entry still tracked (remaining size = 1.0)
374        assert_eq!(exec.open_count(), 1);
375
376        // SL hit: close remaining
377        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        // remaining_size = 2.0 - 1.0 = 1.0; P&L = (1.0799 - 1.0850) * 1.0 = -0.0051
385        assert!((remaining.pnl - (-0.0051)).abs() < 1e-10);
386    }
387
388    #[test]
389    fn two_targets_partial_close_pnl() {
390        // Open: size=2.0, entry at ask=1.0850 (BidAsk model, Buy side)
391        // TP1: price=1.0900, close_ratio=0.3  → close 0.6 lots
392        // TP2: price=1.0950, close_ratio=0.3  → close 0.6 lots
393        // SL:  price=1.0800                    → close remaining 0.8 lots
394        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        // ── TP1 hits ────────────────────────────────────────────────────
428        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        // close_size = original 2.0 * 0.3 = 0.6
436        assert!(
437            (tp1.size - 0.6).abs() < 1e-10,
438            "TP1 size: expected 0.6, got {}",
439            tp1.size
440        );
441        // pnl = (1.0900 - 1.0850) * 0.6 = 0.003
442        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        // ── TP2 hits ────────────────────────────────────────────────────
450        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        // close_size = original 2.0 * 0.3 = 0.6 (NOT 1.4 * 0.3 = 0.42)
463        assert!(
464            (tp2.size - 0.6).abs() < 1e-10,
465            "TP2 size: expected 0.6, got {}",
466            tp2.size
467        );
468        // pnl = (1.0950 - 1.0850) * 0.6 = 0.006
469        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        // ── SL hits — close remaining 0.8 lots ─────────────────────────
477        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        // remaining = 2.0 - 0.6 - 0.6 = 0.8
486        assert!(
487            (sl.size - 0.8).abs() < 1e-10,
488            "SL size: expected 0.8, got {}",
489            sl.size
490        );
491        // pnl = (1.0799 - 1.0850) * 0.8 = -0.00408
492        assert!(
493            (sl.pnl - (-0.00408)).abs() < 1e-10,
494            "SL pnl: expected -0.00408, got {}",
495            sl.pnl
496        );
497
498        // Total: 0.003 + 0.006 - 0.00408 = 0.00492
499        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        // Close manually
533        let close_quote = make_quote("XAUUSD", 1979.0, 1980.0, ts(10, 5, 0));
534        engine.on_price(&close_quote); // seed last quote
535        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        // Sell P&L = (entry - exit_ask) * size = (2000 - 1980) * 1 = 20
547        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        // Scale in
580        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        // Check that the tracked entry now has averaged price and combined size
595        let entry = exec.open_entries.get(&id).unwrap();
596        assert!((entry.average_entry() - 1.0850).abs() < 1e-10); // (1.08+1.09)/2
597        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        // Regression test for Issue 1 Part 3:
680        // A close action for XAUUSD should use the engine's last XAUUSD
681        // quote, not the current merged-feed event quote (GBPJPY).
682        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}