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, VenueOrderId},
376 request::{OrderRequestOpen, RequestOpen},
377 state::{Open, OrderState},
378 },
379 position::PositionReport,
380 };
381 use chrono::{DateTime, Utc};
382 use fnv::FnvHashMap;
383 use rust_decimal::Decimal;
384 use rustrade_instrument::{
385 Side, Underlying,
386 asset::name::AssetNameExchange,
387 exchange::ExchangeId,
388 instrument::{
389 Instrument,
390 kind::InstrumentKind,
391 name::{InstrumentNameExchange, InstrumentNameInternal},
392 quote::InstrumentQuoteAsset,
393 },
394 };
395
396 pub(super) const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
397
398 pub(super) fn d(s: &str) -> Decimal {
399 s.parse().unwrap()
400 }
401
402 pub(super) fn base() -> AssetNameExchange {
403 AssetNameExchange::new("BTC")
404 }
405
406 pub(super) fn quote() -> AssetNameExchange {
407 AssetNameExchange::new("USDT")
408 }
409
410 pub(super) fn instrument_name() -> InstrumentNameExchange {
411 InstrumentNameExchange::new("BTCUSDT")
412 }
413
414 pub(super) fn funded(
416 asset: AssetNameExchange,
417 amount: Decimal,
418 ) -> AssetBalance<AssetNameExchange> {
419 AssetBalance {
420 asset,
421 balance: Balance::new(amount, amount),
422 time_exchange: Utc::now(),
423 }
424 }
425
426 pub(super) fn config_from_balances(
428 balances: Vec<AssetBalance<AssetNameExchange>>,
429 fee_model: FeeModelConfig,
430 ) -> MockExecutionConfig {
431 config_from_balances_with_fill(balances, fee_model, SimFillConfig::default())
432 }
433
434 pub(super) fn config_from_balances_with_fill(
435 balances: Vec<AssetBalance<AssetNameExchange>>,
436 fee_model: FeeModelConfig,
437 fill_model: SimFillConfig,
438 ) -> MockExecutionConfig {
439 MockExecutionConfig::new(
440 EXCHANGE,
441 UnindexedAccountSnapshot {
442 exchange: EXCHANGE,
443 balances,
444 instruments: vec![],
445 },
446 0, fee_model,
448 fill_model,
449 )
450 }
451
452 pub(super) fn spot_config_holding(
459 btc: &str,
460 usdt: &str,
461 order: UnindexedOrder,
462 ) -> MockExecutionConfig {
463 MockExecutionConfig::new(
464 EXCHANGE,
465 UnindexedAccountSnapshot {
466 exchange: EXCHANGE,
467 balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
468 instruments: vec![InstrumentAccountSnapshot {
469 instrument: instrument_name(),
470 orders: vec![order],
471 orders_complete: true,
472 position: PositionReport::Unreported,
473 isolated: None,
474 }],
475 },
476 0, FeeModelConfig::default(),
478 SimFillConfig::default(),
479 )
480 }
481
482 pub(super) fn seeded_gtd(
484 cid: &str,
485 price: &str,
486 opened: DateTime<Utc>,
487 expiry: DateTime<Utc>,
488 ) -> UnindexedOrder {
489 Order {
490 key: OrderKey {
491 exchange: EXCHANGE,
492 instrument: instrument_name(),
493 strategy: StrategyId::new("test"),
494 cid: ClientOrderId::new(cid),
495 },
496 side: Side::Buy,
497 price: Some(d(price)),
498 quantity: d("0.01"),
499 kind: OrderKind::Limit,
500 time_in_force: TimeInForce::GoodTillDate { expiry },
501 state: OrderState::active(Open::new(
502 VenueOrderId::Assigned(OrderId::new(cid)),
503 opened,
504 rust_decimal::Decimal::ZERO,
505 )),
506 }
507 }
508
509 pub(super) fn spot_config(
511 btc: &str,
512 usdt: &str,
513 fee_model: FeeModelConfig,
514 ) -> MockExecutionConfig {
515 config_from_balances(
516 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
517 fee_model,
518 )
519 }
520
521 pub(super) fn spot_config_with_fill(
522 btc: &str,
523 usdt: &str,
524 fee_model: FeeModelConfig,
525 fill_model: SimFillConfig,
526 ) -> MockExecutionConfig {
527 config_from_balances_with_fill(
528 vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
529 fee_model,
530 fill_model,
531 )
532 }
533
534 pub(super) fn instruments_of(
535 instrument: Instrument<ExchangeId, AssetNameExchange>,
536 ) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
537 let mut instruments = FnvHashMap::default();
538 instruments.insert(instrument.name_exchange.clone(), instrument);
539 instruments
540 }
541
542 pub(super) fn spot_instruments()
543 -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
544 instruments_of(Instrument {
545 exchange: EXCHANGE,
546 name_internal: InstrumentNameInternal::new("btcusdt"),
547 name_exchange: instrument_name(),
548 underlying: Underlying {
549 base: base(),
550 quote: quote(),
551 },
552 quote: InstrumentQuoteAsset::UnderlyingQuote,
553 kind: InstrumentKind::Spot,
554 spec: None,
555 data_venue: None,
556 })
557 }
558
559 pub(super) fn request(
565 instrument: InstrumentNameExchange,
566 side: Side,
567 quantity: &str,
568 market: Option<MarketSnapshot>,
569 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
570 OrderEvent {
571 key: OrderKey {
572 exchange: EXCHANGE,
573 instrument,
574 strategy: StrategyId::new("test"),
575 cid: ClientOrderId::new("test-cid"),
576 },
577 state: RequestOpen {
578 side,
579 price: None, quantity: d(quantity),
581 kind: OrderKind::Market,
582 time_in_force: TimeInForce::ImmediateOrCancel,
583 position_id: None,
584 reduce_only: false,
585 market,
586 },
587 }
588 }
589
590 pub(super) fn buy_request(
591 quantity: &str,
592 market: Option<MarketSnapshot>,
593 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
594 request(instrument_name(), Side::Buy, quantity, market)
595 }
596
597 pub(super) fn sell_request(
598 quantity: &str,
599 market: Option<MarketSnapshot>,
600 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
601 request(instrument_name(), Side::Sell, quantity, market)
602 }
603
604 pub(super) fn gtc() -> TimeInForce {
606 TimeInForce::GoodUntilCancelled { post_only: false }
607 }
608
609 pub(super) fn limit_request(
614 cid: &str,
615 side: Side,
616 quantity: &str,
617 price: &str,
618 time_in_force: TimeInForce,
619 ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
620 OrderEvent {
621 key: OrderKey {
622 exchange: EXCHANGE,
623 instrument: instrument_name(),
624 strategy: StrategyId::new("test"),
625 cid: ClientOrderId::new(cid),
626 },
627 state: RequestOpen {
628 side,
629 price: Some(d(price)),
630 quantity: d(quantity),
631 kind: OrderKind::Limit,
632 time_in_force,
633 position_id: None,
634 reduce_only: false,
635 market: None,
636 },
637 }
638 }
639
640 pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
642 MarketSnapshot {
643 best_bid: Some(d(best_bid)),
644 best_ask: Some(d(best_ask)),
645 last_price: None,
646 }
647 }
648
649 pub(super) fn traded(last_price: &str) -> MarketSnapshot {
651 MarketSnapshot {
652 best_bid: None,
653 best_ask: None,
654 last_price: Some(d(last_price)),
655 }
656 }
657
658 pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
660 let p = Some(d(price));
661 Some(MarketSnapshot {
662 best_bid: p,
663 best_ask: p,
664 last_price: p,
665 })
666 }
667}
668
669#[cfg(test)]
670#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
672 use super::{fixtures::*, *};
673 use crate::{
674 AccountEventKind,
675 fee::FeeModelConfig,
676 order::{OrderEvent, id::ClientOrderId, request::RequestCancel, state::OrderState},
677 };
678 use rust_decimal::Decimal;
679
680 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
696 async fn open_order_emissions_are_ordered_across_fills() {
697 const FILLS: usize = 8;
698 const DEBIT_PER_FILL: &str = "500";
699
700 let usdt_start = "10000000";
701
702 let (request_tx, request_rx) = mpsc::unbounded_channel();
703 let (event_tx, mut events) = broadcast::channel(64);
704
705 let exchange = MockExchange::new(
706 spot_config("100", usdt_start, FeeModelConfig::default()),
707 request_rx,
708 event_tx,
709 spot_instruments(),
710 );
711
712 let driver = tokio::spawn(exchange.run());
713
714 let mut responses = Vec::with_capacity(FILLS);
715 for nth in 0..FILLS {
716 let (response_tx, response_rx) = oneshot::channel();
717 let mut request = buy_request("0.01", market_prices("50000"));
718 request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
719
720 request_tx
721 .send(MockExchangeRequest::open_order(
722 Utc::now(),
723 response_tx,
724 request,
725 ))
726 .unwrap();
727 responses.push(response_rx);
728 }
729
730 drop(request_tx);
732 driver.await.unwrap();
733
734 for response in responses {
735 let response = response.await.expect("every open is answered");
736 assert!(
737 matches!(
738 response.state,
739 OrderState::Active(_) | OrderState::Inactive(_)
740 ),
741 "the account is funded and the instrument priced, so every open fills: {response:?}"
742 );
743 }
744
745 let balances = std::iter::from_fn(|| events.try_recv().ok())
746 .filter_map(|event| match event.kind {
747 AccountEventKind::BalanceSnapshot(snapshot) => Some(snapshot.0.balance.total),
748 _ => None,
749 })
750 .collect::<Vec<_>>();
751
752 let expected = (1..=FILLS)
754 .map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
755 .collect::<Vec<_>>();
756
757 assert_eq!(
758 balances, expected,
759 "balance snapshots must reach the client in the order the venue booked them"
760 );
761 }
762
763 #[tokio::test(start_paused = true)]
778 async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
779 let opened = Utc::now();
780 let expiry = opened + chrono::TimeDelta::seconds(30);
781
782 let (request_tx, request_rx) = mpsc::unbounded_channel();
783 let (event_tx, mut events) = broadcast::channel(64);
784
785 let mut config =
786 spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
787 config.latency_ms = 50;
789
790 let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
791 let driver = tokio::spawn(exchange.run());
792
793 let (fill_tx, fill_rx) = oneshot::channel();
795 request_tx
796 .send(MockExchangeRequest::open_order(
797 opened,
798 fill_tx,
799 buy_request("0.01", market_prices("50000")),
800 ))
801 .unwrap();
802
803 let (snapshot_tx, snapshot_rx) = oneshot::channel();
806 request_tx
807 .send(MockExchangeRequest::fetch_account_snapshot(
808 expiry + chrono::TimeDelta::seconds(1),
809 snapshot_tx,
810 ))
811 .unwrap();
812
813 drop(request_tx);
814 driver.await.unwrap();
815 fill_rx.await.expect("the open is answered");
816 let _ = snapshot_rx.await;
817
818 let kinds = std::iter::from_fn(|| events.try_recv().ok())
819 .map(|event| match event.kind {
820 AccountEventKind::BalanceSnapshot(_) => "balance",
821 AccountEventKind::Trade(_) => "trade",
822 AccountEventKind::OrderSnapshot(_) => "order",
823 _ => "other",
824 })
825 .collect::<Vec<_>>();
826
827 assert_eq!(
828 kinds,
829 ["balance", "trade", "order"],
830 "the fill's events must all precede the sweep, which for a seeded order — held with \
831 no reservation — owes only its terminal snapshot"
832 );
833 }
834
835 #[tokio::test]
836 async fn a_cancel_request_is_answered_rather_than_dropped() {
837 let (request_tx, request_rx) = mpsc::unbounded_channel();
838 let (event_tx, _events) = broadcast::channel(16);
839
840 let exchange = MockExchange::new(
841 spot_config("100", "10000", FeeModelConfig::default()),
842 request_rx,
843 event_tx,
844 spot_instruments(),
845 );
846 let driver = tokio::spawn(exchange.run());
847
848 let (response_tx, response_rx) = oneshot::channel();
849 let open = buy_request("1", market_prices("50000"));
850 request_tx
851 .send(MockExchangeRequest::cancel_order(
852 Utc::now(),
853 response_tx,
854 OrderEvent {
855 key: open.key,
856 state: RequestCancel { id: None },
857 },
858 ))
859 .unwrap();
860
861 let response = response_rx.await.expect("a cancel must be answered");
862 assert!(
863 response.state.is_err(),
864 "this venue rests no orders, so a cancel must be rejected: {response:?}"
865 );
866
867 drop(request_tx);
868 driver.await.unwrap();
869 }
870}