1use crate::{
2 UnindexedAccountEvent,
3 client::mock::MockExecutionConfig,
4 exchange::mock::request::{MockExchangeRequest, MockExchangeRequestKind},
5 order::{Order, state::UnindexedOrderState},
6};
7use chrono::{DateTime, TimeDelta, Utc};
8use fnv::FnvHashMap;
9use futures::stream::BoxStream;
10use rustrade_instrument::{
11 asset::name::AssetNameExchange,
12 exchange::ExchangeId,
13 instrument::{Instrument, name::InstrumentNameExchange},
14};
15use std::fmt::Debug;
16use tokio::sync::{broadcast, mpsc, oneshot};
17use tokio_stream::{StreamExt, wrappers::BroadcastStream};
18use tracing::{error, info};
19
20pub mod account;
21pub mod orders;
22pub mod request;
23pub mod venue;
24
25pub use venue::{CancelOutcome, OpenOutcome, SimulatedVenue, VenueInstrumentMarket, VenueOutcome};
26
27#[derive(Debug)]
46pub struct MockExchange {
47 pub venue: SimulatedVenue,
49 pub latency_ms: u64,
51 pub request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
52 pub event_tx: broadcast::Sender<UnindexedAccountEvent>,
53}
54
55impl MockExchange {
56 pub fn new(
57 config: MockExecutionConfig,
58 request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
59 event_tx: broadcast::Sender<UnindexedAccountEvent>,
60 instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
61 ) -> Self {
62 Self {
63 venue: SimulatedVenue::new(&config, instruments),
64 latency_ms: config.latency_ms,
65 request_rx,
66 event_tx,
67 }
68 }
69
70 pub async fn run(mut self) {
77 let (emit_tx, emit_rx) = mpsc::unbounded_channel();
78 let emitter = tokio::spawn(Self::emit_queued_output(
79 emit_rx,
80 self.event_tx.clone(),
81 self.venue.exchange,
82 ));
83
84 while let Some(request) = self.request_rx.recv().await {
85 let expiries = self.advance_venue_time(request.time_request);
89 self.queue_emission(&emit_tx, expiries, None);
90
91 match request.kind {
92 MockExchangeRequestKind::FetchAccountSnapshot { response_tx } => {
93 let snapshot = self.venue.account_snapshot();
94 self.respond_with_latency(response_tx, snapshot);
95 }
96 MockExchangeRequestKind::FetchBalances {
97 response_tx,
98 assets,
99 } => {
100 let balances = self.venue.balances(&assets);
101 self.respond_with_latency(response_tx, balances);
102 }
103 MockExchangeRequestKind::FetchOrdersOpen {
104 response_tx,
105 instruments,
106 } => {
107 let orders_open = self.venue.orders_open(&instruments);
108 self.respond_with_latency(response_tx, orders_open);
109 }
110 MockExchangeRequestKind::FetchOrdersEnded {
111 response_tx,
112 orders,
113 } => {
114 let orders_ended = self.venue.orders_ended(&orders);
115 self.respond_with_latency(response_tx, orders_ended);
116 }
117 MockExchangeRequestKind::FetchTrades {
118 response_tx,
119 start,
120 end,
121 } => {
122 let mut trades = self.venue.trades(start);
123 trades.retain(|trade| trade.time_exchange <= end);
124 self.respond_with_latency(response_tx, trades);
125 }
126 MockExchangeRequestKind::CancelOrder {
127 response_tx,
128 request,
129 } => {
130 let outcome = self.venue.cancel_order(request);
136 self.queue_emission(&emit_tx, outcome.events, None);
137 let _ = response_tx.send(outcome.response);
138 }
139 MockExchangeRequestKind::OpenOrder {
140 response_tx,
141 request,
142 } => {
143 let outcome = self.venue.open_order(request);
146 self.respond_open_with_latency(&emit_tx, response_tx, outcome);
147 }
148 }
149 }
150
151 drop(emit_tx);
155 if let Err(error) = emitter.await {
156 error!(
157 exchange = %self.venue.exchange,
158 %error,
159 "MockExchange emitter task did not shut down cleanly; queued fills may be lost"
160 );
161 }
162
163 info!(exchange = %self.venue.exchange, "MockExchange shutting down");
164 }
165
166 #[must_use]
171 fn advance_venue_time(&mut self, time_request: DateTime<Utc>) -> Vec<UnindexedAccountEvent> {
172 let client_to_exchange_latency = self.latency_ms / 2;
173
174 let time_exchange = time_request
175 .checked_add_signed(TimeDelta::milliseconds(client_to_exchange_latency as i64))
176 .unwrap_or(time_request);
177
178 self.venue.advance_time(time_exchange)
179 }
180
181 fn respond_with_latency<Response>(
186 &self,
187 response_tx: oneshot::Sender<Response>,
188 response: Response,
189 ) where
190 Response: Send + 'static,
191 {
192 let exchange = self.venue.exchange;
193 let latency = std::time::Duration::from_millis(self.latency_ms);
194
195 tokio::spawn(async move {
196 tokio::time::sleep(latency).await;
197 if response_tx.send(response).is_err() {
198 error!(
199 %exchange,
200 kind = std::any::type_name::<Response>(),
201 "MockExchange failed to send oneshot response to client"
202 );
203 }
204 });
205 }
206
207 fn respond_open_with_latency(
223 &self,
224 emit_tx: &mpsc::UnboundedSender<PendingEmission>,
225 response_tx: oneshot::Sender<
226 Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
227 >,
228 outcome: OpenOutcome,
229 ) {
230 self.queue_emission(
231 emit_tx,
232 outcome.events,
233 Some(PendingOpenResponse {
234 response_tx,
235 response: outcome.response,
236 }),
237 );
238 }
239
240 fn queue_emission(
248 &self,
249 emit_tx: &mpsc::UnboundedSender<PendingEmission>,
250 events: Vec<UnindexedAccountEvent>,
251 response: Option<PendingOpenResponse>,
252 ) {
253 if events.is_empty() && response.is_none() {
254 return;
255 }
256
257 let ready_at =
261 tokio::time::Instant::now() + std::time::Duration::from_millis(self.latency_ms);
262
263 if emit_tx
266 .send(PendingEmission {
267 ready_at,
268 events,
269 response,
270 })
271 .is_err()
272 {
273 error!(
274 exchange = %self.venue.exchange,
275 "MockExchange could not queue venue output: the emitter has stopped"
276 );
277 }
278 }
279
280 async fn emit_queued_output(
287 mut emit_rx: mpsc::UnboundedReceiver<PendingEmission>,
288 event_tx: broadcast::Sender<UnindexedAccountEvent>,
289 exchange: ExchangeId,
290 ) {
291 while let Some(emission) = emit_rx.recv().await {
292 tokio::time::sleep_until(emission.ready_at).await;
293
294 for event in emission.events {
296 if event_tx.send(event).is_err() {
297 error!(
298 %exchange,
299 "MockExchange failed to send AccountEvent notification to client"
300 );
301 }
302 }
303
304 let Some(PendingOpenResponse {
306 response_tx,
307 response,
308 }) = emission.response
309 else {
310 continue;
311 };
312
313 if response_tx.send(response).is_err() {
314 error!(
315 %exchange,
316 kind = "OrderResponseOpen",
317 "MockExchange failed to send oneshot response to client"
318 );
319 }
320 }
321 }
322
323 pub fn account_stream(&self) -> BoxStream<'static, UnindexedAccountEvent> {
324 futures::StreamExt::boxed(BroadcastStream::new(self.event_tx.subscribe()).map_while(
325 |result| match result {
326 Ok(event) => Some(event),
327 Err(error) => {
328 error!(
329 ?error,
330 "MockExchange Broadcast AccountStream lagged - terminating"
331 );
332 None
333 }
334 },
335 ))
336 }
337}
338
339#[derive(Debug)]
350struct PendingEmission {
351 ready_at: tokio::time::Instant,
353 events: Vec<UnindexedAccountEvent>,
355 response: Option<PendingOpenResponse>,
358}
359
360#[derive(Debug)]
362struct PendingOpenResponse {
363 response_tx: oneshot::Sender<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
364 response: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
365}
366
367#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)] pub(crate) mod fixtures {
375 use crate::{
376 InstrumentAccountSnapshot, UnindexedAccountSnapshot,
377 balance::{AssetBalance, Balance},
378 client::mock::MockExecutionConfig,
379 fee::FeeModelConfig,
380 fill::SimFillConfig,
381 market::MarketSnapshot,
382 order::{
383 Order, OrderEvent, OrderKey, OrderKind, TimeInForce, UnindexedOrder,
384 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
385 request::{OrderRequestOpen, RequestOpen},
386 state::{Open, OrderState},
387 },
388 position::PositionReport,
389 };
390 use chrono::{DateTime, Utc};
391 use fnv::FnvHashMap;
392 use rust_decimal::Decimal;
393 use rustrade_instrument::{
394 Side, Underlying,
395 asset::name::AssetNameExchange,
396 exchange::ExchangeId,
397 instrument::{
398 Instrument,
399 kind::InstrumentKind,
400 name::{InstrumentNameExchange, InstrumentNameInternal},
401 quote::InstrumentQuoteAsset,
402 },
403 };
404
405 pub(super) const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
406
407 pub(super) fn d(s: &str) -> Decimal {
408 s.parse().unwrap()
409 }
410
411 pub(super) fn base() -> AssetNameExchange {
412 AssetNameExchange::new("BTC")
413 }
414
415 pub(super) fn quote() -> AssetNameExchange {
416 AssetNameExchange::new("USDT")
417 }
418
419 pub(super) fn instrument_name() -> InstrumentNameExchange {
420 InstrumentNameExchange::new("BTCUSDT")
421 }
422
423 pub(super) fn funded(
425 asset: AssetNameExchange,
426 amount: Decimal,
427 ) -> AssetBalance<AssetNameExchange> {
428 AssetBalance {
429 asset,
430 balance: Balance::new(amount, amount),
431 time_exchange: Utc::now(),
432 }
433 }
434
435 pub(super) fn config_from_balances(
437 balances: Vec<AssetBalance<AssetNameExchange>>,
438 fee_model: FeeModelConfig,
439 ) -> MockExecutionConfig {
440 config_from_balances_with_fill(balances, fee_model, SimFillConfig::default())
441 }
442
443 pub(super) fn config_from_balances_with_fill(
444 balances: Vec<AssetBalance<AssetNameExchange>>,
445 fee_model: FeeModelConfig,
446 fill_model: SimFillConfig,
447 ) -> MockExecutionConfig {
448 MockExecutionConfig::new(
449 EXCHANGE,
450 UnindexedAccountSnapshot {
451 exchange: EXCHANGE,
452 balances,
453 instruments: vec![],
454 },
455 0, fee_model,
457 fill_model,
458 )
459 }
460
461 pub(super) fn spot_config_holding(
468 btc: &str,
469 usdt: &str,
470 order: UnindexedOrder,
471 ) -> MockExecutionConfig {
472 MockExecutionConfig::new(
473 EXCHANGE,
474 UnindexedAccountSnapshot {
475 exchange: EXCHANGE,
476 balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
477 instruments: vec![InstrumentAccountSnapshot {
478 instrument: instrument_name(),
479 orders: vec![order],
480 orders_complete: true,
481 position: PositionReport::Unreported,
482 isolated: None,
483 }],
484 },
485 0, FeeModelConfig::default(),
487 SimFillConfig::default(),
488 )
489 }
490
491 pub(super) fn seeded_gtd(
493 cid: &str,
494 price: &str,
495 opened: DateTime<Utc>,
496 expiry: DateTime<Utc>,
497 ) -> UnindexedOrder {
498 Order {
499 key: OrderKey {
500 exchange: EXCHANGE,
501 instrument: instrument_name(),
502 strategy: StrategyId::new("test"),
503 cid: ClientOrderId::new(cid),
504 },
505 side: Side::Buy,
506 price: Some(d(price)),
507 quantity: d("0.01"),
508 kind: OrderKind::Limit,
509 time_in_force: TimeInForce::GoodTillDate { expiry },
510 state: OrderState::active(Open::new(
511 VenueOrderId::Assigned(OrderId::new(cid)),
512 opened,
513 rust_decimal::Decimal::ZERO,
514 )),
515 }
516 }
517
518 pub(super) fn spot_config(
520 btc: &str,
521 usdt: &str,
522 fee_model: FeeModelConfig,
523 ) -> MockExecutionConfig {
524 config_from_balances(
525 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
526 fee_model,
527 )
528 }
529
530 pub(super) fn spot_config_with_fill(
531 btc: &str,
532 usdt: &str,
533 fee_model: FeeModelConfig,
534 fill_model: SimFillConfig,
535 ) -> MockExecutionConfig {
536 config_from_balances_with_fill(
537 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
538 fee_model,
539 fill_model,
540 )
541 }
542
543 pub(super) fn instruments_of(
544 instrument: Instrument<ExchangeId, AssetNameExchange>,
545 ) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
546 let mut instruments = FnvHashMap::default();
547 instruments.insert(instrument.name_exchange.clone(), instrument);
548 instruments
549 }
550
551 pub(super) fn spot_instruments()
552 -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
553 instruments_of(Instrument {
554 exchange: EXCHANGE,
555 name_internal: InstrumentNameInternal::new("btcusdt"),
556 name_exchange: instrument_name(),
557 underlying: Underlying {
558 base: base(),
559 quote: quote(),
560 },
561 quote: InstrumentQuoteAsset::UnderlyingQuote,
562 kind: InstrumentKind::Spot,
563 spec: None,
564 data_venue: None,
565 })
566 }
567
568 pub(super) fn request(
574 instrument: InstrumentNameExchange,
575 side: Side,
576 quantity: &str,
577 market: Option<MarketSnapshot>,
578 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
579 OrderEvent {
580 key: OrderKey {
581 exchange: EXCHANGE,
582 instrument,
583 strategy: StrategyId::new("test"),
584 cid: ClientOrderId::new("test-cid"),
585 },
586 state: RequestOpen {
587 side,
588 price: None, quantity: d(quantity),
590 kind: OrderKind::Market,
591 time_in_force: TimeInForce::ImmediateOrCancel,
592 position_id: None,
593 reduce_only: false,
594 market,
595 },
596 }
597 }
598
599 pub(super) fn buy_request(
600 quantity: &str,
601 market: Option<MarketSnapshot>,
602 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
603 request(instrument_name(), Side::Buy, quantity, market)
604 }
605
606 pub(super) fn sell_request(
607 quantity: &str,
608 market: Option<MarketSnapshot>,
609 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
610 request(instrument_name(), Side::Sell, quantity, market)
611 }
612
613 pub(super) fn gtc() -> TimeInForce {
615 TimeInForce::GoodUntilCancelled { post_only: false }
616 }
617
618 pub(super) fn limit_request(
623 cid: &str,
624 side: Side,
625 quantity: &str,
626 price: &str,
627 time_in_force: TimeInForce,
628 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
629 OrderEvent {
630 key: OrderKey {
631 exchange: EXCHANGE,
632 instrument: instrument_name(),
633 strategy: StrategyId::new("test"),
634 cid: ClientOrderId::new(cid),
635 },
636 state: RequestOpen {
637 side,
638 price: Some(d(price)),
639 quantity: d(quantity),
640 kind: OrderKind::Limit,
641 time_in_force,
642 position_id: None,
643 reduce_only: false,
644 market: None,
645 },
646 }
647 }
648
649 pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
651 MarketSnapshot {
652 best_bid: Some(d(best_bid)),
653 best_ask: Some(d(best_ask)),
654 last_price: None,
655 }
656 }
657
658 pub(super) fn traded(last_price: &str) -> MarketSnapshot {
660 MarketSnapshot {
661 best_bid: None,
662 best_ask: None,
663 last_price: Some(d(last_price)),
664 }
665 }
666
667 pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
669 let p = Some(d(price));
670 Some(MarketSnapshot {
671 best_bid: p,
672 best_ask: p,
673 last_price: p,
674 })
675 }
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
681 use super::{fixtures::*, *};
682 use crate::{
683 AccountEventKind,
684 fee::FeeModelConfig,
685 order::{
686 OrderEvent, OrderKey,
687 id::ClientOrderId,
688 request::RequestCancel,
689 state::{InactiveOrderState, OrderState},
690 },
691 };
692 use rust_decimal::Decimal;
693
694 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
710 async fn open_order_emissions_are_ordered_across_fills() {
711 const FILLS: usize = 8;
712 const DEBIT_PER_FILL: &str = "500";
713
714 let usdt_start = "10000000";
715
716 let (request_tx, request_rx) = mpsc::unbounded_channel();
717 let (event_tx, mut events) = broadcast::channel(64);
718
719 let exchange = MockExchange::new(
720 spot_config("100", usdt_start, FeeModelConfig::default()),
721 request_rx,
722 event_tx,
723 spot_instruments(),
724 );
725
726 let driver = tokio::spawn(exchange.run());
727
728 let mut responses = Vec::with_capacity(FILLS);
729 for nth in 0..FILLS {
730 let (response_tx, response_rx) = oneshot::channel();
731 let mut request = buy_request("0.01", market_prices("50000"));
732 request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
733
734 request_tx
735 .send(MockExchangeRequest::open_order(
736 Utc::now(),
737 response_tx,
738 request,
739 ))
740 .unwrap();
741 responses.push(response_rx);
742 }
743
744 drop(request_tx);
746 driver.await.unwrap();
747
748 for response in responses {
749 let response = response.await.expect("every open is answered");
750 assert!(
751 matches!(
752 response.state,
753 OrderState::Active(_) | OrderState::Inactive(_)
754 ),
755 "the account is funded and the instrument priced, so every open fills: {response:?}"
756 );
757 }
758
759 let balances = std::iter::from_fn(|| events.try_recv().ok())
760 .filter_map(|event| match event.kind {
761 AccountEventKind::BalanceSnapshot(snapshot) => Some(snapshot.0.balance.total),
762 _ => None,
763 })
764 .collect::<Vec<_>>();
765
766 let expected = (1..=FILLS)
768 .map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
769 .collect::<Vec<_>>();
770
771 assert_eq!(
772 balances, expected,
773 "balance snapshots must reach the client in the order the venue booked them"
774 );
775 }
776
777 #[tokio::test(start_paused = true)]
792 async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
793 let opened = Utc::now();
794 let expiry = opened + chrono::TimeDelta::seconds(30);
795
796 let (request_tx, request_rx) = mpsc::unbounded_channel();
797 let (event_tx, mut events) = broadcast::channel(64);
798
799 let mut config =
800 spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
801 config.latency_ms = 50;
803
804 let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
805 let driver = tokio::spawn(exchange.run());
806
807 let (fill_tx, fill_rx) = oneshot::channel();
809 request_tx
810 .send(MockExchangeRequest::open_order(
811 opened,
812 fill_tx,
813 buy_request("0.01", market_prices("50000")),
814 ))
815 .unwrap();
816
817 let (snapshot_tx, snapshot_rx) = oneshot::channel();
820 request_tx
821 .send(MockExchangeRequest::fetch_account_snapshot(
822 expiry + chrono::TimeDelta::seconds(1),
823 snapshot_tx,
824 ))
825 .unwrap();
826
827 drop(request_tx);
828 driver.await.unwrap();
829 fill_rx.await.expect("the open is answered");
830 let _ = snapshot_rx.await;
831
832 let kinds = std::iter::from_fn(|| events.try_recv().ok())
833 .map(|event| match event.kind {
834 AccountEventKind::BalanceSnapshot(_) => "balance",
835 AccountEventKind::Trade(_) => "trade",
836 AccountEventKind::OrderSnapshot(_) => "order",
837 _ => "other",
838 })
839 .collect::<Vec<_>>();
840
841 assert_eq!(
842 kinds,
843 ["balance", "trade", "order"],
844 "the fill's events must all precede the sweep, which for a seeded order — held with \
845 no reservation — owes only its terminal snapshot"
846 );
847 }
848
849 #[tokio::test]
850 async fn a_cancel_request_is_answered_rather_than_dropped() {
851 let (request_tx, request_rx) = mpsc::unbounded_channel();
852 let (event_tx, _events) = broadcast::channel(16);
853
854 let exchange = MockExchange::new(
855 spot_config("100", "10000", FeeModelConfig::default()),
856 request_rx,
857 event_tx,
858 spot_instruments(),
859 );
860 let driver = tokio::spawn(exchange.run());
861
862 let (response_tx, response_rx) = oneshot::channel();
863 let open = buy_request("1", market_prices("50000"));
864 request_tx
865 .send(MockExchangeRequest::cancel_order(
866 Utc::now(),
867 response_tx,
868 OrderEvent {
869 key: open.key,
870 state: RequestCancel { id: None },
871 },
872 ))
873 .unwrap();
874
875 let response = response_rx.await.expect("a cancel must be answered");
876 assert!(
877 response.state.is_err(),
878 "this venue rests no orders, so a cancel must be rejected: {response:?}"
879 );
880
881 drop(request_tx);
882 driver.await.unwrap();
883 }
884
885 #[tokio::test]
888 async fn an_order_state_lookup_is_answered_from_the_ledger() {
889 use crate::client::{OrderStatusClient, mock::MockExecution};
890
891 let (request_tx, request_rx) = mpsc::unbounded_channel();
892 let (event_tx, event_rx) = broadcast::channel(16);
893 let exchange = MockExchange::new(
894 spot_config("100", "10000000", FeeModelConfig::default()),
895 request_rx,
896 event_tx,
897 spot_instruments(),
898 );
899 let driver = tokio::spawn(exchange.run());
900
901 let open = buy_request("1", market_prices("50000"));
902 let (response_tx, response_rx) = oneshot::channel();
903 request_tx
904 .send(MockExchangeRequest::open_order(
905 Utc::now(),
906 response_tx,
907 open.clone(),
908 ))
909 .unwrap();
910 let opened = response_rx.await.expect("an open must be answered");
911 assert!(matches!(
912 opened.state,
913 OrderState::Inactive(InactiveOrderState::FullyFilled(_))
914 ));
915
916 let client = MockExecution {
917 mocked_exchange: EXCHANGE,
918 clock: Utc::now,
919 request_tx: request_tx.clone(),
920 event_rx,
921 };
922 let unknown = OrderKey {
923 cid: ClientOrderId::new("never-opened"),
924 ..open.key.clone()
925 };
926 let ended = client
927 .fetch_ended_orders(&[open.key.clone(), unknown])
928 .await
929 .expect("the venue is running");
930
931 let [order] = ended.as_slice() else {
932 panic!("only the filled order is reported: {ended:?}");
933 };
934 assert_eq!(order.key, open.key);
935 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
936
937 drop(client);
938 drop(request_tx);
939 driver.await.unwrap();
940 }
941}