1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum OrderFailMode {
19 AlwaysSucceed,
21 FailOnInsufficientBalance,
23 IocAlwaysReject,
25}
26
27impl Default for OrderFailMode {
28 fn default() -> Self {
29 Self::AlwaysSucceed
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct MockKnobs {
36 pub order_fail_mode: OrderFailMode,
38 pub exchange_health: bot_core::ExchangeHealth,
40 pub should_timeout: bool,
42 pub should_rate_limit: bool,
44 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#[derive(Debug, Clone)]
62struct Position {
63 qty: Decimal,
64 avg_entry_px: Price,
65 unrealized_pnl: Decimal,
66}
67
68#[derive(Debug, Clone)]
70#[allow(dead_code)]
71struct PendingOrder {
72 oid: u64,
73 order: OrderInput,
74}
75
76struct MockState {
78 balances: HashMap<AssetId, Decimal>,
80 positions: HashMap<InstrumentId, Position>,
81 account_value: Decimal,
82
83 current_mids: HashMap<String, Decimal>,
85
86 next_oid: u64,
88 fills: Vec<Fill>,
89
90 placed_orders: Vec<OrderInput>,
92 queued_place_order_outcomes: VecDeque<Result<(), ExchangeError>>,
93
94 knobs: MockKnobs,
96
97 time_ms: i64,
99
100 quote_queue: VecDeque<Quote>,
102
103 #[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 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 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 *self.balances.entry(quote_asset).or_default() -= notional;
153 *self.balances.entry(base_asset).or_default() += fill.qty.0;
154 }
155 OrderSide::Sell => {
156 *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 let required = order.price.0 * order.qty.0;
169 let available = self.balances.get("e_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 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
200pub struct MockExchange {
202 exchange_id: ExchangeId,
203 environment: Environment,
204 inner: Arc<RwLock<MockState>>,
205}
206
207impl MockExchange {
208 pub fn new_with_balances(balances: HashMap<AssetId, Decimal>) -> Self {
210 Self {
211 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 pub fn new() -> Self {
233 let mut balances = HashMap::new();
234 balances.insert(AssetId::new("USDC"), Decimal::new(100000, 0)); balances.insert(AssetId::new("ETH"), Decimal::new(10, 0)); Self::new_with_balances(balances)
237 }
238
239 pub async fn set_fail_mode(&self, mode: OrderFailMode) {
243 self.inner.write().await.knobs.order_fail_mode = mode;
244 }
245
246 pub async fn set_exchange_health(&self, health: bot_core::ExchangeHealth) {
248 self.inner.write().await.knobs.exchange_health = health;
249 }
250
251 pub async fn set_should_timeout(&self, should_timeout: bool) {
253 self.inner.write().await.knobs.should_timeout = should_timeout;
254 }
255
256 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 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 pub async fn set_fill_all_immediately(&self, fill: bool) {
276 self.inner.write().await.knobs.fill_all_immediately = fill;
277 }
278
279 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 pub async fn set_time(&self, time_ms: i64) {
290 self.inner.write().await.time_ms = time_ms;
291 }
292
293 pub async fn set_balance(&self, asset: AssetId, amount: Decimal) {
295 self.inner.write().await.balances.insert(asset, amount);
296 }
297
298 pub async fn placed_orders(&self) -> Vec<OrderInput> {
302 self.inner.read().await.placed_orders.clone()
303 }
304
305 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 pub async fn fills(&self) -> Vec<Fill> {
318 self.inner.read().await.fills.clone()
319 }
320
321 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 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 state.placed_orders.extend_from_slice(orders);
364
365 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 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 if let Err(reason) = state.check_balance(order) {
407 results.push(PlaceOrderResult::Rejected { reason });
408 continue;
409 }
410
411 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 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 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 !state.quote_queue.is_empty() {
481 if let Some(quote) = state.quote_queue.pop_front() {
482 state.time_ms = quote.ts;
484 return Ok(vec![quote]);
485 }
486 }
487
488 let mut quotes = Vec::new();
490 for instrument in instruments {
491 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 let spread_bps = Decimal::new(5, 4); 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 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)), 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 let usdc = mock.balance(&AssetId::new("USDC")).await;
587 assert_eq!(usdc, Decimal::new(99700, 0)); let eth = mock.balance(&AssetId::new("ETH")).await;
590 assert_eq!(eth, Decimal::new(101, 1)); }
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; 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}