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            commission: 0.0,
254            swap: 0.0,
255            gross_pnl: None,
256        });
257
258        // If partial, reduce the remaining size for future closes.
259        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// ─── Tests ──────────────────────────────────────────────────────────────────
271
272#[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        // Open a buy
301        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        // SL triggers
323        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        // P&L = (bid - entry) * size = (1.0799 - 1.0850) * 1.0 = -0.0051
333        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        // TP1 hit: partial close 50%
366        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        // P&L = (1.0900 - 1.0850) * (2.0 * 0.5) = 0.0050 * 1.0 = 0.005
373        assert!((partial.pnl - 0.005).abs() < 1e-10);
374        assert_eq!(partial.close_reason, CloseReason::Target);
375
376        // Entry still tracked (remaining size = 1.0)
377        assert_eq!(exec.open_count(), 1);
378
379        // SL hit: close remaining
380        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        // remaining_size = 2.0 - 1.0 = 1.0; P&L = (1.0799 - 1.0850) * 1.0 = -0.0051
388        assert!((remaining.pnl - (-0.0051)).abs() < 1e-10);
389    }
390
391    #[test]
392    fn two_targets_partial_close_pnl() {
393        // Open: size=2.0, entry at ask=1.0850 (BidAsk model, Buy side)
394        // TP1: price=1.0900, close_ratio=0.3  → close 0.6 lots
395        // TP2: price=1.0950, close_ratio=0.3  → close 0.6 lots
396        // SL:  price=1.0800                    → close remaining 0.8 lots
397        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        // ── TP1 hits ────────────────────────────────────────────────────
431        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        // close_size = original 2.0 * 0.3 = 0.6
439        assert!(
440            (tp1.size - 0.6).abs() < 1e-10,
441            "TP1 size: expected 0.6, got {}",
442            tp1.size
443        );
444        // pnl = (1.0900 - 1.0850) * 0.6 = 0.003
445        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        // ── TP2 hits ────────────────────────────────────────────────────
453        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        // close_size = original 2.0 * 0.3 = 0.6 (NOT 1.4 * 0.3 = 0.42)
466        assert!(
467            (tp2.size - 0.6).abs() < 1e-10,
468            "TP2 size: expected 0.6, got {}",
469            tp2.size
470        );
471        // pnl = (1.0950 - 1.0850) * 0.6 = 0.006
472        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        // ── SL hits — close remaining 0.8 lots ─────────────────────────
480        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        // remaining = 2.0 - 0.6 - 0.6 = 0.8
489        assert!(
490            (sl.size - 0.8).abs() < 1e-10,
491            "SL size: expected 0.8, got {}",
492            sl.size
493        );
494        // pnl = (1.0799 - 1.0850) * 0.8 = -0.00408
495        assert!(
496            (sl.pnl - (-0.00408)).abs() < 1e-10,
497            "SL pnl: expected -0.00408, got {}",
498            sl.pnl
499        );
500
501        // Total: 0.003 + 0.006 - 0.00408 = 0.00492
502        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        // Close manually
536        let close_quote = make_quote("XAUUSD", 1979.0, 1980.0, ts(10, 5, 0));
537        engine.on_price(&close_quote); // seed last quote
538        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        // Sell P&L = (entry - exit_ask) * size = (2000 - 1980) * 1 = 20
550        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        // Scale in
583        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        // Check that the tracked entry now has averaged price and combined size
598        let entry = exec.open_entries.get(&id).unwrap();
599        assert!((entry.average_entry() - 1.0850).abs() < 1e-10); // (1.08+1.09)/2
600        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        // Regression test for Issue 1 Part 3:
683        // A close action for XAUUSD should use the engine's last XAUUSD
684        // quote, not the current merged-feed event quote (GBPJPY).
685        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}