Skip to main content

bot_engine/testing/
mock_exchange.rs

1//! Mock Exchange for deterministic testing.
2//!
3//! Provides a stateful simulation of an exchange without real API calls.
4//! Supports behavioral controls ("knobs") to test failure modes.
5
6use async_lock::RwLock;
7use bot_core::{
8    AccountState, AssetId, ClientOrderId, Environment, Exchange, ExchangeError, ExchangeId,
9    ExchangeOrderId, Fill, InstrumentId, OrderInput, OrderSide, PlaceOrderResult, PositionSnapshot,
10    Price, Qty, Quote, TimeInForce, TradeId,
11};
12use rust_decimal::Decimal;
13use std::collections::{HashMap, VecDeque};
14use std::sync::Arc;
15
16/// Behavioral control for order execution
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum OrderFailMode {
19    /// Orders always succeed and fill immediately
20    AlwaysSucceed,
21    /// Orders fail if insufficient balance (spot) or margin (perp)
22    FailOnInsufficientBalance,
23    /// IOC orders always reject (no immediate liquidity)
24    IocAlwaysReject,
25}
26
27impl Default for OrderFailMode {
28    fn default() -> Self {
29        Self::AlwaysSucceed
30    }
31}
32
33/// Behavioral controls ("knobs") for testing
34#[derive(Debug, Clone)]
35pub struct MockKnobs {
36    /// Order failure mode.
37    pub order_fail_mode: OrderFailMode,
38    /// Simulated exchange health.
39    pub exchange_health: bot_core::ExchangeHealth,
40    /// Whether requests should timeout.
41    pub should_timeout: bool,
42    /// Whether requests should rate limit.
43    pub should_rate_limit: bool,
44    /// Fill all orders immediately regardless of TIF (for backtesting)
45    pub fill_all_immediately: bool,
46}
47
48impl Default for MockKnobs {
49    fn default() -> Self {
50        Self {
51            order_fail_mode: OrderFailMode::AlwaysSucceed,
52            exchange_health: bot_core::ExchangeHealth::Active,
53            should_timeout: false,
54            should_rate_limit: false,
55            fill_all_immediately: false,
56        }
57    }
58}
59
60/// Internal position tracking
61#[derive(Debug, Clone)]
62struct Position {
63    qty: Decimal,
64    avg_entry_px: Price,
65    unrealized_pnl: Decimal,
66}
67
68/// Pending order for price-crossing fill simulation
69#[derive(Debug, Clone)]
70#[allow(dead_code)]
71struct PendingOrder {
72    oid: u64,
73    order: OrderInput,
74}
75
76/// Internal mock state
77struct MockState {
78    // Account state
79    balances: HashMap<AssetId, Decimal>,
80    positions: HashMap<InstrumentId, Position>,
81    account_value: Decimal,
82
83    // Market data
84    current_mids: HashMap<String, Decimal>,
85
86    // Order tracking
87    next_oid: u64,
88    fills: Vec<Fill>,
89
90    // Event recording
91    placed_orders: Vec<OrderInput>,
92    queued_place_order_outcomes: VecDeque<Result<(), ExchangeError>>,
93
94    // Behavioral controls
95    knobs: MockKnobs,
96
97    // Time control
98    time_ms: i64,
99
100    // Queued quotes for backtesting (FIFO)
101    quote_queue: VecDeque<Quote>,
102
103    // Pending limit orders (for realistic fill simulation)
104    #[allow(dead_code)]
105    pending_orders: HashMap<u64, PendingOrder>,
106}
107
108impl MockState {
109    fn allocate_oid(&mut self) -> u64 {
110        let oid = self.next_oid;
111        self.next_oid += 1;
112        oid
113    }
114
115    fn execute_immediate_fill(&mut self, order: &OrderInput, oid: u64) -> Fill {
116        let trade_id = TradeId::new(format!("mock_{}", oid));
117
118        let fill = Fill {
119            trade_id: trade_id.clone(),
120            client_id: Some(order.client_id.clone()),
121            exchange_order_id: Some(ExchangeOrderId::new(oid.to_string())),
122            instrument: order.instrument.clone(),
123            side: order.side,
124            price: order.price,
125            qty: order.qty,
126            fee: bot_core::Fee::new(Decimal::ZERO, AssetId::new("USDC")),
127            ts: self.time_ms,
128        };
129
130        // Update balances based on fill
131        self.update_balances_after_fill(&fill);
132
133        fill
134    }
135
136    fn update_balances_after_fill(&mut self, fill: &Fill) {
137        let quote_asset = AssetId::new("USDC");
138
139        // Extract base asset from instrument (e.g., "ETH-SPOT" -> "ETH")
140        let instrument_str = fill.instrument.to_string();
141        let base_asset = if let Some(pos) = instrument_str.rfind('-') {
142            AssetId::new(&instrument_str[..pos])
143        } else {
144            AssetId::new(&instrument_str)
145        };
146
147        let notional = fill.price.0 * fill.qty.0;
148
149        match fill.side {
150            OrderSide::Buy => {
151                // Deduct quote, add base
152                *self.balances.entry(quote_asset).or_default() -= notional;
153                *self.balances.entry(base_asset).or_default() += fill.qty.0;
154            }
155            OrderSide::Sell => {
156                // Add quote, deduct base
157                *self.balances.entry(quote_asset).or_default() += notional;
158                *self.balances.entry(base_asset).or_default() -= fill.qty.0;
159            }
160        }
161    }
162
163    fn check_balance(&self, order: &OrderInput) -> Result<(), String> {
164        let quote_asset = AssetId::new("USDC");
165
166        if order.side == OrderSide::Buy {
167            // Check if we have enough quote currency
168            let required = order.price.0 * order.qty.0;
169            let available = self.balances.get(&quote_asset).copied().unwrap_or_default();
170
171            if required > available {
172                return Err(format!(
173                    "Insufficient balance: need {} USDC, have {}",
174                    required, available
175                ));
176            }
177        } else {
178            // Check if we have enough base asset
179            let instrument_str = order.instrument.to_string();
180            let base_asset = if let Some(pos) = instrument_str.rfind('-') {
181                AssetId::new(&instrument_str[..pos])
182            } else {
183                AssetId::new(&instrument_str)
184            };
185
186            let available = self.balances.get(&base_asset).copied().unwrap_or_default();
187
188            if order.qty.0 > available {
189                return Err(format!(
190                    "Insufficient balance: need {} {}, have {}",
191                    order.qty.0, base_asset, available
192                ));
193            }
194        }
195
196        Ok(())
197    }
198}
199
200/// Mock Exchange - stateful simulation for testing
201pub struct MockExchange {
202    exchange_id: ExchangeId,
203    environment: Environment,
204    inner: Arc<RwLock<MockState>>,
205}
206
207impl MockExchange {
208    /// Create a new mock exchange with initial balances
209    pub fn new_with_balances(balances: HashMap<AssetId, Decimal>) -> Self {
210        Self {
211            // Use "hyperliquid" to match production Market enum's exchange_id
212            exchange_id: ExchangeId::new("hyperliquid"),
213            environment: Environment::Testnet,
214            inner: Arc::new(RwLock::new(MockState {
215                balances,
216                positions: HashMap::new(),
217                account_value: Decimal::ZERO,
218                current_mids: HashMap::new(),
219                next_oid: 1000,
220                fills: Vec::new(),
221                placed_orders: Vec::new(),
222                queued_place_order_outcomes: VecDeque::new(),
223                knobs: MockKnobs::default(),
224                time_ms: 0,
225                quote_queue: VecDeque::new(),
226                pending_orders: HashMap::new(),
227            })),
228        }
229    }
230
231    /// Create a new mock exchange with default balances (for testing)
232    pub fn new() -> Self {
233        let mut balances = HashMap::new();
234        balances.insert(AssetId::new("USDC"), Decimal::new(100000, 0)); // 100k USDC
235        balances.insert(AssetId::new("ETH"), Decimal::new(10, 0)); // 10 ETH
236        Self::new_with_balances(balances)
237    }
238
239    // === CONTROL METHODS (Test Knobs) ===
240
241    /// Set order failure behavior.
242    pub async fn set_fail_mode(&self, mode: OrderFailMode) {
243        self.inner.write().await.knobs.order_fail_mode = mode;
244    }
245
246    /// Set simulated exchange health.
247    pub async fn set_exchange_health(&self, health: bot_core::ExchangeHealth) {
248        self.inner.write().await.knobs.exchange_health = health;
249    }
250
251    /// Enable or disable simulated request timeouts.
252    pub async fn set_should_timeout(&self, should_timeout: bool) {
253        self.inner.write().await.knobs.should_timeout = should_timeout;
254    }
255
256    /// Queue one placement error for the next order batch.
257    pub async fn queue_place_order_error(&self, error: ExchangeError) {
258        self.inner
259            .write()
260            .await
261            .queued_place_order_outcomes
262            .push_back(Err(error));
263    }
264
265    /// Queue one placement success for the next order batch.
266    pub async fn queue_place_order_success(&self) {
267        self.inner
268            .write()
269            .await
270            .queued_place_order_outcomes
271            .push_back(Ok(()));
272    }
273
274    /// Enable immediate fill mode for all orders (for backtesting)
275    pub async fn set_fill_all_immediately(&self, fill: bool) {
276        self.inner.write().await.knobs.fill_all_immediately = fill;
277    }
278
279    /// Set the current mid price for a coin.
280    pub async fn set_mid(&self, coin: &str, price: Decimal) {
281        self.inner
282            .write()
283            .await
284            .current_mids
285            .insert(coin.to_string(), price);
286    }
287
288    /// Set the mock exchange clock in milliseconds.
289    pub async fn set_time(&self, time_ms: i64) {
290        self.inner.write().await.time_ms = time_ms;
291    }
292
293    /// Set an asset balance.
294    pub async fn set_balance(&self, asset: AssetId, amount: Decimal) {
295        self.inner.write().await.balances.insert(asset, amount);
296    }
297
298    // === VERIFICATION METHODS ===
299
300    /// Return all placed orders recorded by the mock.
301    pub async fn placed_orders(&self) -> Vec<OrderInput> {
302        self.inner.read().await.placed_orders.clone()
303    }
304
305    /// Return the balance for one asset.
306    pub async fn balance(&self, asset: &AssetId) -> Decimal {
307        self.inner
308            .read()
309            .await
310            .balances
311            .get(asset)
312            .copied()
313            .unwrap_or_default()
314    }
315
316    /// Return all fills recorded by the mock.
317    pub async fn fills(&self) -> Vec<Fill> {
318        self.inner.read().await.fills.clone()
319    }
320
321    // === BACKTESTING METHODS ===
322
323    /// Queue historical quotes for backtesting. These will be returned
324    /// by poll_quotes() in FIFO order, one per call.
325    pub async fn queue_quotes(&self, quotes: Vec<Quote>) {
326        let mut state = self.inner.write().await;
327        state.quote_queue.extend(quotes);
328    }
329
330    /// Check if there are queued quotes remaining
331    pub async fn has_queued_quotes(&self) -> bool {
332        !self.inner.read().await.quote_queue.is_empty()
333    }
334}
335
336impl Default for MockExchange {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342#[async_trait::async_trait]
343impl Exchange for MockExchange {
344    fn exchange_id(&self) -> &ExchangeId {
345        &self.exchange_id
346    }
347
348    fn environment(&self) -> Environment {
349        self.environment
350    }
351
352    async fn place_orders(
353        &self,
354        orders: &[OrderInput],
355    ) -> Result<Vec<PlaceOrderResult>, ExchangeError> {
356        let mut state = self.inner.write().await;
357
358        if let Some(outcome) = state.queued_place_order_outcomes.pop_front() {
359            outcome?;
360        }
361
362        // Record for verification
363        state.placed_orders.extend_from_slice(orders);
364
365        // Check behavioral knobs
366        if state.knobs.exchange_health != bot_core::ExchangeHealth::Active {
367            return Err(ExchangeError::Unavailable);
368        }
369
370        if state.knobs.should_timeout {
371            return Err(ExchangeError::Network("Timeout".into()));
372        }
373
374        if state.knobs.should_rate_limit {
375            return Err(ExchangeError::RateLimited);
376        }
377
378        let mut results = Vec::new();
379
380        for order in orders {
381            match state.knobs.order_fail_mode {
382                OrderFailMode::AlwaysSucceed => {
383                    let oid = state.allocate_oid();
384
385                    // For IOC orders or fill_all_immediately mode, execute immediately
386                    let (filled_qty, avg_fill_px) =
387                        if order.tif == TimeInForce::Ioc || state.knobs.fill_all_immediately {
388                            let fill = state.execute_immediate_fill(order, oid);
389                            let qty = fill.qty;
390                            let px = fill.price;
391                            state.fills.push(fill);
392                            (Some(qty), Some(px))
393                        } else {
394                            (None, None)
395                        };
396
397                    results.push(PlaceOrderResult::Accepted {
398                        exchange_order_id: Some(ExchangeOrderId::new(oid.to_string())),
399                        filled_qty,
400                        avg_fill_px,
401                    });
402                }
403
404                OrderFailMode::FailOnInsufficientBalance => {
405                    // Check balance first
406                    if let Err(reason) = state.check_balance(order) {
407                        results.push(PlaceOrderResult::Rejected { reason });
408                        continue;
409                    }
410
411                    // Otherwise succeed
412                    let oid = state.allocate_oid();
413
414                    let (filled_qty, avg_fill_px) = if order.tif == TimeInForce::Ioc {
415                        let fill = state.execute_immediate_fill(order, oid);
416                        let qty = fill.qty;
417                        let px = fill.price;
418                        state.fills.push(fill);
419                        (Some(qty), Some(px))
420                    } else {
421                        (None, None)
422                    };
423
424                    results.push(PlaceOrderResult::Accepted {
425                        exchange_order_id: Some(ExchangeOrderId::new(oid.to_string())),
426                        filled_qty,
427                        avg_fill_px,
428                    });
429                }
430
431                OrderFailMode::IocAlwaysReject => {
432                    if order.tif == TimeInForce::Ioc {
433                        results.push(PlaceOrderResult::Rejected {
434                            reason: "IOC order rejected: no immediate liquidity".into(),
435                        });
436                    } else {
437                        let oid = state.allocate_oid();
438                        results.push(PlaceOrderResult::Accepted {
439                            exchange_order_id: Some(ExchangeOrderId::new(oid.to_string())),
440                            filled_qty: None,
441                            avg_fill_px: None,
442                        });
443                    }
444                }
445            }
446        }
447
448        Ok(results)
449    }
450
451    async fn cancel_order(
452        &self,
453        _instrument: &InstrumentId,
454        _market_index: &bot_core::MarketIndex,
455        _client_id: &ClientOrderId,
456        _exchange_order_id: Option<&ExchangeOrderId>,
457    ) -> Result<(), ExchangeError> {
458        // Mock: always succeeds
459        Ok(())
460    }
461
462    async fn cancel_all_orders(
463        &self,
464        _instrument: &InstrumentId,
465        _market_index: &bot_core::MarketIndex,
466    ) -> Result<u32, ExchangeError> {
467        // Mock: no orders to cancel
468        Ok(0)
469    }
470
471    async fn poll_user_fills(&self, _cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError> {
472        let state = self.inner.read().await;
473        Ok(state.fills.clone())
474    }
475
476    async fn poll_quotes(&self, instruments: &[InstrumentId]) -> Result<Vec<Quote>, ExchangeError> {
477        let mut state = self.inner.write().await;
478
479        // If we have queued quotes (backtesting mode), pop one from the queue
480        if !state.quote_queue.is_empty() {
481            if let Some(quote) = state.quote_queue.pop_front() {
482                // Update time to match the quote timestamp
483                state.time_ms = quote.ts;
484                return Ok(vec![quote]);
485            }
486        }
487
488        // Otherwise, generate quotes from current_mids (real-time mode)
489        let mut quotes = Vec::new();
490        for instrument in instruments {
491            // Extract coin from instrument (e.g., "ETH-PERP" -> "ETH")
492            let instrument_str = instrument.to_string();
493            let coin = if let Some(pos) = instrument_str.rfind('-') {
494                &instrument_str[..pos]
495            } else {
496                &instrument_str
497            };
498
499            if let Some(mid) = state.current_mids.get(coin) {
500                // Simulate bid/ask spread (5 bps)
501                let spread_bps = Decimal::new(5, 4); // 0.0005
502                let bid = *mid * (Decimal::ONE - spread_bps);
503                let ask = *mid * (Decimal::ONE + spread_bps);
504
505                quotes.push(Quote {
506                    instrument: instrument.clone(),
507                    bid: Price::new(bid),
508                    ask: Price::new(ask),
509                    bid_size: Qty::new(Decimal::new(100, 0)),
510                    ask_size: Qty::new(Decimal::new(100, 0)),
511                    ts: state.time_ms,
512                });
513            }
514        }
515
516        Ok(quotes)
517    }
518
519    async fn poll_account_state(&self) -> Result<AccountState, ExchangeError> {
520        let state = self.inner.read().await;
521
522        let positions: Vec<PositionSnapshot> = state
523            .positions
524            .iter()
525            .map(|(instrument, pos)| PositionSnapshot {
526                instrument: instrument.clone(),
527                qty: pos.qty,
528                avg_entry_px: Some(pos.avg_entry_px),
529                unrealized_pnl: Some(pos.unrealized_pnl),
530                liquidation_px: None,
531            })
532            .collect();
533
534        Ok(AccountState {
535            positions,
536            account_value: Some(state.account_value),
537            unrealized_pnl: Some(Decimal::ZERO),
538        })
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[tokio::test]
547    async fn test_mock_exchange_creation() {
548        let mock = MockExchange::new();
549
550        // Verify initial balances
551        let usdc = mock.balance(&AssetId::new("USDC")).await;
552        assert_eq!(usdc, Decimal::new(100000, 0));
553
554        let eth = mock.balance(&AssetId::new("ETH")).await;
555        assert_eq!(eth, Decimal::new(10, 0));
556    }
557
558    #[tokio::test]
559    async fn test_place_order_always_succeed() {
560        let mock = MockExchange::new();
561        mock.set_mid("ETH", Decimal::new(3000, 0)).await;
562
563        let order = OrderInput {
564            instrument: InstrumentId::new("ETH-SPOT"),
565            market_index: bot_core::MarketIndex::new(0),
566            side: OrderSide::Buy,
567            price: Price::new(Decimal::new(3000, 0)),
568            qty: Qty::new(Decimal::new(1, 1)), // 0.1 ETH
569            client_id: ClientOrderId::generate(),
570            tif: TimeInForce::Ioc,
571            post_only: false,
572            reduce_only: false,
573        };
574
575        let results = mock.place_orders(&[order]).await.unwrap();
576        assert_eq!(results.len(), 1);
577
578        match &results[0] {
579            PlaceOrderResult::Accepted { .. } => {}
580            PlaceOrderResult::Rejected { reason } => {
581                panic!("Order rejected: {}", reason);
582            }
583        }
584
585        // Verify balance updated
586        let usdc = mock.balance(&AssetId::new("USDC")).await;
587        assert_eq!(usdc, Decimal::new(99700, 0)); // 100000 - 300
588
589        let eth = mock.balance(&AssetId::new("ETH")).await;
590        assert_eq!(eth, Decimal::new(101, 1)); // 10 + 0.1
591    }
592
593    #[tokio::test]
594    async fn test_insufficient_balance() {
595        let mock = MockExchange::new();
596        mock.set_fail_mode(OrderFailMode::FailOnInsufficientBalance)
597            .await;
598        mock.set_balance(AssetId::new("USDC"), Decimal::new(10, 0))
599            .await; // Only $10
600
601        let order = OrderInput {
602            instrument: InstrumentId::new("ETH-SPOT"),
603            market_index: bot_core::MarketIndex::new(0),
604            side: OrderSide::Buy,
605            price: Price::new(Decimal::new(3000, 0)),
606            qty: Qty::new(Decimal::new(1, 1)),
607            client_id: ClientOrderId::generate(),
608            tif: TimeInForce::Ioc,
609            post_only: false,
610            reduce_only: false,
611        };
612
613        let results = mock.place_orders(&[order]).await.unwrap();
614
615        match &results[0] {
616            PlaceOrderResult::Rejected { reason } => {
617                assert!(reason.contains("Insufficient balance"));
618            }
619            PlaceOrderResult::Accepted { .. } => {
620                panic!("Order should have been rejected");
621            }
622        }
623    }
624}