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::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                    // The venue releases what the cancelled order held before returning, so the
122                    // restatement it owes is queued ahead of the response, exactly as an open's is.
123                    // A venue this driver builds rests only orders a configured `initial_state`
124                    // seeded, which hold no reservation, so `events` is empty in practice today —
125                    // but dropping it on the floor would be a silent loss the moment that changes.
126                    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                    // The venue books the fill against its own ledger before returning, so a
135                    // subsequent request on this loop already sees it.
136                    let outcome = self.venue.open_order(request);
137                    self.respond_open_with_latency(&emit_tx, response_tx, outcome);
138                }
139            }
140        }
141
142        // Nothing further will be queued, so close the queue and let the emitter finish draining
143        // it. A fill booked on the final request would otherwise be dropped with the task, which is
144        // precisely the truncation the drain exists to prevent.
145        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    /// Applies this driver's latency model, then advances the venue to the resulting instant.
158    ///
159    /// Returns whatever the advance retired — see [`SimulatedVenue::advance_time`]. The caller must
160    /// deliver them; dropping them would leave the client holding an order the venue no longer has.
161    #[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    /// Sends the provided `Response` via the [`oneshot::Sender`] after waiting for the latency
173    /// [`Duration`](std::time::Duration).
174    ///
175    /// Used to simulate network latency between the exchange and client.
176    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    /// Queues everything one filled open owes the client, to be emitted in venue order.
199    ///
200    /// The venue has already decided both the events and their order; this only schedules their
201    /// delivery. Putting the response last makes "the client has its response" imply "every
202    /// account event for this order has already been sent".
203    ///
204    /// # Why a queue rather than a task per fill
205    /// A balance is an **absolute snapshot**, not a delta: successive fills report `9_999_500`,
206    /// then `9_999_000`, then `9_998_500`. Applying them out of order therefore does not merely
207    /// reorder history, it yields the wrong balance.
208    ///
209    /// Each fill previously got its own [`tokio::spawn`]. Those tasks raced -- at `latency_ms: 0`
210    /// they all become runnable at once -- so snapshots reached the client in arbitrary order and
211    /// the last to *arrive* won. One queue, drained by one task, makes emission order equal
212    /// booking order by construction.
213    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    /// Queues account events, and any response they precede, for the emitter to drain in order.
232    ///
233    /// Everything this venue produces goes through here rather than to `event_tx` directly, so
234    /// that emission order equals production order across *all* of it -- fills, expiries and
235    /// cancel restatements alike. Sending any of them inline would let it overtake a batch still
236    /// waiting out its latency, and an absolute balance delivered out of order is the wrong
237    /// balance, not merely a late one.
238    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        // Stamped at production time, not at emission time, so the delay each batch waits is
249        // measured from when the venue produced *it*. `latency_ms` is fixed for the exchange, so
250        // these are non-decreasing in queue order and the drain never sleeps away a latency twice.
251        let ready_at =
252            tokio::time::Instant::now() + std::time::Duration::from_millis(self.latency_ms);
253
254        // Failure means the emitter is gone, which happens only once `run` has returned. There is
255        // no client left to notify, so there is nothing to do but say so.
256        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    /// Drains queued venue output in order, emitting each batch's account events before its
272    /// response.
273    ///
274    /// Runs until `emit_rx` closes -- which happens when [`MockExchange::run`] returns -- and then
275    /// finishes whatever is still queued, so a shutdown cannot strand a booked fill or a swept
276    /// expiry.
277    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            // Order is the venue's contract; this preserves it verbatim.
286            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            // Absent for events nothing requested, such as an order retired by its own deadline.
296            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/// One batch of venue output awaiting emission, held in booking order by [`MockExchange`]'s
331/// emitter queue.
332///
333/// # Why a response is optional
334/// Not everything this venue produces answers a request. An order retired by its own deadline is
335/// swept whenever the clock advances -- which happens on *every* request, before that request is
336/// even looked at -- so the events belong to nobody's response. They must still be delivered in
337/// the same ordered stream as everything else: a balance is an absolute restatement, so an expiry
338/// overtaking a fill still queued behind its latency would leave the client holding the wrong
339/// balance, which is the exact failure this queue exists to prevent.
340#[derive(Debug)]
341struct PendingEmission {
342    /// When this batch's latency expires, measured from the instant the venue produced it.
343    ready_at: tokio::time::Instant,
344    /// The account events the venue produced, in the order it requires.
345    events: Vec<UnindexedAccountEvent>,
346    /// The response to send once every event above has been emitted, if this batch answers a
347    /// request at all.
348    response: Option<PendingOpenResponse>,
349}
350
351/// An open response and the channel owed it.
352#[derive(Debug)]
353struct PendingOpenResponse {
354    response_tx: oneshot::Sender<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
355    response: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
356}
357
358/// Fixtures shared by this module's driver tests and [`venue`]'s state-machine tests.
359///
360/// They live in the parent rather than beside either set of tests because a private item is visible
361/// to descendant modules: `venue::tests` can reach `mock::fixtures`, whereas `mock::tests` could not
362/// reach anything declared inside `venue`.
363#[cfg(test)]
364#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
365pub(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    /// A balance with nothing reserved, which is the only shape this venue can model.
415    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    /// Zero latency, so a driver's queue is exercised without any wall-clock sleeping.
427    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, // latency_ms
447            fee_model,
448            fill_model,
449        )
450    }
451
452    /// A spot config whose account already holds one open order, as `initial_state` seeds it.
453    ///
454    /// The only way an order can rest on a venue [`MockExchange`] drives: that venue is
455    /// [`VenueRegime::RequestPriced`], which refuses `OrderKind::Limit` outright.
456    ///
457    /// [`VenueRegime::RequestPriced`]: crate::exchange::mock::venue::VenueRegime::RequestPriced
458    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, // latency_ms
477            FeeModelConfig::default(),
478            SimFillConfig::default(),
479        )
480    }
481
482    /// An open buy resting at `price`, retiring itself at `expiry`.
483    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    /// The spot fixture: `btc` of the base asset and `usdt` of the quote, both fully free.
510    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    /// A Market order carrying the snapshot the venue will price it against.
560    ///
561    /// The snapshot is set on the request rather than passed alongside it, because
562    /// [`RequestOpen::market`] is the venue's only price source and a second copy on the same path
563    /// could disagree with it.
564    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, // Market orders don't have a limit price
580                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    /// The terms a plainly resting order carries: on the book until somebody cancels it.
605    pub(super) fn gtc() -> TimeInForce {
606        TimeInForce::GoodUntilCancelled { post_only: false }
607    }
608
609    /// A Limit order at `price`, carrying its own `cid` so several can rest at once.
610    ///
611    /// `market` is deliberately `None`: a limit order is judged and priced against the venue's own
612    /// market, never against a snapshot its sender stamped.
613    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    /// Both sides of a book and no trade price, which is what an L1-only feed supplies.
641    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    /// A trade price and no book, which is what a trades-only feed supplies.
650    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    /// A snapshot whose three prices are all `price`, wrapped as the venue receives it.
659    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)] // Test code: panics on bad input are acceptable
671mod 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    /// A [`SimulatedVenue`] balance is an absolute restatement, so emission order *is* the answer.
681    ///
682    /// Each fill used to be emitted by its own spawned task. Those tasks raced, so the snapshots
683    /// `9_999_500`, `9_999_000`, `9_998_500`, ... reached the client in arbitrary order and the
684    /// last to arrive won — which is not the same thing as the last to be booked. That went
685    /// unnoticed only because the timestamp each snapshot carries is derived from a clock
686    /// contaminated with wall-clock time, and so happened to be unique and increasing, letting the
687    /// engine discard the out-of-order ones as stale. Correcting that clock removes the accident.
688    ///
689    /// Asserted on the broadcast stream rather than on any downstream balance, because the ordering
690    /// is the venue's contract to keep: the engine cannot repair snapshots it receives out of order.
691    ///
692    /// # Why this must stay multi-threaded
693    /// The race is between spawned tasks. On a `current_thread` runtime they are serialised by the
694    /// scheduler and this test passes against the unfixed code, detecting nothing.
695    #[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        // Closing the request channel ends `run`, which drains the emitter before returning.
731        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        // One balance snapshot per fill, each debiting the quote asset by the same notional.
753        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    /// A cancel is rejected by the venue, and the driver answers the channel it was asked on.
764    ///
765    /// The rejection itself is the venue's own; what this pins is that the driver
766    /// forwards it rather than leaving the caller's `oneshot` to be dropped — which is what the
767    /// previous, mistyped implementation did, since it could not produce the channel's response
768    /// type at all.
769    /// An order retired by its own deadline reaches the client, in the emitter's queue rather than
770    /// around it.
771    ///
772    /// The sweep runs synchronously at the top of `run`'s loop, while the emitter may still be
773    /// asleep waiting out an earlier batch's latency. Sending it straight to `event_tx` would let
774    /// it overtake that batch — and a balance is an absolute restatement, so a snapshot applied out
775    /// of order leaves the client holding the wrong number, not merely a late one. This asserts the
776    /// expiry lands *after* the fill booked before it.
777    #[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        // Non-zero, so the emitter is genuinely asleep when the next request sweeps.
788        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        // Booked before the deadline, so its fill is queued and still waiting out its latency.
794        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        // A second request stamped past the deadline: advancing the clock for it sweeps the
804        // seeded order while the fill above is still in the emitter's queue.
805        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}