Skip to main content

bot_engine/testing/
paper_exchange.rs

1//! Paper Exchange: Simulates order execution using real quotes.
2//!
3//! Wraps a real exchange for quote data but simulates fills locally.
4//! Orders fill when the quote price crosses the order price.
5//! Uses FillSimulator for shared fill matching logic.
6//! Uses MarginLedger for accurate perp margin accounting.
7
8use super::fill_simulator::{FillSimulator, PendingOrder};
9use crate::simulation::MarginLedger;
10use async_lock::RwLock;
11use bot_core::{
12    AccountState, AssetId, ClientOrderId, Exchange, ExchangeError, ExchangeId, ExchangeOrderId,
13    Fill, InstrumentId, InstrumentMeta, MarketIndex, OrderInput, PlaceOrderResult, Qty, Quote,
14    TimeInForce,
15};
16use rust_decimal::Decimal;
17use std::collections::{HashMap, VecDeque};
18use std::sync::Arc;
19
20/// Newtype wrapper around `Arc<dyn Exchange>` to implement Exchange trait.
21/// This allows PaperExchange to wrap any `Arc<dyn Exchange>` while satisfying orphan rules.
22pub struct ArcExchange(pub Arc<dyn Exchange>);
23
24impl ArcExchange {
25    /// Wrap an exchange trait object.
26    pub fn new(exchange: Arc<dyn Exchange>) -> Self {
27        Self(exchange)
28    }
29}
30
31#[async_trait::async_trait]
32impl Exchange for ArcExchange {
33    fn exchange_id(&self) -> &ExchangeId {
34        self.0.exchange_id()
35    }
36
37    fn environment(&self) -> bot_core::Environment {
38        self.0.environment()
39    }
40
41    async fn place_orders(
42        &self,
43        orders: &[OrderInput],
44    ) -> Result<Vec<PlaceOrderResult>, ExchangeError> {
45        self.0.place_orders(orders).await
46    }
47
48    async fn cancel_order(
49        &self,
50        instrument: &InstrumentId,
51        market_index: &MarketIndex,
52        client_id: &ClientOrderId,
53        exchange_order_id: Option<&ExchangeOrderId>,
54    ) -> Result<(), ExchangeError> {
55        self.0
56            .cancel_order(instrument, market_index, client_id, exchange_order_id)
57            .await
58    }
59
60    async fn cancel_all_orders(
61        &self,
62        instrument: &InstrumentId,
63        market_index: &MarketIndex,
64    ) -> Result<u32, ExchangeError> {
65        self.0.cancel_all_orders(instrument, market_index).await
66    }
67
68    async fn poll_user_fills(&self, cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError> {
69        self.0.poll_user_fills(cursor).await
70    }
71
72    async fn poll_quotes(&self, instruments: &[InstrumentId]) -> Result<Vec<Quote>, ExchangeError> {
73        self.0.poll_quotes(instruments).await
74    }
75
76    async fn poll_account_state(&self) -> Result<AccountState, ExchangeError> {
77        self.0.poll_account_state().await
78    }
79}
80
81// ============================================================================
82// NoOpExchange - Minimal placeholder for standalone PaperExchange
83// ============================================================================
84
85/// Minimal exchange that does nothing - used internally by PaperExchange::new_standalone()
86/// All quotes come from queue_quotes/inject_quote, not from this exchange.
87pub struct NoOpExchange {
88    exchange_id: ExchangeId,
89    environment: bot_core::Environment,
90}
91
92impl NoOpExchange {
93    /// Create a no-op testnet paper exchange identity.
94    pub fn new() -> Self {
95        Self::with_config("paper-exchange", bot_core::Environment::Testnet)
96    }
97
98    /// Create with a custom exchange ID (for backtest mode matching production exchange)
99    pub fn with_exchange_id(id: &str) -> Self {
100        Self::with_config(id, bot_core::Environment::Testnet)
101    }
102
103    /// Create with custom exchange ID and environment (for full compatibility)
104    pub fn with_config(id: &str, environment: bot_core::Environment) -> Self {
105        Self {
106            exchange_id: ExchangeId::new(id),
107            environment,
108        }
109    }
110}
111
112impl Default for NoOpExchange {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[async_trait::async_trait]
119impl Exchange for NoOpExchange {
120    fn exchange_id(&self) -> &ExchangeId {
121        &self.exchange_id
122    }
123
124    fn environment(&self) -> bot_core::Environment {
125        self.environment
126    }
127
128    async fn place_orders(
129        &self,
130        _orders: &[OrderInput],
131    ) -> Result<Vec<PlaceOrderResult>, ExchangeError> {
132        // PaperExchange handles orders itself, this is never called
133        Ok(vec![])
134    }
135
136    async fn cancel_order(
137        &self,
138        _instrument: &InstrumentId,
139        _market_index: &MarketIndex,
140        _client_id: &ClientOrderId,
141        _exchange_order_id: Option<&ExchangeOrderId>,
142    ) -> Result<(), ExchangeError> {
143        Ok(())
144    }
145
146    async fn cancel_all_orders(
147        &self,
148        _instrument: &InstrumentId,
149        _market_index: &MarketIndex,
150    ) -> Result<u32, ExchangeError> {
151        Ok(0)
152    }
153
154    async fn poll_user_fills(&self, _cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError> {
155        Ok(vec![])
156    }
157
158    async fn poll_quotes(
159        &self,
160        _instruments: &[InstrumentId],
161    ) -> Result<Vec<Quote>, ExchangeError> {
162        // PaperExchange uses queue_quotes/inject_quote, not this
163        Ok(vec![])
164    }
165
166    async fn poll_account_state(&self) -> Result<AccountState, ExchangeError> {
167        Ok(AccountState {
168            positions: vec![],
169            account_value: None,
170            unrealized_pnl: None,
171        })
172    }
173}
174
175/// Internal state for the paper exchange
176struct PaperState {
177    /// Fill simulator handles pending orders, balances, and fill matching
178    simulator: FillSimulator,
179    /// Margin ledger for accurate perp accounting (positions, margin, equity)
180    margin_ledger: MarginLedger,
181    /// Simulated fills waiting to be returned
182    simulated_fills: Vec<Fill>,
183    /// Current time (for fill timestamps)
184    time_ms: i64,
185    /// Last known quotes (for fill matching)
186    last_quotes: HashMap<InstrumentId, Quote>,
187    /// Injected quotes for simulation testing (bypasses real exchange)
188    injected_quotes: HashMap<InstrumentId, Quote>,
189    /// Whether to use injected quotes instead of real ones
190    use_injected_quotes: bool,
191    /// Queued quotes for batch backtesting (FIFO)
192    quote_queue: VecDeque<Quote>,
193}
194
195/// Type alias for standalone PaperExchange (no inner exchange dependency)
196pub type StandalonePaperExchange = PaperExchange<NoOpExchange>;
197
198/// Create a standalone paper exchange for backtesting without any inner exchange.
199/// Uses `queue_quotes()` or `inject_quote()` for price data.
200///
201/// # Example
202/// ```ignore
203/// let exchange = create_standalone_paper_exchange(balances);
204/// exchange.queue_quotes(historical_quotes).await;
205/// // Use exchange for backtesting
206/// ```
207pub fn create_standalone_paper_exchange(
208    initial_balances: HashMap<AssetId, Decimal>,
209) -> PaperExchange<NoOpExchange> {
210    create_standalone_paper_exchange_with_id(
211        initial_balances,
212        "paper-exchange",
213        bot_core::Environment::Testnet,
214    )
215}
216
217/// Create a standalone paper exchange with a custom exchange ID and environment.
218/// Use exchange_id = "hyperliquid" and environment = Mainnet for backtest mode compatibility.
219pub fn create_standalone_paper_exchange_with_id(
220    initial_balances: HashMap<AssetId, Decimal>,
221    exchange_id: &str,
222    environment: bot_core::Environment,
223) -> PaperExchange<NoOpExchange> {
224    PaperExchange::new(
225        NoOpExchange::with_config(exchange_id, environment),
226        initial_balances,
227    )
228}
229
230/// Paper Exchange - wraps a real exchange for quotes, simulates fills locally.
231///
232/// Uses `FillSimulator` for shared fill matching logic.
233///
234/// # Usage
235/// ```ignore
236/// let real_exchange = HyperliquidExchange::new(config);
237/// let paper = PaperExchange::new(real_exchange, initial_balances);
238/// // Use paper as the exchange - strategies see no difference
239/// ```
240pub struct PaperExchange<E: Exchange> {
241    /// The real exchange for quote data
242    quote_source: E,
243    /// Internal state
244    state: Arc<RwLock<PaperState>>,
245}
246
247impl<E: Exchange> PaperExchange<E> {
248    /// Create a new paper exchange wrapping a real quote source
249    pub fn new(quote_source: E, initial_balances: HashMap<AssetId, Decimal>) -> Self {
250        // Extract starting USDC for the margin ledger
251        let starting_usdc = initial_balances
252            .get(&AssetId::new("USDC"))
253            .copied()
254            .unwrap_or(Decimal::ZERO);
255
256        Self {
257            quote_source,
258            state: Arc::new(RwLock::new(PaperState {
259                simulator: FillSimulator::new(initial_balances),
260                margin_ledger: MarginLedger::new(starting_usdc, Decimal::ZERO),
261                simulated_fills: Vec::new(),
262                time_ms: bot_core::now_ms(),
263                last_quotes: HashMap::new(),
264                injected_quotes: HashMap::new(),
265                use_injected_quotes: false,
266                quote_queue: VecDeque::new(),
267            })),
268        }
269    }
270
271    /// Set a balance directly (for testing)
272    pub async fn set_balance(&self, asset: AssetId, amount: Decimal) {
273        self.state
274            .write()
275            .await
276            .simulator
277            .set_balance(asset, amount);
278    }
279
280    /// Register instrument metadata for quote-aware paper/backtest accounting.
281    pub async fn register_instrument_meta(&self, meta: &InstrumentMeta) {
282        self.state
283            .write()
284            .await
285            .simulator
286            .register_instrument_meta(meta);
287    }
288
289    /// Register several instrument metadata records.
290    pub async fn register_instrument_metas(&self, metas: &[InstrumentMeta]) {
291        let mut state = self.state.write().await;
292        for meta in metas {
293            state.simulator.register_instrument_meta(meta);
294        }
295    }
296
297    /// Get current balance
298    pub async fn balance(&self, asset: &AssetId) -> Decimal {
299        self.state.read().await.simulator.balance(asset)
300    }
301
302    /// Get pending orders count
303    pub async fn pending_orders_count(&self) -> usize {
304        self.state.read().await.simulator.pending_orders_count()
305    }
306
307    /// Set fee rate for fill simulation (e.g., 0.0004 = 0.04%)
308    /// For spot BUY orders, fee is deducted from base asset
309    pub async fn set_fee_rate(&self, fee_rate: Decimal) {
310        let mut state = self.state.write().await;
311        state.simulator.set_fee_rate(fee_rate);
312        state.margin_ledger.set_fee_rate(fee_rate);
313    }
314
315    /// Get current balances (for testing)
316    pub async fn get_balances(&self) -> HashMap<AssetId, Decimal> {
317        let state = self.state.read().await;
318        let mut balances = HashMap::new();
319        // Get USDC balance
320        balances.insert(
321            AssetId::new("USDC"),
322            state.simulator.balance(&AssetId::new("USDC")),
323        );
324        balances.insert(
325            AssetId::new("BTC"),
326            state.simulator.balance(&AssetId::new("BTC")),
327        );
328        balances.insert(
329            AssetId::new("ETH"),
330            state.simulator.balance(&AssetId::new("ETH")),
331        );
332        balances
333    }
334
335    /// Get position for an instrument (for testing)
336    /// Returns the net position qty (positive = long, negative = short)
337    pub async fn get_position(&self, instrument: &InstrumentId) -> Decimal {
338        let instrument_str = instrument.to_string();
339        let state = self.state.read().await;
340
341        // For PERP instruments, use margin ledger
342        if instrument_str.ends_with("-PERP") {
343            return state.margin_ledger.position_qty(instrument);
344        }
345
346        // For SPOT, position is the base asset balance
347        let base_asset = if let Some(pos) = instrument_str.rfind('-') {
348            AssetId::new(&instrument_str[..pos])
349        } else {
350            AssetId::new(&instrument_str)
351        };
352        state.simulator.balance(&base_asset)
353    }
354
355    /// Set leverage for an instrument in the margin ledger
356    pub async fn set_instrument_leverage(
357        &self,
358        instrument: &InstrumentId,
359        leverage: Decimal,
360        max_leverage: Decimal,
361    ) {
362        self.state
363            .write()
364            .await
365            .margin_ledger
366            .set_leverage(instrument, leverage, max_leverage);
367    }
368
369    /// Get the margin ledger's free USDC (for testing)
370    pub async fn free_usdc(&self) -> Decimal {
371        self.state.read().await.margin_ledger.free_usdc()
372    }
373
374    // =========================================================================
375    // Simulation Testing Controls
376    // =========================================================================
377
378    /// Enable simulation mode - quotes will come from injected_quotes instead of real exchange.
379    /// This allows deterministic testing where you control the price feed.
380    pub async fn enable_simulation_mode(&self) {
381        self.state.write().await.use_injected_quotes = true;
382    }
383
384    /// Disable simulation mode - return to using real exchange quotes.
385    pub async fn disable_simulation_mode(&self) {
386        self.state.write().await.use_injected_quotes = false;
387    }
388
389    /// Check if simulation mode is enabled.
390    pub async fn is_simulation_mode(&self) -> bool {
391        self.state.read().await.use_injected_quotes
392    }
393
394    /// Inject a quote for simulation testing.
395    /// When simulation mode is enabled, poll_quotes will return these instead of real quotes.
396    pub async fn inject_quote(&self, instrument: InstrumentId, bid: Decimal, ask: Decimal) {
397        let mut state = self.state.write().await;
398        let quote = Quote {
399            instrument: instrument.clone(),
400            bid: bot_core::Price::new(bid),
401            ask: bot_core::Price::new(ask),
402            bid_size: Qty::new(Decimal::new(1000, 0)),
403            ask_size: Qty::new(Decimal::new(1000, 0)),
404            ts: state.time_ms,
405        };
406        state.injected_quotes.insert(instrument, quote);
407    }
408
409    // =========================================================================
410    // Batch Backtesting Controls
411    // =========================================================================
412
413    /// Queue multiple quotes for batch backtesting (FIFO).
414    /// When quotes are queued, poll_quotes will pop from this queue first.
415    /// This enables fast-forward backtesting with realistic price-crossing fills.
416    pub async fn queue_quotes(&self, quotes: Vec<Quote>) {
417        let mut state = self.state.write().await;
418        state.quote_queue.extend(quotes);
419    }
420
421    /// Check if there are still queued quotes remaining.
422    /// Use this to detect when backtesting is complete.
423    pub async fn has_queued_quotes(&self) -> bool {
424        !self.state.read().await.quote_queue.is_empty()
425    }
426
427    /// Get the number of queued quotes remaining.
428    pub async fn queue_len(&self) -> usize {
429        self.state.read().await.quote_queue.len()
430    }
431
432    /// Clear all queued quotes.
433    pub async fn clear_quote_queue(&self) {
434        self.state.write().await.quote_queue.clear();
435    }
436
437    /// Set simulated time (for deterministic testing).
438    pub async fn set_time(&self, time_ms: i64) {
439        self.state.write().await.time_ms = time_ms;
440    }
441
442    /// Advance simulated time by delta milliseconds.
443    pub async fn advance_time(&self, delta_ms: i64) {
444        self.state.write().await.time_ms += delta_ms;
445    }
446
447    /// Get current simulated time.
448    pub async fn current_time(&self) -> i64 {
449        self.state.read().await.time_ms
450    }
451
452    /// Get all simulated fills (for testing).
453    pub async fn fills(&self) -> Vec<Fill> {
454        self.state.read().await.simulated_fills.clone()
455    }
456
457    /// Check if any pending orders can be filled based on current quotes
458    async fn check_fills(&self) {
459        let mut state = self.state.write().await;
460        // Clone to avoid borrow conflict
461        let quotes = state.last_quotes.clone();
462        let time_ms = state.time_ms;
463        let simulated = state.simulator.check_fills(&quotes, time_ms);
464
465        for sim_fill in simulated {
466            // Apply margin accounting for PERP fills
467            if state
468                .simulator
469                .instrument_is_perp(&sim_fill.fill.instrument)
470            {
471                state.margin_ledger.apply_perp_fill(
472                    &sim_fill.fill.instrument,
473                    sim_fill.fill.side,
474                    sim_fill.fill.price.0,
475                    sim_fill.fill.qty.0,
476                    sim_fill.fill.fee.amount,
477                );
478            }
479            state.simulated_fills.push(sim_fill.fill);
480        }
481    }
482
483    /// Check if balance is sufficient for order.
484    /// For PERP instruments: uses margin-aware check (notional / leverage).
485    /// For spot-like instruments: selling requires base, buying requires quote.
486    fn check_balance_for_order(
487        simulator: &FillSimulator,
488        margin_ledger: &MarginLedger,
489        order: &OrderInput,
490    ) -> Result<(), String> {
491        if simulator.instrument_is_perp(&order.instrument) {
492            // PERP: delegate to margin-aware check
493            margin_ledger.check_margin_for_perp_order(
494                &order.instrument,
495                order.side,
496                order.price.0,
497                order.qty.0,
498                order.reduce_only,
499            )
500        } else {
501            simulator.check_balance(&order.instrument, order.side, order.price.0, order.qty.0)
502        }
503    }
504}
505
506#[async_trait::async_trait]
507impl<E: Exchange + Send + Sync> Exchange for PaperExchange<E> {
508    fn exchange_id(&self) -> &ExchangeId {
509        self.quote_source.exchange_id()
510    }
511
512    fn environment(&self) -> bot_core::Environment {
513        self.quote_source.environment()
514    }
515
516    async fn place_orders(
517        &self,
518        orders: &[OrderInput],
519    ) -> Result<Vec<PlaceOrderResult>, ExchangeError> {
520        #[cfg(feature = "wasm")]
521        web_sys::console::log_1(
522            &format!(
523                "[WASM Runner] place_orders called with {} orders",
524                orders.len()
525            )
526            .into(),
527        );
528        tracing::debug!(
529            "[WASM Runner] place_orders called with {} orders",
530            orders.len()
531        );
532        let mut state = self.state.write().await;
533        let mut results = Vec::new();
534
535        for order in orders {
536            #[cfg(feature = "wasm")]
537            web_sys::console::log_1(
538                &format!(
539                    "[WASM Runner] Processing order: {:?} {} {} @ {} (instrument={})",
540                    order.client_id, order.side, order.qty, order.price, order.instrument
541                )
542                .into(),
543            );
544            tracing::debug!(
545                "[WASM Runner] Processing order: {:?} {} {} @ {}",
546                order.client_id,
547                order.side,
548                order.qty,
549                order.price
550            );
551
552            // Check balance using margin-aware logic
553            if let Err(reason) =
554                Self::check_balance_for_order(&state.simulator, &state.margin_ledger, order)
555            {
556                #[cfg(feature = "wasm")]
557                web_sys::console::log_1(
558                    &format!("[WASM Runner] Order REJECTED: {}", reason).into(),
559                );
560                tracing::debug!("[WASM Runner] Order rejected: {}", reason);
561                results.push(PlaceOrderResult::Rejected { reason });
562                continue;
563            }
564
565            let exchange_order_id = state.simulator.next_exchange_order_id("paper");
566
567            // For IOC orders, check if we can fill immediately
568            if order.tif == TimeInForce::Ioc {
569                if let Some(quote) = state.last_quotes.get(&order.instrument) {
570                    let can_fill = match order.side {
571                        bot_core::OrderSide::Buy => quote.ask.0 <= order.price.0,
572                        bot_core::OrderSide::Sell => quote.bid.0 >= order.price.0,
573                    };
574
575                    if can_fill {
576                        let fill_price = match order.side {
577                            bot_core::OrderSide::Buy => quote.ask,
578                            bot_core::OrderSide::Sell => quote.bid,
579                        };
580
581                        let fill = Fill {
582                            trade_id: bot_core::TradeId::new(format!(
583                                "paper_{}",
584                                exchange_order_id.0
585                            )),
586                            client_id: Some(order.client_id.clone()),
587                            exchange_order_id: Some(exchange_order_id.clone()),
588                            instrument: order.instrument.clone(),
589                            side: order.side,
590                            price: fill_price,
591                            qty: order.qty.clone(),
592                            fee: bot_core::Fee::new(Decimal::ZERO, AssetId::new("USDC")),
593                            ts: state.time_ms,
594                        };
595
596                        if state.simulator.instrument_is_perp(&fill.instrument) {
597                            // PERP: use margin ledger for proper accounting
598                            state.margin_ledger.apply_perp_fill(
599                                &fill.instrument,
600                                fill.side,
601                                fill.price.0,
602                                fill.qty.0,
603                                fill.fee.amount,
604                            );
605                        }
606                        state.simulator.apply_fill(&fill);
607                        state.simulated_fills.push(fill.clone());
608
609                        results.push(PlaceOrderResult::Accepted {
610                            exchange_order_id: Some(exchange_order_id),
611                            filled_qty: Some(fill.qty),
612                            avg_fill_px: Some(fill.price),
613                        });
614                    } else {
615                        // IOC cannot fill immediately
616                        results.push(PlaceOrderResult::Rejected {
617                            reason: "IOC order cannot fill at current price".into(),
618                        });
619                    }
620                } else {
621                    // No quote available
622                    results.push(PlaceOrderResult::Rejected {
623                        reason: "No quote available for instrument".into(),
624                    });
625                }
626            } else {
627                // Non-IOC orders go to pending via FillSimulator
628                let created_at = state.time_ms; // Copy before mutable borrow
629                #[cfg(feature = "wasm")]
630                web_sys::console::log_1(
631                    &format!(
632                        "[WASM Runner] Adding to simulator pending: {} {} @ {} (TIF={:?})",
633                        order.side, order.qty, order.price, order.tif
634                    )
635                    .into(),
636                );
637                state.simulator.add_pending_order(PendingOrder {
638                    client_id: order.client_id.clone(),
639                    exchange_order_id: exchange_order_id.clone(),
640                    instrument: order.instrument.clone(),
641                    side: order.side,
642                    price: order.price.clone(),
643                    qty: order.qty.clone(),
644                    remaining_qty: order.qty.clone(),
645                    created_at,
646                });
647                #[cfg(feature = "wasm")]
648                web_sys::console::log_1(
649                    &format!(
650                        "[WASM Runner] After add: pending_count={}",
651                        state.simulator.pending_orders_count()
652                    )
653                    .into(),
654                );
655
656                results.push(PlaceOrderResult::Accepted {
657                    exchange_order_id: Some(exchange_order_id),
658                    filled_qty: None,
659                    avg_fill_px: None,
660                });
661            }
662        }
663
664        Ok(results)
665    }
666
667    async fn cancel_order(
668        &self,
669        _instrument: &InstrumentId,
670        _market_index: &MarketIndex,
671        client_id: &ClientOrderId,
672        _exchange_order_id: Option<&ExchangeOrderId>,
673    ) -> Result<(), ExchangeError> {
674        #[cfg(feature = "wasm")]
675        web_sys::console::log_1(
676            &format!("[WASM Runner] cancel_order called: {:?}", client_id).into(),
677        );
678        let mut state = self.state.write().await;
679        state.simulator.remove_order(client_id);
680        Ok(())
681    }
682
683    async fn cancel_all_orders(
684        &self,
685        instrument: &InstrumentId,
686        _market_index: &MarketIndex,
687    ) -> Result<u32, ExchangeError> {
688        #[cfg(feature = "wasm")]
689        web_sys::console::log_1(
690            &format!(
691                "[WASM Runner] cancel_all_orders called: instrument={}",
692                instrument
693            )
694            .into(),
695        );
696        let mut state = self.state.write().await;
697        let removed = state.simulator.remove_orders_for_instrument(instrument);
698        #[cfg(feature = "wasm")]
699        web_sys::console::log_1(
700            &format!(
701                "[WASM Runner] cancel_all_orders removed {} orders",
702                removed.len()
703            )
704            .into(),
705        );
706        Ok(removed.len() as u32)
707    }
708
709    async fn poll_user_fills(&self, _cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError> {
710        // Check if any pending orders can now fill
711        self.check_fills().await;
712
713        // Drain simulated fills
714        let mut state = self.state.write().await;
715        let fills = std::mem::take(&mut state.simulated_fills);
716        Ok(fills)
717    }
718
719    async fn poll_quotes(&self, instruments: &[InstrumentId]) -> Result<Vec<Quote>, ExchangeError> {
720        let mut state = self.state.write().await;
721
722        // Quote source priority:
723        // 1. quote_queue (batch backtest mode) - pop next quote
724        // 2. injected_quotes (simulation mode) - use latest injected
725        // 3. real exchange (live/paper trading) - fetch from underlying
726        let quotes = if !state.quote_queue.is_empty() {
727            // Batch backtest mode: pop next quote from queue
728            if let Some(quote) = state.quote_queue.pop_front() {
729                // Update time to match the quote timestamp
730                state.time_ms = quote.ts;
731                vec![quote]
732            } else {
733                vec![]
734            }
735        } else if state.use_injected_quotes {
736            // Simulation mode: use injected quotes
737            instruments
738                .iter()
739                .filter_map(|inst| state.injected_quotes.get(inst).cloned())
740                .collect()
741        } else {
742            // Normal mode: get real quotes from the underlying exchange
743            // Note: we need to drop the lock before the await
744            drop(state);
745            let quotes = self.quote_source.poll_quotes(instruments).await?;
746            state = self.state.write().await;
747            state.time_ms = bot_core::now_ms();
748            quotes
749        };
750
751        // Store for fill matching
752        for quote in &quotes {
753            state
754                .last_quotes
755                .insert(quote.instrument.clone(), quote.clone());
756        }
757
758        // Check fills immediately after updating quotes
759        // This is important because runner calls poll_user_fills BEFORE poll_quotes
760        let quotes_copy = state.last_quotes.clone();
761        let time_ms = state.time_ms;
762        let simulated = state.simulator.check_fills(&quotes_copy, time_ms);
763        for sim_fill in simulated {
764            // Apply margin accounting for PERP fills
765            if state
766                .simulator
767                .instrument_is_perp(&sim_fill.fill.instrument)
768            {
769                state.margin_ledger.apply_perp_fill(
770                    &sim_fill.fill.instrument,
771                    sim_fill.fill.side,
772                    sim_fill.fill.price.0,
773                    sim_fill.fill.qty.0,
774                    sim_fill.fill.fee.amount,
775                );
776            }
777            state.simulated_fills.push(sim_fill.fill);
778        }
779
780        // Check for liquidations at current mark prices
781        let marks: HashMap<InstrumentId, Decimal> = state
782            .last_quotes
783            .iter()
784            .map(|(id, q)| (id.clone(), q.mid().0))
785            .collect();
786        let liquidated = state.margin_ledger.check_liquidations(&marks);
787        for instrument in liquidated {
788            if let Some(mark) = marks.get(&instrument) {
789                state.margin_ledger.liquidate(&instrument, *mark);
790            }
791        }
792
793        Ok(quotes)
794    }
795
796    async fn poll_account_state(&self) -> Result<AccountState, ExchangeError> {
797        let state = self.state.read().await;
798
799        // Build mark prices from last known quotes
800        let marks: HashMap<InstrumentId, Decimal> = state
801            .last_quotes
802            .iter()
803            .map(|(id, q)| (id.clone(), q.mid().0))
804            .collect();
805
806        // Return real equity including margin positions and unrealized PnL
807        let equity = state.margin_ledger.equity(&marks);
808        let unrealized = state.margin_ledger.total_unrealized_pnl(&marks);
809        let positions = state.margin_ledger.position_snapshots(&marks);
810
811        Ok(AccountState {
812            positions,
813            account_value: Some(equity),
814            unrealized_pnl: Some(unrealized),
815        })
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use bot_core::{OrderSide, Price};
823
824    // Create a minimal mock for testing PaperExchange
825    struct MinimalMockExchange {
826        exchange_id: ExchangeId,
827        quotes: Arc<RwLock<Vec<Quote>>>,
828    }
829
830    impl MinimalMockExchange {
831        fn new() -> Self {
832            Self {
833                exchange_id: ExchangeId::new("minimal-mock"),
834                quotes: Arc::new(RwLock::new(Vec::new())),
835            }
836        }
837
838        async fn set_quote(&self, quote: Quote) {
839            self.quotes.write().await.push(quote);
840        }
841    }
842
843    #[async_trait::async_trait]
844    impl Exchange for MinimalMockExchange {
845        fn exchange_id(&self) -> &ExchangeId {
846            &self.exchange_id
847        }
848
849        fn environment(&self) -> bot_core::Environment {
850            bot_core::Environment::Testnet
851        }
852
853        async fn place_orders(
854            &self,
855            _orders: &[OrderInput],
856        ) -> Result<Vec<PlaceOrderResult>, ExchangeError> {
857            unimplemented!("MinimalMockExchange doesn't support orders")
858        }
859
860        async fn cancel_order(
861            &self,
862            _instrument: &InstrumentId,
863            _market_index: &MarketIndex,
864            _client_id: &ClientOrderId,
865            _exchange_order_id: Option<&ExchangeOrderId>,
866        ) -> Result<(), ExchangeError> {
867            Ok(())
868        }
869
870        async fn cancel_all_orders(
871            &self,
872            _instrument: &InstrumentId,
873            _market_index: &MarketIndex,
874        ) -> Result<u32, ExchangeError> {
875            Ok(0)
876        }
877
878        async fn poll_user_fills(&self, _cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError> {
879            Ok(vec![])
880        }
881
882        async fn poll_quotes(
883            &self,
884            _instruments: &[InstrumentId],
885        ) -> Result<Vec<Quote>, ExchangeError> {
886            let quotes = self.quotes.read().await;
887            Ok(quotes.clone())
888        }
889
890        async fn poll_account_state(&self) -> Result<AccountState, ExchangeError> {
891            Ok(AccountState {
892                positions: vec![],
893                account_value: None,
894                unrealized_pnl: None,
895            })
896        }
897    }
898
899    #[tokio::test]
900    async fn test_paper_exchange_uses_fill_simulator() {
901        let mut balances = HashMap::new();
902        balances.insert(AssetId::new("USDC"), Decimal::new(10000, 0));
903
904        let mock = MinimalMockExchange::new();
905        let paper = PaperExchange::new(mock, balances);
906
907        // Check USDC balance
908        let balance = paper.balance(&AssetId::new("USDC")).await;
909        assert_eq!(balance, Decimal::new(10000, 0));
910    }
911
912    #[tokio::test]
913    async fn test_paper_exchange_accepts_perp_short() {
914        let mut balances = HashMap::new();
915        balances.insert(AssetId::new("USDC"), Decimal::new(100000, 0)); // 100k margin
916
917        let mock = MinimalMockExchange::new();
918
919        // Set a quote first
920        mock.set_quote(Quote {
921            instrument: InstrumentId::new("BTC-PERP"),
922            bid: Price::new(Decimal::new(50000, 0)),
923            ask: Price::new(Decimal::new(50001, 0)),
924            bid_size: Qty::new(Decimal::new(10, 0)),
925            ask_size: Qty::new(Decimal::new(10, 0)),
926            ts: 0,
927        })
928        .await;
929
930        let paper = PaperExchange::new(mock, balances);
931
932        // Poll quotes to populate last_quotes
933        paper
934            .poll_quotes(&[InstrumentId::new("BTC-PERP")])
935            .await
936            .unwrap();
937
938        // Place a SELL (short) order on PERP - should work with only margin
939        let orders = vec![OrderInput {
940            client_id: ClientOrderId::new("0xtest12345678901234567890123456"),
941            instrument: InstrumentId::new("BTC-PERP"),
942            market_index: MarketIndex::new(0),
943            side: OrderSide::Sell,
944            price: Price::new(Decimal::new(51000, 0)),
945            qty: Qty::new(Decimal::new(1, 0)),
946            tif: bot_core::TimeInForce::Gtc,
947            post_only: false,
948            reduce_only: false,
949        }];
950
951        let results = paper.place_orders(&orders).await.unwrap();
952
953        match &results[0] {
954            PlaceOrderResult::Accepted { .. } => { /* expected */ }
955            PlaceOrderResult::Rejected { reason } => {
956                panic!(
957                    "PERP short should be accepted with margin, got rejection: {}",
958                    reason
959                );
960            }
961        }
962    }
963}