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