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::FetchTrades {
111 response_tx,
112 time_since,
113 } => {
114 let trades = self.venue.trades(time_since);
115 self.respond_with_latency(response_tx, trades);
116 }
117 MockExchangeRequestKind::CancelOrder {
118 response_tx,
119 request,
120 } => {
121 let outcome = self.venue.cancel_order(request);
127 self.queue_emission(&emit_tx, outcome.events, None);
128 let _ = response_tx.send(outcome.response);
129 }
130 MockExchangeRequestKind::OpenOrder {
131 response_tx,
132 request,
133 } => {
134 let outcome = self.venue.open_order(request);
137 self.respond_open_with_latency(&emit_tx, response_tx, outcome);
138 }
139 }
140 }
141
142 drop(emit_tx);
146 if let Err(error) = emitter.await {
147 error!(
148 exchange = %self.venue.exchange,
149 %error,
150 "MockExchange emitter task did not shut down cleanly; queued fills may be lost"
151 );
152 }
153
154 info!(exchange = %self.venue.exchange, "MockExchange shutting down");
155 }
156
157 #[must_use]
162 fn advance_venue_time(&mut self, time_request: DateTime<Utc>) -> Vec<UnindexedAccountEvent> {
163 let client_to_exchange_latency = self.latency_ms / 2;
164
165 let time_exchange = time_request
166 .checked_add_signed(TimeDelta::milliseconds(client_to_exchange_latency as i64))
167 .unwrap_or(time_request);
168
169 self.venue.advance_time(time_exchange)
170 }
171
172 fn respond_with_latency<Response>(
177 &self,
178 response_tx: oneshot::Sender<Response>,
179 response: Response,
180 ) where
181 Response: Send + 'static,
182 {
183 let exchange = self.venue.exchange;
184 let latency = std::time::Duration::from_millis(self.latency_ms);
185
186 tokio::spawn(async move {
187 tokio::time::sleep(latency).await;
188 if response_tx.send(response).is_err() {
189 error!(
190 %exchange,
191 kind = std::any::type_name::<Response>(),
192 "MockExchange failed to send oneshot response to client"
193 );
194 }
195 });
196 }
197
198 fn respond_open_with_latency(
214 &self,
215 emit_tx: &mpsc::UnboundedSender<PendingEmission>,
216 response_tx: oneshot::Sender<
217 Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
218 >,
219 outcome: OpenOutcome,
220 ) {
221 self.queue_emission(
222 emit_tx,
223 outcome.events,
224 Some(PendingOpenResponse {
225 response_tx,
226 response: outcome.response,
227 }),
228 );
229 }
230
231 fn queue_emission(
239 &self,
240 emit_tx: &mpsc::UnboundedSender<PendingEmission>,
241 events: Vec<UnindexedAccountEvent>,
242 response: Option<PendingOpenResponse>,
243 ) {
244 if events.is_empty() && response.is_none() {
245 return;
246 }
247
248 let ready_at =
252 tokio::time::Instant::now() + std::time::Duration::from_millis(self.latency_ms);
253
254 if emit_tx
257 .send(PendingEmission {
258 ready_at,
259 events,
260 response,
261 })
262 .is_err()
263 {
264 error!(
265 exchange = %self.venue.exchange,
266 "MockExchange could not queue venue output: the emitter has stopped"
267 );
268 }
269 }
270
271 async fn emit_queued_output(
278 mut emit_rx: mpsc::UnboundedReceiver<PendingEmission>,
279 event_tx: broadcast::Sender<UnindexedAccountEvent>,
280 exchange: ExchangeId,
281 ) {
282 while let Some(emission) = emit_rx.recv().await {
283 tokio::time::sleep_until(emission.ready_at).await;
284
285 for event in emission.events {
287 if event_tx.send(event).is_err() {
288 error!(
289 %exchange,
290 "MockExchange failed to send AccountEvent notification to client"
291 );
292 }
293 }
294
295 let Some(PendingOpenResponse {
297 response_tx,
298 response,
299 }) = emission.response
300 else {
301 continue;
302 };
303
304 if response_tx.send(response).is_err() {
305 error!(
306 %exchange,
307 kind = "OrderResponseOpen",
308 "MockExchange failed to send oneshot response to client"
309 );
310 }
311 }
312 }
313
314 pub fn account_stream(&self) -> BoxStream<'static, UnindexedAccountEvent> {
315 futures::StreamExt::boxed(BroadcastStream::new(self.event_tx.subscribe()).map_while(
316 |result| match result {
317 Ok(event) => Some(event),
318 Err(error) => {
319 error!(
320 ?error,
321 "MockExchange Broadcast AccountStream lagged - terminating"
322 );
323 None
324 }
325 },
326 ))
327 }
328}
329
330#[derive(Debug)]
341struct PendingEmission {
342 ready_at: tokio::time::Instant,
344 events: Vec<UnindexedAccountEvent>,
346 response: Option<PendingOpenResponse>,
349}
350
351#[derive(Debug)]
353struct PendingOpenResponse {
354 response_tx: oneshot::Sender<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
355 response: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
356}
357
358#[cfg(test)]
364#[allow(clippy::unwrap_used, clippy::expect_used)] pub(crate) mod fixtures {
366 use crate::{
367 InstrumentAccountSnapshot, UnindexedAccountSnapshot,
368 balance::{AssetBalance, Balance},
369 client::mock::MockExecutionConfig,
370 fee::FeeModelConfig,
371 fill::SimFillConfig,
372 market::MarketSnapshot,
373 order::{
374 Order, OrderEvent, OrderKey, OrderKind, TimeInForce, UnindexedOrder,
375 id::{ClientOrderId, OrderId, StrategyId},
376 request::{OrderRequestOpen, RequestOpen},
377 state::{Open, OrderState},
378 },
379 };
380 use chrono::{DateTime, Utc};
381 use fnv::FnvHashMap;
382 use rust_decimal::Decimal;
383 use rustrade_instrument::{
384 Side, Underlying,
385 asset::name::AssetNameExchange,
386 exchange::ExchangeId,
387 instrument::{
388 Instrument,
389 kind::InstrumentKind,
390 name::{InstrumentNameExchange, InstrumentNameInternal},
391 quote::InstrumentQuoteAsset,
392 },
393 };
394
395 pub(super) const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
396
397 pub(super) fn d(s: &str) -> Decimal {
398 s.parse().unwrap()
399 }
400
401 pub(super) fn base() -> AssetNameExchange {
402 AssetNameExchange::new("BTC")
403 }
404
405 pub(super) fn quote() -> AssetNameExchange {
406 AssetNameExchange::new("USDT")
407 }
408
409 pub(super) fn instrument_name() -> InstrumentNameExchange {
410 InstrumentNameExchange::new("BTCUSDT")
411 }
412
413 pub(super) fn funded(
415 asset: AssetNameExchange,
416 amount: Decimal,
417 ) -> AssetBalance<AssetNameExchange> {
418 AssetBalance {
419 asset,
420 balance: Balance::new(amount, amount),
421 time_exchange: Utc::now(),
422 }
423 }
424
425 pub(super) fn config_from_balances(
427 balances: Vec<AssetBalance<AssetNameExchange>>,
428 fee_model: FeeModelConfig,
429 ) -> MockExecutionConfig {
430 config_from_balances_with_fill(balances, fee_model, SimFillConfig::default())
431 }
432
433 pub(super) fn config_from_balances_with_fill(
434 balances: Vec<AssetBalance<AssetNameExchange>>,
435 fee_model: FeeModelConfig,
436 fill_model: SimFillConfig,
437 ) -> MockExecutionConfig {
438 MockExecutionConfig::new(
439 EXCHANGE,
440 UnindexedAccountSnapshot {
441 exchange: EXCHANGE,
442 balances,
443 instruments: vec![],
444 },
445 0, fee_model,
447 fill_model,
448 )
449 }
450
451 pub(super) fn spot_config_holding(
458 btc: &str,
459 usdt: &str,
460 order: UnindexedOrder,
461 ) -> MockExecutionConfig {
462 MockExecutionConfig::new(
463 EXCHANGE,
464 UnindexedAccountSnapshot {
465 exchange: EXCHANGE,
466 balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
467 instruments: vec![InstrumentAccountSnapshot {
468 instrument: instrument_name(),
469 orders: vec![order],
470 position: None,
471 isolated: None,
472 }],
473 },
474 0, FeeModelConfig::default(),
476 SimFillConfig::default(),
477 )
478 }
479
480 pub(super) fn seeded_gtd(
482 cid: &str,
483 price: &str,
484 opened: DateTime<Utc>,
485 expiry: DateTime<Utc>,
486 ) -> UnindexedOrder {
487 Order {
488 key: OrderKey {
489 exchange: EXCHANGE,
490 instrument: instrument_name(),
491 strategy: StrategyId::new("test"),
492 cid: ClientOrderId::new(cid),
493 },
494 side: Side::Buy,
495 price: Some(d(price)),
496 quantity: d("0.01"),
497 kind: OrderKind::Limit,
498 time_in_force: TimeInForce::GoodTillDate { expiry },
499 state: OrderState::active(Open::new(
500 OrderId::new(cid),
501 opened,
502 rust_decimal::Decimal::ZERO,
503 )),
504 }
505 }
506
507 pub(super) fn spot_config(
509 btc: &str,
510 usdt: &str,
511 fee_model: FeeModelConfig,
512 ) -> MockExecutionConfig {
513 config_from_balances(
514 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
515 fee_model,
516 )
517 }
518
519 pub(super) fn spot_config_with_fill(
520 btc: &str,
521 usdt: &str,
522 fee_model: FeeModelConfig,
523 fill_model: SimFillConfig,
524 ) -> MockExecutionConfig {
525 config_from_balances_with_fill(
526 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
527 fee_model,
528 fill_model,
529 )
530 }
531
532 pub(super) fn instruments_of(
533 instrument: Instrument<ExchangeId, AssetNameExchange>,
534 ) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
535 let mut instruments = FnvHashMap::default();
536 instruments.insert(instrument.name_exchange.clone(), instrument);
537 instruments
538 }
539
540 pub(super) fn spot_instruments()
541 -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
542 instruments_of(Instrument {
543 exchange: EXCHANGE,
544 name_internal: InstrumentNameInternal::new("btcusdt"),
545 name_exchange: instrument_name(),
546 underlying: Underlying {
547 base: base(),
548 quote: quote(),
549 },
550 quote: InstrumentQuoteAsset::UnderlyingQuote,
551 kind: InstrumentKind::Spot,
552 spec: None,
553 data_venue: None,
554 })
555 }
556
557 pub(super) fn request(
563 instrument: InstrumentNameExchange,
564 side: Side,
565 quantity: &str,
566 market: Option<MarketSnapshot>,
567 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
568 OrderEvent {
569 key: OrderKey {
570 exchange: EXCHANGE,
571 instrument,
572 strategy: StrategyId::new("test"),
573 cid: ClientOrderId::new("test-cid"),
574 },
575 state: RequestOpen {
576 side,
577 price: None, quantity: d(quantity),
579 kind: OrderKind::Market,
580 time_in_force: TimeInForce::ImmediateOrCancel,
581 position_id: None,
582 reduce_only: false,
583 market,
584 },
585 }
586 }
587
588 pub(super) fn buy_request(
589 quantity: &str,
590 market: Option<MarketSnapshot>,
591 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
592 request(instrument_name(), Side::Buy, quantity, market)
593 }
594
595 pub(super) fn sell_request(
596 quantity: &str,
597 market: Option<MarketSnapshot>,
598 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
599 request(instrument_name(), Side::Sell, quantity, market)
600 }
601
602 pub(super) fn gtc() -> TimeInForce {
604 TimeInForce::GoodUntilCancelled { post_only: false }
605 }
606
607 pub(super) fn limit_request(
612 cid: &str,
613 side: Side,
614 quantity: &str,
615 price: &str,
616 time_in_force: TimeInForce,
617 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
618 OrderEvent {
619 key: OrderKey {
620 exchange: EXCHANGE,
621 instrument: instrument_name(),
622 strategy: StrategyId::new("test"),
623 cid: ClientOrderId::new(cid),
624 },
625 state: RequestOpen {
626 side,
627 price: Some(d(price)),
628 quantity: d(quantity),
629 kind: OrderKind::Limit,
630 time_in_force,
631 position_id: None,
632 reduce_only: false,
633 market: None,
634 },
635 }
636 }
637
638 pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
640 MarketSnapshot {
641 best_bid: Some(d(best_bid)),
642 best_ask: Some(d(best_ask)),
643 last_price: None,
644 }
645 }
646
647 pub(super) fn traded(last_price: &str) -> MarketSnapshot {
649 MarketSnapshot {
650 best_bid: None,
651 best_ask: None,
652 last_price: Some(d(last_price)),
653 }
654 }
655
656 pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
658 let p = Some(d(price));
659 Some(MarketSnapshot {
660 best_bid: p,
661 best_ask: p,
662 last_price: p,
663 })
664 }
665}
666
667#[cfg(test)]
668#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
670 use super::{fixtures::*, *};
671 use crate::{
672 AccountEventKind,
673 fee::FeeModelConfig,
674 order::{OrderEvent, id::ClientOrderId, request::RequestCancel, state::OrderState},
675 };
676 use rust_decimal::Decimal;
677
678 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
694 async fn open_order_emissions_are_ordered_across_fills() {
695 const FILLS: usize = 8;
696 const DEBIT_PER_FILL: &str = "500";
697
698 let usdt_start = "10000000";
699
700 let (request_tx, request_rx) = mpsc::unbounded_channel();
701 let (event_tx, mut events) = broadcast::channel(64);
702
703 let exchange = MockExchange::new(
704 spot_config("100", usdt_start, FeeModelConfig::default()),
705 request_rx,
706 event_tx,
707 spot_instruments(),
708 );
709
710 let driver = tokio::spawn(exchange.run());
711
712 let mut responses = Vec::with_capacity(FILLS);
713 for nth in 0..FILLS {
714 let (response_tx, response_rx) = oneshot::channel();
715 let mut request = buy_request("0.01", market_prices("50000"));
716 request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
717
718 request_tx
719 .send(MockExchangeRequest::open_order(
720 Utc::now(),
721 response_tx,
722 request,
723 ))
724 .unwrap();
725 responses.push(response_rx);
726 }
727
728 drop(request_tx);
730 driver.await.unwrap();
731
732 for response in responses {
733 let response = response.await.expect("every open is answered");
734 assert!(
735 matches!(
736 response.state,
737 OrderState::Active(_) | OrderState::Inactive(_)
738 ),
739 "the account is funded and the instrument priced, so every open fills: {response:?}"
740 );
741 }
742
743 let balances = std::iter::from_fn(|| events.try_recv().ok())
744 .filter_map(|event| match event.kind {
745 AccountEventKind::BalanceSnapshot(snapshot) => Some(snapshot.0.balance.total),
746 _ => None,
747 })
748 .collect::<Vec<_>>();
749
750 let expected = (1..=FILLS)
752 .map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
753 .collect::<Vec<_>>();
754
755 assert_eq!(
756 balances, expected,
757 "balance snapshots must reach the client in the order the venue booked them"
758 );
759 }
760
761 #[tokio::test(start_paused = true)]
776 async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
777 let opened = Utc::now();
778 let expiry = opened + chrono::TimeDelta::seconds(30);
779
780 let (request_tx, request_rx) = mpsc::unbounded_channel();
781 let (event_tx, mut events) = broadcast::channel(64);
782
783 let mut config =
784 spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
785 config.latency_ms = 50;
787
788 let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
789 let driver = tokio::spawn(exchange.run());
790
791 let (fill_tx, fill_rx) = oneshot::channel();
793 request_tx
794 .send(MockExchangeRequest::open_order(
795 opened,
796 fill_tx,
797 buy_request("0.01", market_prices("50000")),
798 ))
799 .unwrap();
800
801 let (snapshot_tx, snapshot_rx) = oneshot::channel();
804 request_tx
805 .send(MockExchangeRequest::fetch_account_snapshot(
806 expiry + chrono::TimeDelta::seconds(1),
807 snapshot_tx,
808 ))
809 .unwrap();
810
811 drop(request_tx);
812 driver.await.unwrap();
813 fill_rx.await.expect("the open is answered");
814 let _ = snapshot_rx.await;
815
816 let kinds = std::iter::from_fn(|| events.try_recv().ok())
817 .map(|event| match event.kind {
818 AccountEventKind::BalanceSnapshot(_) => "balance",
819 AccountEventKind::Trade(_) => "trade",
820 AccountEventKind::OrderSnapshot(_) => "order",
821 _ => "other",
822 })
823 .collect::<Vec<_>>();
824
825 assert_eq!(
826 kinds,
827 ["balance", "trade", "order"],
828 "the fill's events must all precede the sweep, which for a seeded order — held with \
829 no reservation — owes only its terminal snapshot"
830 );
831 }
832
833 #[tokio::test]
834 async fn a_cancel_request_is_answered_rather_than_dropped() {
835 let (request_tx, request_rx) = mpsc::unbounded_channel();
836 let (event_tx, _events) = broadcast::channel(16);
837
838 let exchange = MockExchange::new(
839 spot_config("100", "10000", FeeModelConfig::default()),
840 request_rx,
841 event_tx,
842 spot_instruments(),
843 );
844 let driver = tokio::spawn(exchange.run());
845
846 let (response_tx, response_rx) = oneshot::channel();
847 let open = buy_request("1", market_prices("50000"));
848 request_tx
849 .send(MockExchangeRequest::cancel_order(
850 Utc::now(),
851 response_tx,
852 OrderEvent {
853 key: open.key,
854 state: RequestCancel { id: None },
855 },
856 ))
857 .unwrap();
858
859 let response = response_rx.await.expect("a cancel must be answered");
860 assert!(
861 response.state.is_err(),
862 "this venue rests no orders, so a cancel must be rejected: {response:?}"
863 );
864
865 drop(request_tx);
866 driver.await.unwrap();
867 }
868}