Skip to main content

rustrade_execution/exchange/mock/
mod.rs

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/// Asynchronous driver for a [`SimulatedVenue`]: channels, simulated latency, emission ordering.
28///
29/// It owns no ledger and prices nothing. What the venue models, what it rejects and what it leaves
30/// unaccounted for are documented on [`SimulatedVenue`]; this type decides only *when* the venue's
31/// output reaches a client.
32///
33/// # Ordering
34/// A filled open's account events and its response are queued together and drained by a single
35/// task, so emission order equals booking order *across* fills, not merely within one. Closing
36/// `request_rx` closes that queue in turn, and [`run`](Self::run) returns only once it has drained.
37///
38/// # ⚠️ Known limitation: fetch responses are not ordered against anything
39/// Only opens go through that queue. Every `Fetch*` response is sent from a task of its own, so two
40/// fetches issued together may be answered in either order, and a fetch issued after an open may be
41/// answered before it. A client that reads a balance through `FetchBalances` while fills are in
42/// flight can therefore observe one that is newer or older than the `BalanceSnapshot` it is about
43/// to receive. Fills themselves are unaffected, and a backtest driving this venue
44/// request-at-a-time never observes it; paper trading against a live feed can.
45#[derive(Debug)]
46pub struct MockExchange {
47    /// The venue's state machine. Ledger, pricing and ordering obligations live here.
48    pub venue: SimulatedVenue,
49    /// Simulated round-trip delay. Transport policy, so it lives on the driver, not the venue.
50    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    /// Serves requests until the request channel closes, then lets queued fills finish.
71    ///
72    /// Filled opens are not emitted inline: they are queued and drained by a single task, so that
73    /// emission order equals booking order *across* fills, not merely within one. Closing the
74    /// request channel closes that queue in turn, and this method returns only once the queue has
75    /// drained, so shutting the venue down cannot strand a fill the client is still waiting on.
76    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            // Advancing the clock can retire an order whose deadline has passed. Those events
86            // answer no request, and are queued before this request's own output so that the
87            // client sees the expiry before anything the request goes on to produce.
88            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                    // The venue releases what the cancelled order held before returning, so the
131                    // restatement it owes is queued ahead of the response, exactly as an open's is.
132                    // A venue this driver builds rests only orders a configured `initial_state`
133                    // seeded, which hold no reservation, so `events` is empty in practice today —
134                    // but dropping it on the floor would be a silent loss the moment that changes.
135                    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                    // The venue books the fill against its own ledger before returning, so a
144                    // subsequent request on this loop already sees it.
145                    let outcome = self.venue.open_order(request);
146                    self.respond_open_with_latency(&emit_tx, response_tx, outcome);
147                }
148            }
149        }
150
151        // Nothing further will be queued, so close the queue and let the emitter finish draining
152        // it. A fill booked on the final request would otherwise be dropped with the task, which is
153        // precisely the truncation the drain exists to prevent.
154        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    /// Applies this driver's latency model, then advances the venue to the resulting instant.
167    ///
168    /// Returns whatever the advance retired — see [`SimulatedVenue::advance_time`]. The caller must
169    /// deliver them; dropping them would leave the client holding an order the venue no longer has.
170    #[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    /// Sends the provided `Response` via the [`oneshot::Sender`] after waiting for the latency
182    /// [`Duration`](std::time::Duration).
183    ///
184    /// Used to simulate network latency between the exchange and client.
185    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    /// Queues everything one filled open owes the client, to be emitted in venue order.
208    ///
209    /// The venue has already decided both the events and their order; this only schedules their
210    /// delivery. Putting the response last makes "the client has its response" imply "every
211    /// account event for this order has already been sent".
212    ///
213    /// # Why a queue rather than a task per fill
214    /// A balance is an **absolute snapshot**, not a delta: successive fills report `9_999_500`,
215    /// then `9_999_000`, then `9_998_500`. Applying them out of order therefore does not merely
216    /// reorder history, it yields the wrong balance.
217    ///
218    /// Each fill previously got its own [`tokio::spawn`]. Those tasks raced -- at `latency_ms: 0`
219    /// they all become runnable at once -- so snapshots reached the client in arbitrary order and
220    /// the last to *arrive* won. One queue, drained by one task, makes emission order equal
221    /// booking order by construction.
222    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    /// Queues account events, and any response they precede, for the emitter to drain in order.
241    ///
242    /// Everything this venue produces goes through here rather than to `event_tx` directly, so
243    /// that emission order equals production order across *all* of it -- fills, expiries and
244    /// cancel restatements alike. Sending any of them inline would let it overtake a batch still
245    /// waiting out its latency, and an absolute balance delivered out of order is the wrong
246    /// balance, not merely a late one.
247    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        // Stamped at production time, not at emission time, so the delay each batch waits is
258        // measured from when the venue produced *it*. `latency_ms` is fixed for the exchange, so
259        // these are non-decreasing in queue order and the drain never sleeps away a latency twice.
260        let ready_at =
261            tokio::time::Instant::now() + std::time::Duration::from_millis(self.latency_ms);
262
263        // Failure means the emitter is gone, which happens only once `run` has returned. There is
264        // no client left to notify, so there is nothing to do but say so.
265        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    /// Drains queued venue output in order, emitting each batch's account events before its
281    /// response.
282    ///
283    /// Runs until `emit_rx` closes -- which happens when [`MockExchange::run`] returns -- and then
284    /// finishes whatever is still queued, so a shutdown cannot strand a booked fill or a swept
285    /// expiry.
286    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            // Order is the venue's contract; this preserves it verbatim.
295            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            // Absent for events nothing requested, such as an order retired by its own deadline.
305            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/// One batch of venue output awaiting emission, held in booking order by [`MockExchange`]'s
340/// emitter queue.
341///
342/// # Why a response is optional
343/// Not everything this venue produces answers a request. An order retired by its own deadline is
344/// swept whenever the clock advances -- which happens on *every* request, before that request is
345/// even looked at -- so the events belong to nobody's response. They must still be delivered in
346/// the same ordered stream as everything else: a balance is an absolute restatement, so an expiry
347/// overtaking a fill still queued behind its latency would leave the client holding the wrong
348/// balance, which is the exact failure this queue exists to prevent.
349#[derive(Debug)]
350struct PendingEmission {
351    /// When this batch's latency expires, measured from the instant the venue produced it.
352    ready_at: tokio::time::Instant,
353    /// The account events the venue produced, in the order it requires.
354    events: Vec<UnindexedAccountEvent>,
355    /// The response to send once every event above has been emitted, if this batch answers a
356    /// request at all.
357    response: Option<PendingOpenResponse>,
358}
359
360/// An open response and the channel owed it.
361#[derive(Debug)]
362struct PendingOpenResponse {
363    response_tx: oneshot::Sender<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
364    response: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
365}
366
367/// Fixtures shared by this module's driver tests and [`venue`]'s state-machine tests.
368///
369/// They live in the parent rather than beside either set of tests because a private item is visible
370/// to descendant modules: `venue::tests` can reach `mock::fixtures`, whereas `mock::tests` could not
371/// reach anything declared inside `venue`.
372#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
374pub(crate) mod fixtures {
375    use crate::{
376        InstrumentAccountSnapshot, UnindexedAccountSnapshot,
377        balance::{AssetBalance, Balance},
378        client::mock::MockExecutionConfig,
379        fee::{FeeModelConfig, SpotFeeAsset},
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    /// A balance with nothing reserved, which is the only shape this venue can model.
424    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    /// Zero latency, so a driver's queue is exercised without any wall-clock sleeping.
436    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, // latency_ms
456            fee_model,
457            fill_model,
458            SpotFeeAsset::default(),
459            crate::slippage::SlippageConfig::default(),
460            crate::fill::MarketOrderTiming::default(),
461            crate::fill::StaleMarketPolicy::default(),
462            None,
463            None,
464        )
465    }
466
467    /// A spot config whose account already holds one open order, as `initial_state` seeds it.
468    ///
469    /// The only way an order can rest on a venue [`MockExchange`] drives: that venue is
470    /// [`VenueRegime::RequestPriced`], which refuses `OrderKind::Limit` outright.
471    ///
472    /// [`VenueRegime::RequestPriced`]: crate::exchange::mock::venue::VenueRegime::RequestPriced
473    pub(super) fn spot_config_holding(
474        btc: &str,
475        usdt: &str,
476        order: UnindexedOrder,
477    ) -> MockExecutionConfig {
478        MockExecutionConfig::new(
479            EXCHANGE,
480            UnindexedAccountSnapshot {
481                exchange: EXCHANGE,
482                balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
483                instruments: vec![InstrumentAccountSnapshot {
484                    instrument: instrument_name(),
485                    orders: vec![order],
486                    orders_complete: true,
487                    position: PositionReport::Unreported,
488                    isolated: None,
489                }],
490            },
491            0, // latency_ms
492            FeeModelConfig::default(),
493            SimFillConfig::default(),
494            SpotFeeAsset::default(),
495            crate::slippage::SlippageConfig::default(),
496            crate::fill::MarketOrderTiming::default(),
497            crate::fill::StaleMarketPolicy::default(),
498            None,
499            None,
500        )
501    }
502
503    /// An open buy resting at `price`, retiring itself at `expiry`.
504    pub(super) fn seeded_gtd(
505        cid: &str,
506        price: &str,
507        opened: DateTime<Utc>,
508        expiry: DateTime<Utc>,
509    ) -> UnindexedOrder {
510        Order {
511            key: OrderKey {
512                exchange: EXCHANGE,
513                instrument: instrument_name(),
514                strategy: StrategyId::new("test"),
515                cid: ClientOrderId::new(cid),
516            },
517            side: Side::Buy,
518            price: Some(d(price)),
519            quantity: d("0.01"),
520            kind: OrderKind::Limit,
521            time_in_force: TimeInForce::GoodTillDate { expiry },
522            state: OrderState::active(Open::new(
523                VenueOrderId::Assigned(OrderId::new(cid)),
524                opened,
525                rust_decimal::Decimal::ZERO,
526            )),
527        }
528    }
529
530    /// The spot fixture: `btc` of the base asset and `usdt` of the quote, both fully free.
531    pub(super) fn spot_config(
532        btc: &str,
533        usdt: &str,
534        fee_model: FeeModelConfig,
535    ) -> MockExecutionConfig {
536        config_from_balances(
537            vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
538            fee_model,
539        )
540    }
541
542    pub(super) fn spot_config_with_fill(
543        btc: &str,
544        usdt: &str,
545        fee_model: FeeModelConfig,
546        fill_model: SimFillConfig,
547    ) -> MockExecutionConfig {
548        config_from_balances_with_fill(
549            vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
550            fee_model,
551            fill_model,
552        )
553    }
554
555    pub(super) fn instruments_of(
556        instrument: Instrument<ExchangeId, AssetNameExchange>,
557    ) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
558        let mut instruments = FnvHashMap::default();
559        instruments.insert(instrument.name_exchange.clone(), instrument);
560        instruments
561    }
562
563    pub(super) fn spot_instruments()
564    -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
565        instruments_of(Instrument {
566            exchange: EXCHANGE,
567            name_internal: InstrumentNameInternal::new("btcusdt"),
568            name_exchange: instrument_name(),
569            underlying: Underlying {
570                base: base(),
571                quote: quote(),
572            },
573            quote: InstrumentQuoteAsset::UnderlyingQuote,
574            kind: InstrumentKind::Spot,
575            spec: None,
576            data_venue: None,
577        })
578    }
579
580    /// A Market order carrying the snapshot the venue will price it against.
581    ///
582    /// The snapshot is set on the request rather than passed alongside it, because
583    /// [`RequestOpen::market`] is the venue's only price source and a second copy on the same path
584    /// could disagree with it.
585    pub(super) fn request(
586        instrument: InstrumentNameExchange,
587        side: Side,
588        quantity: &str,
589        market: Option<MarketSnapshot>,
590    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
591        OrderEvent {
592            key: OrderKey {
593                exchange: EXCHANGE,
594                instrument,
595                strategy: StrategyId::new("test"),
596                cid: ClientOrderId::new("test-cid"),
597            },
598            state: RequestOpen {
599                side,
600                price: None, // Market orders don't have a limit price
601                quantity: d(quantity),
602                kind: OrderKind::Market,
603                time_in_force: TimeInForce::ImmediateOrCancel,
604                position_id: None,
605                reduce_only: false,
606                market,
607            },
608        }
609    }
610
611    pub(super) fn buy_request(
612        quantity: &str,
613        market: Option<MarketSnapshot>,
614    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
615        request(instrument_name(), Side::Buy, quantity, market)
616    }
617
618    pub(super) fn sell_request(
619        quantity: &str,
620        market: Option<MarketSnapshot>,
621    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
622        request(instrument_name(), Side::Sell, quantity, market)
623    }
624
625    /// The terms a plainly resting order carries: on the book until somebody cancels it.
626    pub(super) fn gtc() -> TimeInForce {
627        TimeInForce::GoodUntilCancelled { post_only: false }
628    }
629
630    /// A Limit order at `price`, carrying its own `cid` so several can rest at once.
631    ///
632    /// `market` is deliberately `None`: a limit order is judged and priced against the venue's own
633    /// market, never against a snapshot its sender stamped.
634    pub(super) fn limit_request(
635        cid: &str,
636        side: Side,
637        quantity: &str,
638        price: &str,
639        time_in_force: TimeInForce,
640    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
641        OrderEvent {
642            key: OrderKey {
643                exchange: EXCHANGE,
644                instrument: instrument_name(),
645                strategy: StrategyId::new("test"),
646                cid: ClientOrderId::new(cid),
647            },
648            state: RequestOpen {
649                side,
650                price: Some(d(price)),
651                quantity: d(quantity),
652                kind: OrderKind::Limit,
653                time_in_force,
654                position_id: None,
655                reduce_only: false,
656                market: None,
657            },
658        }
659    }
660
661    /// Both sides of a book and no trade price, which is what an L1-only feed supplies.
662    pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
663        MarketSnapshot {
664            best_bid: Some(d(best_bid)),
665            best_ask: Some(d(best_ask)),
666            last_price: None,
667        }
668    }
669
670    /// A trade price and no book, which is what a trades-only feed supplies.
671    pub(super) fn traded(last_price: &str) -> MarketSnapshot {
672        MarketSnapshot {
673            best_bid: None,
674            best_ask: None,
675            last_price: Some(d(last_price)),
676        }
677    }
678
679    /// A snapshot whose three prices are all `price`, wrapped as the venue receives it.
680    pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
681        let p = Some(d(price));
682        Some(MarketSnapshot {
683            best_bid: p,
684            best_ask: p,
685            last_price: p,
686        })
687    }
688}
689
690#[cfg(test)]
691#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
692mod tests {
693    use super::{fixtures::*, *};
694    use crate::{
695        AccountEventKind,
696        fee::FeeModelConfig,
697        order::{
698            OrderEvent, OrderKey,
699            id::ClientOrderId,
700            request::RequestCancel,
701            state::{InactiveOrderState, OrderState},
702        },
703    };
704    use rust_decimal::Decimal;
705
706    /// A [`SimulatedVenue`] balance is an absolute restatement, so emission order *is* the answer.
707    ///
708    /// Each fill used to be emitted by its own spawned task. Those tasks raced, so the snapshots
709    /// `9_999_500`, `9_999_000`, `9_998_500`, ... reached the client in arbitrary order and the
710    /// last to arrive won — which is not the same thing as the last to be booked. That went
711    /// unnoticed only because the timestamp each snapshot carries is derived from a clock
712    /// contaminated with wall-clock time, and so happened to be unique and increasing, letting the
713    /// engine discard the out-of-order ones as stale. Correcting that clock removes the accident.
714    ///
715    /// Asserted on the broadcast stream rather than on any downstream balance, because the ordering
716    /// is the venue's contract to keep: the engine cannot repair snapshots it receives out of order.
717    ///
718    /// # Why this must stay multi-threaded
719    /// The race is between spawned tasks. On a `current_thread` runtime they are serialised by the
720    /// scheduler and this test passes against the unfixed code, detecting nothing.
721    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
722    async fn open_order_emissions_are_ordered_across_fills() {
723        const FILLS: usize = 8;
724        const DEBIT_PER_FILL: &str = "500";
725
726        let usdt_start = "10000000";
727
728        let (request_tx, request_rx) = mpsc::unbounded_channel();
729        let (event_tx, mut events) = broadcast::channel(64);
730
731        let exchange = MockExchange::new(
732            spot_config("100", usdt_start, FeeModelConfig::default()),
733            request_rx,
734            event_tx,
735            spot_instruments(),
736        );
737
738        let driver = tokio::spawn(exchange.run());
739
740        let mut responses = Vec::with_capacity(FILLS);
741        for nth in 0..FILLS {
742            let (response_tx, response_rx) = oneshot::channel();
743            let mut request = buy_request("0.01", market_prices("50000"));
744            request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
745
746            request_tx
747                .send(MockExchangeRequest::open_order(
748                    Utc::now(),
749                    response_tx,
750                    request,
751                ))
752                .unwrap();
753            responses.push(response_rx);
754        }
755
756        // Closing the request channel ends `run`, which drains the emitter before returning.
757        drop(request_tx);
758        driver.await.unwrap();
759
760        for response in responses {
761            let response = response.await.expect("every open is answered");
762            assert!(
763                matches!(
764                    response.state,
765                    OrderState::Active(_) | OrderState::Inactive(_)
766                ),
767                "the account is funded and the instrument priced, so every open fills: {response:?}"
768            );
769        }
770
771        // Each fill also restates the base it received; this test pins the quote it paid with.
772        let balances = std::iter::from_fn(|| events.try_recv().ok())
773            .filter_map(|event| match event.kind {
774                AccountEventKind::BalanceSnapshot(snapshot) if snapshot.0.asset == quote() => {
775                    Some(snapshot.0.balance.total)
776                }
777                _ => None,
778            })
779            .collect::<Vec<_>>();
780
781        // One quote snapshot per fill, each debiting the quote asset by the same notional.
782        let expected = (1..=FILLS)
783            .map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
784            .collect::<Vec<_>>();
785
786        assert_eq!(
787            balances, expected,
788            "balance snapshots must reach the client in the order the venue booked them"
789        );
790    }
791
792    /// The async driver builds its venue from the configuration, slippage included.
793    #[tokio::test]
794    async fn the_driver_fills_at_the_configured_slippage() {
795        let (request_tx, request_rx) = mpsc::unbounded_channel();
796        let (event_tx, mut events) = broadcast::channel(16);
797        let config = MockExecutionConfig {
798            slippage: crate::slippage::SlippageConfig::fixed(d("0.01")).unwrap(),
799            ..spot_config("100", "10000000", FeeModelConfig::default())
800        };
801        let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
802        let driver = tokio::spawn(exchange.run());
803
804        let (response_tx, response_rx) = oneshot::channel();
805        request_tx
806            .send(MockExchangeRequest::open_order(
807                Utc::now(),
808                response_tx,
809                buy_request("0.01", market_prices("50000")),
810            ))
811            .unwrap();
812        response_rx.await.expect("the open is answered");
813        drop(request_tx);
814        driver.await.unwrap();
815
816        let prices = std::iter::from_fn(|| events.try_recv().ok())
817            .filter_map(|event| match event.kind {
818                AccountEventKind::Trade(trade) => Some(trade.price),
819                _ => None,
820            })
821            .collect::<Vec<_>>();
822        assert_eq!(prices, vec![d("50500")], "50,000 bought 1% above");
823    }
824
825    /// A cancel is rejected by the venue, and the driver answers the channel it was asked on.
826    ///
827    /// The rejection itself is the venue's own; what this pins is that the driver
828    /// forwards it rather than leaving the caller's `oneshot` to be dropped — which is what the
829    /// previous, mistyped implementation did, since it could not produce the channel's response
830    /// type at all.
831    /// An order retired by its own deadline reaches the client, in the emitter's queue rather than
832    /// around it.
833    ///
834    /// The sweep runs synchronously at the top of `run`'s loop, while the emitter may still be
835    /// asleep waiting out an earlier batch's latency. Sending it straight to `event_tx` would let
836    /// it overtake that batch — and a balance is an absolute restatement, so a snapshot applied out
837    /// of order leaves the client holding the wrong number, not merely a late one. This asserts the
838    /// expiry lands *after* the fill booked before it.
839    #[tokio::test(start_paused = true)]
840    async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
841        let opened = Utc::now();
842        let expiry = opened + chrono::TimeDelta::seconds(30);
843
844        let (request_tx, request_rx) = mpsc::unbounded_channel();
845        let (event_tx, mut events) = broadcast::channel(64);
846
847        let mut config =
848            spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
849        // Non-zero, so the emitter is genuinely asleep when the next request sweeps.
850        config.latency_ms = 50;
851
852        let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
853        let driver = tokio::spawn(exchange.run());
854
855        // Booked before the deadline, so its fill is queued and still waiting out its latency.
856        let (fill_tx, fill_rx) = oneshot::channel();
857        request_tx
858            .send(MockExchangeRequest::open_order(
859                opened,
860                fill_tx,
861                buy_request("0.01", market_prices("50000")),
862            ))
863            .unwrap();
864
865        // A second request stamped past the deadline: advancing the clock for it sweeps the
866        // seeded order while the fill above is still in the emitter's queue.
867        let (snapshot_tx, snapshot_rx) = oneshot::channel();
868        request_tx
869            .send(MockExchangeRequest::fetch_account_snapshot(
870                expiry + chrono::TimeDelta::seconds(1),
871                snapshot_tx,
872            ))
873            .unwrap();
874
875        drop(request_tx);
876        driver.await.unwrap();
877        fill_rx.await.expect("the open is answered");
878        let _ = snapshot_rx.await;
879
880        let kinds = std::iter::from_fn(|| events.try_recv().ok())
881            .map(|event| match event.kind {
882                AccountEventKind::BalanceSnapshot(_) => "balance",
883                AccountEventKind::Trade(_) => "trade",
884                AccountEventKind::OrderSnapshot(_) => "order",
885                _ => "other",
886            })
887            .collect::<Vec<_>>();
888
889        assert_eq!(
890            kinds,
891            ["balance", "balance", "trade", "order"],
892            "the fill's events must all precede the sweep, which for a seeded order — held with \
893             no reservation — owes only its terminal snapshot"
894        );
895    }
896
897    #[tokio::test]
898    async fn a_cancel_request_is_answered_rather_than_dropped() {
899        let (request_tx, request_rx) = mpsc::unbounded_channel();
900        let (event_tx, _events) = broadcast::channel(16);
901
902        let exchange = MockExchange::new(
903            spot_config("100", "10000", FeeModelConfig::default()),
904            request_rx,
905            event_tx,
906            spot_instruments(),
907        );
908        let driver = tokio::spawn(exchange.run());
909
910        let (response_tx, response_rx) = oneshot::channel();
911        let open = buy_request("1", market_prices("50000"));
912        request_tx
913            .send(MockExchangeRequest::cancel_order(
914                Utc::now(),
915                response_tx,
916                OrderEvent {
917                    key: open.key,
918                    state: RequestCancel { id: None },
919                },
920            ))
921            .unwrap();
922
923        let response = response_rx.await.expect("a cancel must be answered");
924        assert!(
925            response.state.is_err(),
926            "this venue rests no orders, so a cancel must be rejected: {response:?}"
927        );
928
929        drop(request_tx);
930        driver.await.unwrap();
931    }
932
933    /// The mock client's order-state lookup reaches the venue's ledger and back: an order that
934    /// filled is reported, and one the venue never held is omitted.
935    #[tokio::test]
936    async fn an_order_state_lookup_is_answered_from_the_ledger() {
937        use crate::client::{OrderStatusClient, mock::MockExecution};
938
939        let (request_tx, request_rx) = mpsc::unbounded_channel();
940        let (event_tx, event_rx) = broadcast::channel(16);
941        let exchange = MockExchange::new(
942            spot_config("100", "10000000", FeeModelConfig::default()),
943            request_rx,
944            event_tx,
945            spot_instruments(),
946        );
947        let driver = tokio::spawn(exchange.run());
948
949        let open = buy_request("1", market_prices("50000"));
950        let (response_tx, response_rx) = oneshot::channel();
951        request_tx
952            .send(MockExchangeRequest::open_order(
953                Utc::now(),
954                response_tx,
955                open.clone(),
956            ))
957            .unwrap();
958        let opened = response_rx.await.expect("an open must be answered");
959        assert!(matches!(
960            opened.state,
961            OrderState::Inactive(InactiveOrderState::FullyFilled(_))
962        ));
963
964        let client = MockExecution {
965            mocked_exchange: EXCHANGE,
966            clock: Utc::now,
967            request_tx: request_tx.clone(),
968            event_rx,
969        };
970        let unknown = OrderKey {
971            cid: ClientOrderId::new("never-opened"),
972            ..open.key.clone()
973        };
974        let ended = client
975            .fetch_ended_orders(&[open.key.clone(), unknown])
976            .await
977            .expect("the venue is running");
978
979        let [order] = ended.as_slice() else {
980            panic!("only the filled order is reported: {ended:?}");
981        };
982        assert_eq!(order.key, open.key);
983        assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
984
985        drop(client);
986        drop(request_tx);
987        driver.await.unwrap();
988    }
989}