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,
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        )
459    }
460
461    /// A spot config whose account already holds one open order, as `initial_state` seeds it.
462    ///
463    /// The only way an order can rest on a venue [`MockExchange`] drives: that venue is
464    /// [`VenueRegime::RequestPriced`], which refuses `OrderKind::Limit` outright.
465    ///
466    /// [`VenueRegime::RequestPriced`]: crate::exchange::mock::venue::VenueRegime::RequestPriced
467    pub(super) fn spot_config_holding(
468        btc: &str,
469        usdt: &str,
470        order: UnindexedOrder,
471    ) -> MockExecutionConfig {
472        MockExecutionConfig::new(
473            EXCHANGE,
474            UnindexedAccountSnapshot {
475                exchange: EXCHANGE,
476                balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
477                instruments: vec![InstrumentAccountSnapshot {
478                    instrument: instrument_name(),
479                    orders: vec![order],
480                    orders_complete: true,
481                    position: PositionReport::Unreported,
482                    isolated: None,
483                }],
484            },
485            0, // latency_ms
486            FeeModelConfig::default(),
487            SimFillConfig::default(),
488        )
489    }
490
491    /// An open buy resting at `price`, retiring itself at `expiry`.
492    pub(super) fn seeded_gtd(
493        cid: &str,
494        price: &str,
495        opened: DateTime<Utc>,
496        expiry: DateTime<Utc>,
497    ) -> UnindexedOrder {
498        Order {
499            key: OrderKey {
500                exchange: EXCHANGE,
501                instrument: instrument_name(),
502                strategy: StrategyId::new("test"),
503                cid: ClientOrderId::new(cid),
504            },
505            side: Side::Buy,
506            price: Some(d(price)),
507            quantity: d("0.01"),
508            kind: OrderKind::Limit,
509            time_in_force: TimeInForce::GoodTillDate { expiry },
510            state: OrderState::active(Open::new(
511                VenueOrderId::Assigned(OrderId::new(cid)),
512                opened,
513                rust_decimal::Decimal::ZERO,
514            )),
515        }
516    }
517
518    /// The spot fixture: `btc` of the base asset and `usdt` of the quote, both fully free.
519    pub(super) fn spot_config(
520        btc: &str,
521        usdt: &str,
522        fee_model: FeeModelConfig,
523    ) -> MockExecutionConfig {
524        config_from_balances(
525            vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
526            fee_model,
527        )
528    }
529
530    pub(super) fn spot_config_with_fill(
531        btc: &str,
532        usdt: &str,
533        fee_model: FeeModelConfig,
534        fill_model: SimFillConfig,
535    ) -> MockExecutionConfig {
536        config_from_balances_with_fill(
537            vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
538            fee_model,
539            fill_model,
540        )
541    }
542
543    pub(super) fn instruments_of(
544        instrument: Instrument<ExchangeId, AssetNameExchange>,
545    ) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
546        let mut instruments = FnvHashMap::default();
547        instruments.insert(instrument.name_exchange.clone(), instrument);
548        instruments
549    }
550
551    pub(super) fn spot_instruments()
552    -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
553        instruments_of(Instrument {
554            exchange: EXCHANGE,
555            name_internal: InstrumentNameInternal::new("btcusdt"),
556            name_exchange: instrument_name(),
557            underlying: Underlying {
558                base: base(),
559                quote: quote(),
560            },
561            quote: InstrumentQuoteAsset::UnderlyingQuote,
562            kind: InstrumentKind::Spot,
563            spec: None,
564            data_venue: None,
565        })
566    }
567
568    /// A Market order carrying the snapshot the venue will price it against.
569    ///
570    /// The snapshot is set on the request rather than passed alongside it, because
571    /// [`RequestOpen::market`] is the venue's only price source and a second copy on the same path
572    /// could disagree with it.
573    pub(super) fn request(
574        instrument: InstrumentNameExchange,
575        side: Side,
576        quantity: &str,
577        market: Option<MarketSnapshot>,
578    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
579        OrderEvent {
580            key: OrderKey {
581                exchange: EXCHANGE,
582                instrument,
583                strategy: StrategyId::new("test"),
584                cid: ClientOrderId::new("test-cid"),
585            },
586            state: RequestOpen {
587                side,
588                price: None, // Market orders don't have a limit price
589                quantity: d(quantity),
590                kind: OrderKind::Market,
591                time_in_force: TimeInForce::ImmediateOrCancel,
592                position_id: None,
593                reduce_only: false,
594                market,
595            },
596        }
597    }
598
599    pub(super) fn buy_request(
600        quantity: &str,
601        market: Option<MarketSnapshot>,
602    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
603        request(instrument_name(), Side::Buy, quantity, market)
604    }
605
606    pub(super) fn sell_request(
607        quantity: &str,
608        market: Option<MarketSnapshot>,
609    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
610        request(instrument_name(), Side::Sell, quantity, market)
611    }
612
613    /// The terms a plainly resting order carries: on the book until somebody cancels it.
614    pub(super) fn gtc() -> TimeInForce {
615        TimeInForce::GoodUntilCancelled { post_only: false }
616    }
617
618    /// A Limit order at `price`, carrying its own `cid` so several can rest at once.
619    ///
620    /// `market` is deliberately `None`: a limit order is judged and priced against the venue's own
621    /// market, never against a snapshot its sender stamped.
622    pub(super) fn limit_request(
623        cid: &str,
624        side: Side,
625        quantity: &str,
626        price: &str,
627        time_in_force: TimeInForce,
628    ) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
629        OrderEvent {
630            key: OrderKey {
631                exchange: EXCHANGE,
632                instrument: instrument_name(),
633                strategy: StrategyId::new("test"),
634                cid: ClientOrderId::new(cid),
635            },
636            state: RequestOpen {
637                side,
638                price: Some(d(price)),
639                quantity: d(quantity),
640                kind: OrderKind::Limit,
641                time_in_force,
642                position_id: None,
643                reduce_only: false,
644                market: None,
645            },
646        }
647    }
648
649    /// Both sides of a book and no trade price, which is what an L1-only feed supplies.
650    pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
651        MarketSnapshot {
652            best_bid: Some(d(best_bid)),
653            best_ask: Some(d(best_ask)),
654            last_price: None,
655        }
656    }
657
658    /// A trade price and no book, which is what a trades-only feed supplies.
659    pub(super) fn traded(last_price: &str) -> MarketSnapshot {
660        MarketSnapshot {
661            best_bid: None,
662            best_ask: None,
663            last_price: Some(d(last_price)),
664        }
665    }
666
667    /// A snapshot whose three prices are all `price`, wrapped as the venue receives it.
668    pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
669        let p = Some(d(price));
670        Some(MarketSnapshot {
671            best_bid: p,
672            best_ask: p,
673            last_price: p,
674        })
675    }
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
680mod tests {
681    use super::{fixtures::*, *};
682    use crate::{
683        AccountEventKind,
684        fee::FeeModelConfig,
685        order::{
686            OrderEvent, OrderKey,
687            id::ClientOrderId,
688            request::RequestCancel,
689            state::{InactiveOrderState, OrderState},
690        },
691    };
692    use rust_decimal::Decimal;
693
694    /// A [`SimulatedVenue`] balance is an absolute restatement, so emission order *is* the answer.
695    ///
696    /// Each fill used to be emitted by its own spawned task. Those tasks raced, so the snapshots
697    /// `9_999_500`, `9_999_000`, `9_998_500`, ... reached the client in arbitrary order and the
698    /// last to arrive won — which is not the same thing as the last to be booked. That went
699    /// unnoticed only because the timestamp each snapshot carries is derived from a clock
700    /// contaminated with wall-clock time, and so happened to be unique and increasing, letting the
701    /// engine discard the out-of-order ones as stale. Correcting that clock removes the accident.
702    ///
703    /// Asserted on the broadcast stream rather than on any downstream balance, because the ordering
704    /// is the venue's contract to keep: the engine cannot repair snapshots it receives out of order.
705    ///
706    /// # Why this must stay multi-threaded
707    /// The race is between spawned tasks. On a `current_thread` runtime they are serialised by the
708    /// scheduler and this test passes against the unfixed code, detecting nothing.
709    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
710    async fn open_order_emissions_are_ordered_across_fills() {
711        const FILLS: usize = 8;
712        const DEBIT_PER_FILL: &str = "500";
713
714        let usdt_start = "10000000";
715
716        let (request_tx, request_rx) = mpsc::unbounded_channel();
717        let (event_tx, mut events) = broadcast::channel(64);
718
719        let exchange = MockExchange::new(
720            spot_config("100", usdt_start, FeeModelConfig::default()),
721            request_rx,
722            event_tx,
723            spot_instruments(),
724        );
725
726        let driver = tokio::spawn(exchange.run());
727
728        let mut responses = Vec::with_capacity(FILLS);
729        for nth in 0..FILLS {
730            let (response_tx, response_rx) = oneshot::channel();
731            let mut request = buy_request("0.01", market_prices("50000"));
732            request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
733
734            request_tx
735                .send(MockExchangeRequest::open_order(
736                    Utc::now(),
737                    response_tx,
738                    request,
739                ))
740                .unwrap();
741            responses.push(response_rx);
742        }
743
744        // Closing the request channel ends `run`, which drains the emitter before returning.
745        drop(request_tx);
746        driver.await.unwrap();
747
748        for response in responses {
749            let response = response.await.expect("every open is answered");
750            assert!(
751                matches!(
752                    response.state,
753                    OrderState::Active(_) | OrderState::Inactive(_)
754                ),
755                "the account is funded and the instrument priced, so every open fills: {response:?}"
756            );
757        }
758
759        let balances = std::iter::from_fn(|| events.try_recv().ok())
760            .filter_map(|event| match event.kind {
761                AccountEventKind::BalanceSnapshot(snapshot) => Some(snapshot.0.balance.total),
762                _ => None,
763            })
764            .collect::<Vec<_>>();
765
766        // One balance snapshot per fill, each debiting the quote asset by the same notional.
767        let expected = (1..=FILLS)
768            .map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
769            .collect::<Vec<_>>();
770
771        assert_eq!(
772            balances, expected,
773            "balance snapshots must reach the client in the order the venue booked them"
774        );
775    }
776
777    /// A cancel is rejected by the venue, and the driver answers the channel it was asked on.
778    ///
779    /// The rejection itself is the venue's own; what this pins is that the driver
780    /// forwards it rather than leaving the caller's `oneshot` to be dropped — which is what the
781    /// previous, mistyped implementation did, since it could not produce the channel's response
782    /// type at all.
783    /// An order retired by its own deadline reaches the client, in the emitter's queue rather than
784    /// around it.
785    ///
786    /// The sweep runs synchronously at the top of `run`'s loop, while the emitter may still be
787    /// asleep waiting out an earlier batch's latency. Sending it straight to `event_tx` would let
788    /// it overtake that batch — and a balance is an absolute restatement, so a snapshot applied out
789    /// of order leaves the client holding the wrong number, not merely a late one. This asserts the
790    /// expiry lands *after* the fill booked before it.
791    #[tokio::test(start_paused = true)]
792    async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
793        let opened = Utc::now();
794        let expiry = opened + chrono::TimeDelta::seconds(30);
795
796        let (request_tx, request_rx) = mpsc::unbounded_channel();
797        let (event_tx, mut events) = broadcast::channel(64);
798
799        let mut config =
800            spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
801        // Non-zero, so the emitter is genuinely asleep when the next request sweeps.
802        config.latency_ms = 50;
803
804        let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
805        let driver = tokio::spawn(exchange.run());
806
807        // Booked before the deadline, so its fill is queued and still waiting out its latency.
808        let (fill_tx, fill_rx) = oneshot::channel();
809        request_tx
810            .send(MockExchangeRequest::open_order(
811                opened,
812                fill_tx,
813                buy_request("0.01", market_prices("50000")),
814            ))
815            .unwrap();
816
817        // A second request stamped past the deadline: advancing the clock for it sweeps the
818        // seeded order while the fill above is still in the emitter's queue.
819        let (snapshot_tx, snapshot_rx) = oneshot::channel();
820        request_tx
821            .send(MockExchangeRequest::fetch_account_snapshot(
822                expiry + chrono::TimeDelta::seconds(1),
823                snapshot_tx,
824            ))
825            .unwrap();
826
827        drop(request_tx);
828        driver.await.unwrap();
829        fill_rx.await.expect("the open is answered");
830        let _ = snapshot_rx.await;
831
832        let kinds = std::iter::from_fn(|| events.try_recv().ok())
833            .map(|event| match event.kind {
834                AccountEventKind::BalanceSnapshot(_) => "balance",
835                AccountEventKind::Trade(_) => "trade",
836                AccountEventKind::OrderSnapshot(_) => "order",
837                _ => "other",
838            })
839            .collect::<Vec<_>>();
840
841        assert_eq!(
842            kinds,
843            ["balance", "trade", "order"],
844            "the fill's events must all precede the sweep, which for a seeded order — held with \
845             no reservation — owes only its terminal snapshot"
846        );
847    }
848
849    #[tokio::test]
850    async fn a_cancel_request_is_answered_rather_than_dropped() {
851        let (request_tx, request_rx) = mpsc::unbounded_channel();
852        let (event_tx, _events) = broadcast::channel(16);
853
854        let exchange = MockExchange::new(
855            spot_config("100", "10000", FeeModelConfig::default()),
856            request_rx,
857            event_tx,
858            spot_instruments(),
859        );
860        let driver = tokio::spawn(exchange.run());
861
862        let (response_tx, response_rx) = oneshot::channel();
863        let open = buy_request("1", market_prices("50000"));
864        request_tx
865            .send(MockExchangeRequest::cancel_order(
866                Utc::now(),
867                response_tx,
868                OrderEvent {
869                    key: open.key,
870                    state: RequestCancel { id: None },
871                },
872            ))
873            .unwrap();
874
875        let response = response_rx.await.expect("a cancel must be answered");
876        assert!(
877            response.state.is_err(),
878            "this venue rests no orders, so a cancel must be rejected: {response:?}"
879        );
880
881        drop(request_tx);
882        driver.await.unwrap();
883    }
884
885    /// The mock client's order-state lookup reaches the venue's ledger and back: an order that
886    /// filled is reported, and one the venue never held is omitted.
887    #[tokio::test]
888    async fn an_order_state_lookup_is_answered_from_the_ledger() {
889        use crate::client::{OrderStatusClient, mock::MockExecution};
890
891        let (request_tx, request_rx) = mpsc::unbounded_channel();
892        let (event_tx, event_rx) = broadcast::channel(16);
893        let exchange = MockExchange::new(
894            spot_config("100", "10000000", FeeModelConfig::default()),
895            request_rx,
896            event_tx,
897            spot_instruments(),
898        );
899        let driver = tokio::spawn(exchange.run());
900
901        let open = buy_request("1", market_prices("50000"));
902        let (response_tx, response_rx) = oneshot::channel();
903        request_tx
904            .send(MockExchangeRequest::open_order(
905                Utc::now(),
906                response_tx,
907                open.clone(),
908            ))
909            .unwrap();
910        let opened = response_rx.await.expect("an open must be answered");
911        assert!(matches!(
912            opened.state,
913            OrderState::Inactive(InactiveOrderState::FullyFilled(_))
914        ));
915
916        let client = MockExecution {
917            mocked_exchange: EXCHANGE,
918            clock: Utc::now,
919            request_tx: request_tx.clone(),
920            event_rx,
921        };
922        let unknown = OrderKey {
923            cid: ClientOrderId::new("never-opened"),
924            ..open.key.clone()
925        };
926        let ended = client
927            .fetch_ended_orders(&[open.key.clone(), unknown])
928            .await
929            .expect("the venue is running");
930
931        let [order] = ended.as_slice() else {
932            panic!("only the filled order is reported: {ended:?}");
933        };
934        assert_eq!(order.key, open.key);
935        assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
936
937        drop(client);
938        drop(request_tx);
939        driver.await.unwrap();
940    }
941}