1use 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
20pub struct ArcExchange(pub Arc<dyn Exchange>);
23
24impl ArcExchange {
25 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
81pub struct NoOpExchange {
88 exchange_id: ExchangeId,
89 environment: bot_core::Environment,
90}
91
92impl NoOpExchange {
93 pub fn new() -> Self {
95 Self::with_config("paper-exchange", bot_core::Environment::Testnet)
96 }
97
98 pub fn with_exchange_id(id: &str) -> Self {
100 Self::with_config(id, bot_core::Environment::Testnet)
101 }
102
103 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 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 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
175struct PaperState {
177 simulator: FillSimulator,
179 margin_ledger: MarginLedger,
181 simulated_fills: Vec<Fill>,
183 time_ms: i64,
185 last_quotes: HashMap<InstrumentId, Quote>,
187 injected_quotes: HashMap<InstrumentId, Quote>,
189 use_injected_quotes: bool,
191 quote_queue: VecDeque<Quote>,
193}
194
195pub type StandalonePaperExchange = PaperExchange<NoOpExchange>;
197
198pub 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
217pub 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
230pub struct PaperExchange<E: Exchange> {
241 quote_source: E,
243 state: Arc<RwLock<PaperState>>,
245}
246
247impl<E: Exchange> PaperExchange<E> {
248 pub fn new(quote_source: E, initial_balances: HashMap<AssetId, Decimal>) -> Self {
250 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 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 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 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 pub async fn balance(&self, asset: &AssetId) -> Decimal {
299 self.state.read().await.simulator.balance(asset)
300 }
301
302 pub async fn pending_orders_count(&self) -> usize {
304 self.state.read().await.simulator.pending_orders_count()
305 }
306
307 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 pub async fn get_balances(&self) -> HashMap<AssetId, Decimal> {
317 let state = self.state.read().await;
318 let mut balances = HashMap::new();
319 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 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 if instrument_str.ends_with("-PERP") {
343 return state.margin_ledger.position_qty(instrument);
344 }
345
346 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 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 pub async fn free_usdc(&self) -> Decimal {
371 self.state.read().await.margin_ledger.free_usdc()
372 }
373
374 pub async fn enable_simulation_mode(&self) {
381 self.state.write().await.use_injected_quotes = true;
382 }
383
384 pub async fn disable_simulation_mode(&self) {
386 self.state.write().await.use_injected_quotes = false;
387 }
388
389 pub async fn is_simulation_mode(&self) -> bool {
391 self.state.read().await.use_injected_quotes
392 }
393
394 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 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 pub async fn has_queued_quotes(&self) -> bool {
424 !self.state.read().await.quote_queue.is_empty()
425 }
426
427 pub async fn queue_len(&self) -> usize {
429 self.state.read().await.quote_queue.len()
430 }
431
432 pub async fn clear_quote_queue(&self) {
434 self.state.write().await.quote_queue.clear();
435 }
436
437 pub async fn set_time(&self, time_ms: i64) {
439 self.state.write().await.time_ms = time_ms;
440 }
441
442 pub async fn advance_time(&self, delta_ms: i64) {
444 self.state.write().await.time_ms += delta_ms;
445 }
446
447 pub async fn current_time(&self) -> i64 {
449 self.state.read().await.time_ms
450 }
451
452 pub async fn fills(&self) -> Vec<Fill> {
454 self.state.read().await.simulated_fills.clone()
455 }
456
457 async fn check_fills(&self) {
459 let mut state = self.state.write().await;
460 let quotes = state.last_quotes.clone();
462 let time_ms = state.time_ms;
463 let simulated = state.simulator.check_fills("es, time_ms);
464
465 for sim_fill in simulated {
466 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 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 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 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 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 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 results.push(PlaceOrderResult::Rejected {
617 reason: "IOC order cannot fill at current price".into(),
618 });
619 }
620 } else {
621 results.push(PlaceOrderResult::Rejected {
623 reason: "No quote available for instrument".into(),
624 });
625 }
626 } else {
627 let created_at = state.time_ms; #[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 self.check_fills().await;
712
713 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 let quotes = if !state.quote_queue.is_empty() {
727 if let Some(quote) = state.quote_queue.pop_front() {
729 state.time_ms = quote.ts;
731 vec![quote]
732 } else {
733 vec![]
734 }
735 } else if state.use_injected_quotes {
736 instruments
738 .iter()
739 .filter_map(|inst| state.injected_quotes.get(inst).cloned())
740 .collect()
741 } else {
742 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 for quote in "es {
753 state
754 .last_quotes
755 .insert(quote.instrument.clone(), quote.clone());
756 }
757
758 let quotes_copy = state.last_quotes.clone();
761 let time_ms = state.time_ms;
762 let simulated = state.simulator.check_fills("es_copy, time_ms);
763 for sim_fill in simulated {
764 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 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 let marks: HashMap<InstrumentId, Decimal> = state
801 .last_quotes
802 .iter()
803 .map(|(id, q)| (id.clone(), q.mid().0))
804 .collect();
805
806 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 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 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)); let mock = MinimalMockExchange::new();
918
919 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 paper
934 .poll_quotes(&[InstrumentId::new("BTC-PERP")])
935 .await
936 .unwrap();
937
938 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 { .. } => { }
955 PlaceOrderResult::Rejected { reason } => {
956 panic!(
957 "PERP short should be accepted with margin, got rejection: {}",
958 reason
959 );
960 }
961 }
962 }
963}