Skip to main content

rustrade_execution/client/mock/
mod.rs

1use crate::{
2    AccountEventKind, UnindexedAccountEvent, UnindexedAccountSnapshot,
3    balance::AssetBalance,
4    borrow_capacity::BorrowCapacity,
5    client::{BorrowCapacityClient, ExecutionClient, OrderStatusClient},
6    error::{
7        ClientError, ConnectivityError, NotPlacedReason, OrderError, StreamTerminationReason,
8        UnindexedClientError, UnindexedOrderError,
9    },
10    exchange::mock::request::MockExchangeRequest,
11    expiry::LongOptionExpiry,
12    fee::{FeeAssetConfig, FeeModelConfig, SpotFeeAsset},
13    fill::{MarketOrderTiming, SimFillConfig, StaleMarketPolicy},
14    margin::SpotMarginConfig,
15    order::{
16        Order, OrderKey, UnindexedInactiveOrder, UnindexedOrderKey,
17        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
18        state::{Open, OrderState, UnindexedOrderState},
19    },
20    short_option::ShortPutCollateral,
21    slippage::SlippageConfig,
22    trade::TradesRead,
23};
24use chrono::{DateTime, Utc};
25use derive_more::Constructor;
26use futures::{StreamExt, stream::BoxStream};
27use rustrade_instrument::{
28    Side,
29    asset::name::AssetNameExchange,
30    exchange::ExchangeId,
31    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
32};
33use serde::{Deserialize, Serialize};
34use tokio::sync::{broadcast, mpsc, oneshot};
35use tokio_stream::wrappers::BroadcastStream;
36use tracing::error;
37
38#[derive(
39    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
40)]
41pub struct MockExecutionConfig {
42    pub mocked_exchange: ExchangeId,
43    pub initial_state: UnindexedAccountSnapshot,
44    pub latency_ms: u64,
45    /// Fee model used by the mock exchange to compute trading fees.
46    ///
47    /// Defaults to [`FeeModelConfig::Zero`]. Use [`FeeModelConfig::Percentage`]
48    /// for spot/futures simulation (e.g. 0.1% taker fee).
49    #[serde(default)]
50    pub fee_model: FeeModelConfig,
51    /// Fill model used by the mock exchange to compute execution prices.
52    ///
53    /// Defaults to [`SimFillConfig::LastPrice`], which fills at the snapshot's `last_price`,
54    /// falling back to the side of the book a taker would cross to. Switch to
55    /// [`SimFillConfig::BidAsk`] or [`SimFillConfig::Midpoint`] for more realistic spread-cost
56    /// simulation when market prices are injected alongside orders.
57    #[serde(default)]
58    pub fill_model: SimFillConfig,
59    /// Which asset the mock exchange charges a spot fill's fee in.
60    ///
61    /// Defaults to [`SpotFeeAsset::Quote`], on both sides. [`SpotFeeAsset::Received`] takes a
62    /// buy's fee out of the base it receives, as Binance spot does by default.
63    #[serde(default)]
64    pub spot_fee_asset: SpotFeeAsset,
65    /// How far the mock exchange moves each taker fill against the order, after the
66    /// [`fill_model`](Self::fill_model) has priced it.
67    ///
68    /// Defaults to [`SlippageConfig::none`]. A seeded [`SlippageConfig::uniform`] draws a fresh
69    /// rate for each fill, reproducibly from its seed. See [`SlippageConfig`] for what it applies
70    /// to and how.
71    #[serde(default)]
72    pub slippage: SlippageConfig,
73    /// When the mock exchange fills a market order.
74    ///
75    /// Defaults to [`MarketOrderTiming::OnArrival`]: against the market the venue holds when the
76    /// order arrives, which on bar data is the close of the bar the sender has just seen.
77    /// [`MarketOrderTiming::NextBar`] holds it for the instrument's next bar instead, and needs a
78    /// driver that feeds the venue bars, as `SimRunner` does on a candle feed.
79    #[serde(default)]
80    pub market_order_timing: MarketOrderTiming,
81    /// What the mock exchange does with an order when the instrument's market is stale.
82    ///
83    /// Defaults to [`StaleMarketPolicy::Off`]: it fills against the market it holds, however old.
84    /// [`StaleMarketPolicy::Wait`] makes an order on an instrument with no fresh observation within
85    /// an age wait for the next one, and needs a driver that feeds the venue a market, as
86    /// `SimRunner` does.
87    #[serde(default)]
88    pub stale_market: StaleMarketPolicy,
89    /// Whether the mock exchange lends a spot order what the account lacks, as a margin account
90    /// does.
91    ///
92    /// Defaults to `None`: a cash account, whose spot orders are paid for with what it holds, and
93    /// whose balances report no loans. A [`SpotMarginConfig`] makes it a cross margin account that
94    /// borrows under each order's [`MarginEffect`](crate::margin::MarginEffect), up to a
95    /// leverage limit.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub spot_margin: Option<SpotMarginConfig>,
98    /// A third asset the mock exchange charges spot fees in, at a discount, while the account
99    /// holds enough of it, as Binance's "pay fees with BNB" does.
100    ///
101    /// Defaults to `None`: every fee is charged under [`spot_fee_asset`](Self::spot_fee_asset).
102    /// See [`FeeAssetConfig`] for what a fill pays and when it falls back to that rule.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub fee_asset: Option<FeeAssetConfig>,
105    /// How the venue settles a long option it holds at expiry: exercised when in the money, the
106    /// default, or lapsed. See [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue)'s
107    /// `# How an option is accounted`.
108    ///
109    /// A request-priced venue ([`SimulatedVenue::new`](crate::exchange::mock::SimulatedVenue::new),
110    /// and so [`MockExchange::new`](crate::exchange::mock::MockExchange::new)) has no market feed
111    /// to price an underlying at expiry, so it takes an option buy only under
112    /// [`LongOptionExpiry::Lapse`].
113    #[serde(default)]
114    pub long_option_expiry: LongOptionExpiry,
115    /// How much quote a short put holds on the venue: the full strike, the default, or the
116    /// strike less the premium the sale receives. See [`ShortPutCollateral`].
117    #[serde(default)]
118    pub short_put_collateral: ShortPutCollateral,
119}
120
121#[derive(Debug, Constructor)]
122pub struct MockExecutionClientConfig<FnTime> {
123    pub mocked_exchange: ExchangeId,
124    pub clock: FnTime,
125    pub request_tx: mpsc::UnboundedSender<MockExchangeRequest>,
126    pub event_rx: broadcast::Receiver<UnindexedAccountEvent>,
127}
128
129impl<FnTime> Clone for MockExecutionClientConfig<FnTime>
130where
131    FnTime: Clone,
132{
133    fn clone(&self) -> Self {
134        Self {
135            mocked_exchange: self.mocked_exchange,
136            clock: self.clock.clone(),
137            request_tx: self.request_tx.clone(),
138            event_rx: self.event_rx.resubscribe(),
139        }
140    }
141}
142
143#[derive(Debug, Constructor)]
144pub struct MockExecution<FnTime> {
145    pub mocked_exchange: ExchangeId,
146    pub clock: FnTime,
147    pub request_tx: mpsc::UnboundedSender<MockExchangeRequest>,
148    pub event_rx: broadcast::Receiver<UnindexedAccountEvent>,
149}
150
151impl<FnTime> Clone for MockExecution<FnTime>
152where
153    FnTime: Clone,
154{
155    fn clone(&self) -> Self {
156        Self {
157            mocked_exchange: self.mocked_exchange,
158            clock: self.clock.clone(),
159            request_tx: self.request_tx.clone(),
160            event_rx: self.event_rx.resubscribe(),
161        }
162    }
163}
164
165impl<FnTime> MockExecution<FnTime>
166where
167    FnTime: Fn() -> DateTime<Utc>,
168{
169    pub fn time_request(&self) -> DateTime<Utc> {
170        (self.clock)()
171    }
172}
173
174impl<FnTime> ExecutionClient for MockExecution<FnTime>
175where
176    FnTime: Fn() -> DateTime<Utc> + Clone + Send + Sync,
177{
178    const EXCHANGE: ExchangeId = ExchangeId::Mock;
179
180    // `MockExchange` models no funding schedule or margin for a future, so `Perpetual` and `Future`
181    // have no faithful projection onto it. `Spot` and `Cfd` are positions the mock can open, close
182    // and mark without simulating a lifecycle, and an `Option` is one it settles itself at expiry.
183    // Kept in step with the projection in `generate_mock_exchange_instruments`.
184    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
185        InstrumentKindDiscriminant::Spot,
186        InstrumentKindDiscriminant::Cfd,
187        InstrumentKindDiscriminant::Option,
188    ];
189    type Config = MockExecutionClientConfig<FnTime>;
190    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
191
192    fn new(config: Self::Config) -> Self {
193        Self {
194            mocked_exchange: config.mocked_exchange,
195            clock: config.clock,
196            request_tx: config.request_tx,
197            event_rx: config.event_rx,
198        }
199    }
200
201    async fn account_snapshot(
202        &self,
203        _: &[AssetNameExchange],
204        _: &[InstrumentNameExchange],
205    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
206        let (response_tx, response_rx) = oneshot::channel();
207
208        self.request_tx
209            .send(MockExchangeRequest::fetch_account_snapshot(
210                self.time_request(),
211                response_tx,
212            ))
213            .map_err(|_| {
214                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
215                    self.mocked_exchange,
216                ))
217            })?;
218
219        response_rx.await.map_err(|_| {
220            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
221                self.mocked_exchange,
222            ))
223        })
224    }
225
226    async fn account_stream(
227        &self,
228        _: &[AssetNameExchange],
229        _: &[InstrumentNameExchange],
230    ) -> Result<Self::AccountStream, UnindexedClientError> {
231        // A lag is delivered in-band as one StreamTerminated, where `map_while` would end the
232        // stream with a silent EOF. Whatever its source, a StreamTerminated is terminal by
233        // contract, so the stream ends on the poll after it: a lag, or one the venue sends itself,
234        // as a market-driven venue does when its market feed lags. It ends there rather than on the
235        // next broadcast item, which a quiet venue may never send.
236        let exchange = self.mocked_exchange;
237        let events = BroadcastStream::new(self.event_rx.resubscribe()).map(move |result| {
238            result.unwrap_or_else(|error| {
239                error!(
240                    ?error,
241                    "MockExchange Broadcast AccountStream lagged - terminating"
242                );
243                UnindexedAccountEvent::stream_terminated(
244                    exchange,
245                    StreamTerminationReason::Error("mock broadcast stream lagged".to_string()),
246                )
247            })
248        });
249        Ok(
250            futures::stream::unfold((events, false), |(mut events, ended)| async move {
251                if ended {
252                    return None;
253                }
254                let event = events.next().await?;
255                let ended = matches!(event.kind, AccountEventKind::StreamTerminated(_));
256                Some((event, (events, ended)))
257            })
258            // `unfold` panics when polled again after it ends, which a combinator downstream may do.
259            .fuse()
260            .boxed(),
261        )
262    }
263
264    async fn cancel_order(
265        &self,
266        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
267    ) -> UnindexedOrderResponseCancel {
268        let (response_tx, response_rx) = oneshot::channel();
269
270        let key = OrderKey {
271            exchange: request.key.exchange,
272            instrument: request.key.instrument.clone(),
273            strategy: request.key.strategy.clone(),
274            cid: request.key.cid.clone(),
275        };
276
277        if self
278            .request_tx
279            .send(MockExchangeRequest::cancel_order(
280                self.time_request(),
281                response_tx,
282                request.into_owned_instrument(),
283            ))
284            .is_err()
285        {
286            return UnindexedOrderResponseCancel {
287                key,
288                state: Err(UnindexedOrderError::Connectivity(
289                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
290                )),
291            };
292        }
293
294        match response_rx.await {
295            Ok(response) => response,
296            Err(_) => UnindexedOrderResponseCancel {
297                key,
298                state: Err(UnindexedOrderError::Connectivity(
299                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
300                )),
301            },
302        }
303    }
304
305    async fn open_order(
306        &self,
307        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
308    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
309        let (response_tx, response_rx) = oneshot::channel();
310
311        let request = request.into_owned_instrument();
312
313        if self
314            .request_tx
315            .send(MockExchangeRequest::open_order(
316                self.time_request(),
317                response_tx,
318                // Carries the snapshot the engine sampled when it decided to send this order on
319                // `RequestOpen::market`. It is the venue's only price source for a Market order,
320                // which carries no limit price of its own. `None` there means nothing sampled
321                // one, and the venue rejects rather than guessing.
322                request.clone(),
323            ))
324            .is_err()
325        {
326            // The venue's channel is closed, so the request never reached it.
327            return Order {
328                key: request.key,
329                side: request.state.side,
330                price: request.state.price,
331                quantity: request.state.quantity,
332                kind: request.state.kind,
333                time_in_force: request.state.time_in_force,
334                state: OrderState::inactive(OrderError::NotPlaced(NotPlacedReason::NotSent(
335                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
336                ))),
337            };
338        }
339
340        match response_rx.await {
341            Ok(response) => response,
342            Err(_) => Order {
343                key: request.key,
344                side: request.state.side,
345                price: request.state.price,
346                quantity: request.state.quantity,
347                kind: request.state.kind,
348                time_in_force: request.state.time_in_force,
349                state: OrderState::inactive(OrderError::Connectivity(
350                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
351                )),
352            },
353        }
354    }
355
356    async fn fetch_balances(
357        &self,
358        assets: &[AssetNameExchange],
359    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
360        let (response_tx, response_rx) = oneshot::channel();
361
362        self.request_tx
363            .send(MockExchangeRequest::fetch_balances(
364                self.time_request(),
365                assets.to_vec(),
366                response_tx,
367            ))
368            .map_err(|_| {
369                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
370                    self.mocked_exchange,
371                ))
372            })?;
373
374        response_rx.await.map_err(|_| {
375            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
376                self.mocked_exchange,
377            ))
378        })
379    }
380
381    async fn fetch_open_orders(
382        &self,
383        instruments: &[InstrumentNameExchange],
384    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
385        let (response_tx, response_rx) = oneshot::channel();
386
387        self.request_tx
388            .send(MockExchangeRequest::fetch_orders_open(
389                self.time_request(),
390                instruments.to_vec(),
391                response_tx,
392            ))
393            .map_err(|_| {
394                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
395                    self.mocked_exchange,
396                ))
397            })?;
398
399        response_rx.await.map_err(|_| {
400            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
401                self.mocked_exchange,
402            ))
403        })
404    }
405
406    /// The mock venue holds every trade and answers a span whole, so the read is always
407    /// complete.
408    async fn fetch_trades(
409        &self,
410        start: DateTime<Utc>,
411        end: DateTime<Utc>,
412        instruments: &[InstrumentNameExchange],
413    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
414        let (response_tx, response_rx) = oneshot::channel();
415
416        self.request_tx
417            .send(MockExchangeRequest::fetch_trades(
418                self.time_request(),
419                response_tx,
420                start,
421                end,
422            ))
423            .map_err(|_| {
424                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
425                    self.mocked_exchange,
426                ))
427            })?;
428
429        let mut trades = response_rx.await.map_err(|_| {
430            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
431                self.mocked_exchange,
432            ))
433        })?;
434        if !instruments.is_empty() {
435            trades.retain(|trade| instruments.contains(&trade.instrument));
436        }
437        Ok(TradesRead::complete(trades))
438    }
439}
440
441/// Answered from the venue's own ledger: an order it filled, cancelled or retired at its deadline
442/// is reported, and one still open or never held is omitted. The venue rejects an order only on
443/// arrival, in the response to its open request, so it never reports `OpenFailed`.
444impl<FnTime> OrderStatusClient for MockExecution<FnTime>
445where
446    FnTime: Fn() -> DateTime<Utc> + Clone + Send + Sync,
447{
448    async fn fetch_ended_orders(
449        &self,
450        orders: &[UnindexedOrderKey],
451    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
452        let (response_tx, response_rx) = oneshot::channel();
453
454        self.request_tx
455            .send(MockExchangeRequest::fetch_orders_ended(
456                self.time_request(),
457                orders.to_vec(),
458                response_tx,
459            ))
460            .map_err(|_| {
461                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
462                    self.mocked_exchange,
463                ))
464            })?;
465
466        response_rx.await.map_err(|_| {
467            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
468                self.mocked_exchange,
469            ))
470        })
471    }
472}
473
474/// Answered by the simulated venue from its own ledger and the markets it holds: see
475/// [`SimulatedVenue::borrow_capacity`](crate::exchange::mock::venue::SimulatedVenue::borrow_capacity)
476/// for how it values the account, and when it answers `None` or an error. A venue without a
477/// [`SpotMarginConfig`] lends against no instrument, so answers
478/// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for every one.
479impl<FnTime> BorrowCapacityClient for MockExecution<FnTime>
480where
481    FnTime: Fn() -> DateTime<Utc> + Clone + Send + Sync,
482{
483    async fn fetch_borrow_capacity(
484        &self,
485        instrument: &InstrumentNameExchange,
486        side: Side,
487    ) -> Result<BorrowCapacity, UnindexedClientError> {
488        request_borrow_capacity(
489            &self.request_tx,
490            self.mocked_exchange,
491            self.time_request(),
492            instrument,
493            side,
494        )
495        .await
496    }
497}
498
499/// Asks the [`MockExchange`](crate::exchange::mock::MockExchange) that reads `request_tx` how much
500/// the account can borrow now for an order on `instrument` on `side`, with the request stamped
501/// `time_request`.
502///
503/// What [`MockExecution`]'s [`BorrowCapacityClient`] implementation sends, for a caller that holds
504/// the exchange's request channel without a client, such as a handle to a paper venue. The answer
505/// and its errors are the same.
506///
507/// # Errors
508/// - [`ConnectivityError::ExchangeOffline`] for `mocked_exchange` when the exchange is not running:
509///   it never started, or it has stopped.
510/// - [`ClientError::Api`] with the venue's own error, as
511///   [`SimulatedVenue::borrow_capacity`](crate::exchange::mock::venue::SimulatedVenue::borrow_capacity)
512///   says.
513pub async fn request_borrow_capacity(
514    request_tx: &mpsc::UnboundedSender<MockExchangeRequest>,
515    mocked_exchange: ExchangeId,
516    time_request: DateTime<Utc>,
517    instrument: &InstrumentNameExchange,
518    side: Side,
519) -> Result<BorrowCapacity, UnindexedClientError> {
520    let offline =
521        || UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(mocked_exchange));
522    let (response_tx, response_rx) = oneshot::channel();
523
524    request_tx
525        .send(MockExchangeRequest::fetch_borrow_capacity(
526            time_request,
527            instrument.clone(),
528            side,
529            response_tx,
530        ))
531        .map_err(|_| offline())?;
532
533    response_rx
534        .await
535        .map_err(|_| offline())?
536        .map_err(ClientError::Api)
537}
538
539/// Asks the [`MockExchange`](crate::exchange::mock::MockExchange) that reads `request_tx` to send a
540/// [`VenueCheckpoint`](crate::exchange::mock::venue::VenueCheckpoint) on its account stream, with
541/// the request stamped `time_request`.
542///
543/// Answers with the checkpoint's id once the exchange has sent it, after every account event it
544/// produced before it. Sent is not received: a consumer whose account stream ended or lagged
545/// before the checkpoint reached it never sees it, and should ask again.
546///
547/// The exchange takes the checkpoint once the request reaches it, whether or not this call is
548/// still awaited, so a dropped call can still put a checkpoint on the account stream.
549///
550/// # Errors
551/// [`ConnectivityError::ExchangeOffline`] for `mocked_exchange` when the exchange is not running:
552/// it never started, or it has stopped.
553pub async fn request_checkpoint(
554    request_tx: &mpsc::UnboundedSender<MockExchangeRequest>,
555    mocked_exchange: ExchangeId,
556    time_request: DateTime<Utc>,
557) -> Result<u64, UnindexedClientError> {
558    let offline =
559        || UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(mocked_exchange));
560    let (response_tx, response_rx) = oneshot::channel();
561    request_tx
562        .send(MockExchangeRequest::checkpoint(time_request, response_tx))
563        .map_err(|_| offline())?;
564    response_rx.await.map_err(|_| offline())
565}
566
567#[cfg(test)]
568#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
569mod tests {
570    use super::*;
571    use crate::{AccountEventKind, balance::Balance};
572    use rust_decimal::Decimal;
573    use rustrade_integration::collection::snapshot::Snapshot;
574
575    fn filler_event() -> UnindexedAccountEvent {
576        // Content is irrelevant — these events are overwritten by the lag before they can be
577        // delivered; they exist only to overflow the broadcast buffer.
578        UnindexedAccountEvent::new(
579            ExchangeId::Mock,
580            AccountEventKind::BalanceSnapshot(Snapshot::new(AssetBalance {
581                asset: AssetNameExchange::new("btc"),
582                balance: Balance::new(Decimal::ONE, Decimal::ONE),
583                time_exchange: Utc::now(),
584            })),
585        )
586    }
587
588    /// `fetch_trades` asks the mock exchange for its span and keeps the requested instruments'
589    /// trades, reading the span whole.
590    #[tokio::test]
591    async fn fetch_trades_reads_the_span_for_the_requested_instruments() {
592        use crate::{
593            order::id::{OrderId, StrategyId},
594            trade::{AssetFees, Trade, TradeId},
595        };
596        use rustrade_instrument::Side;
597
598        let (_event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
599        let (request_tx, mut request_rx) = mpsc::unbounded_channel();
600        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
601        let start = Utc::now() - chrono::TimeDelta::hours(1);
602        let end = Utc::now();
603
604        let trade = move |instrument: &str| {
605            Trade::new(
606                TradeId::new(instrument),
607                OrderId::new("order"),
608                InstrumentNameExchange::new(instrument),
609                StrategyId::unknown(),
610                start,
611                Side::Buy,
612                Decimal::ONE,
613                Decimal::ONE,
614                None,
615                AssetFees::new(AssetNameExchange::new("usdt"), Decimal::ZERO, None),
616            )
617        };
618        let exchange = tokio::spawn(async move {
619            let request = request_rx.recv().await.expect("a request");
620            let crate::exchange::mock::request::MockExchangeRequestKind::FetchTrades {
621                response_tx,
622                start: asked_start,
623                end: asked_end,
624            } = request.kind
625            else {
626                panic!("expected FetchTrades, got {:?}", request.kind);
627            };
628            assert_eq!((asked_start, asked_end), (start, end));
629            response_tx
630                .send(vec![trade("btc_usdt"), trade("eth_usdt")])
631                .expect("the client awaits the answer");
632        });
633
634        let read = client
635            .fetch_trades(start, end, &[InstrumentNameExchange::new("eth_usdt")])
636            .await
637            .unwrap();
638        exchange.await.unwrap();
639        assert_eq!(read, TradesRead::complete(vec![trade("eth_usdt")]));
640    }
641
642    /// `request_checkpoint` asks the mock exchange for a checkpoint and answers with the id the
643    /// exchange gave it, or that the exchange is offline once it has stopped.
644    #[tokio::test]
645    async fn request_checkpoint_answers_with_the_exchange_s_id() {
646        let (request_tx, mut request_rx) = mpsc::unbounded_channel::<MockExchangeRequest>();
647        let exchange = tokio::spawn(async move {
648            let request = request_rx.recv().await.expect("a request");
649            let crate::exchange::mock::request::MockExchangeRequestKind::Checkpoint { response_tx } =
650                request.kind
651            else {
652                panic!("expected Checkpoint, got {:?}", request.kind);
653            };
654            response_tx.send(7).expect("the caller awaits the answer");
655        });
656
657        let id = request_checkpoint(&request_tx, ExchangeId::Mock, Utc::now()).await;
658        // Checked before joining the exchange's task, which waits for a request until one comes.
659        assert_eq!(id.unwrap(), 7);
660        exchange.await.unwrap();
661
662        // The exchange's task has ended and dropped its receiver.
663        assert!(matches!(
664            request_checkpoint(&request_tx, ExchangeId::Mock, Utc::now()).await,
665            Err(UnindexedClientError::Connectivity(
666                ConnectivityError::ExchangeOffline(ExchangeId::Mock)
667            ))
668        ));
669    }
670
671    /// Broadcast lag is a terminal stream death: the account stream must deliver an in-band
672    /// `StreamTerminated(Error)` (not a silent EOF) and then end.
673    #[tokio::test]
674    async fn account_stream_emits_stream_terminated_on_broadcast_lag() {
675        // Capacity 1 so a burst of sends with no reader immediately overflows → lag.
676        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
677        let (request_tx, _request_rx) = mpsc::unbounded_channel();
678        // `MockExecution` derives `Constructor`, so its inherent `new` takes the four fields
679        // directly (the `ExecutionClient::new(config)` trait method is shadowed here).
680        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
681
682        let mut stream = client.account_stream(&[], &[]).await.unwrap();
683
684        // Overflow the buffer before the stream's receiver reads anything → force a lag.
685        for _ in 0..3 {
686            event_tx.send(filler_event()).unwrap();
687        }
688
689        // The lag is surfaced in-band as the terminal event...
690        let event = stream.next().await.expect("expected a terminal event");
691        assert!(
692            matches!(
693                event.kind,
694                AccountEventKind::StreamTerminated(StreamTerminationReason::Error(_))
695            ),
696            "expected StreamTerminated(Error) on broadcast lag, got {:?}",
697            event.kind
698        );
699        assert_eq!(event.exchange, ExchangeId::Mock);
700
701        // ...after which the stream ends.
702        assert!(
703            stream.next().await.is_none(),
704            "stream must end after the terminal event"
705        );
706    }
707
708    /// A terminal event the venue sends itself, as a market-driven one does when its market feed
709    /// lags, ends the stream on the next poll, without waiting for an event a quiet venue may
710    /// never send.
711    #[tokio::test]
712    async fn account_stream_ends_right_after_a_terminal_event_from_the_venue() {
713        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(16);
714        let (request_tx, _request_rx) = mpsc::unbounded_channel();
715        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
716        let mut stream = client.account_stream(&[], &[]).await.unwrap();
717
718        event_tx.send(filler_event()).unwrap();
719        event_tx
720            .send(UnindexedAccountEvent::stream_terminated(
721                ExchangeId::Mock,
722                StreamTerminationReason::Error("market feed lagged".to_string()),
723            ))
724            .unwrap();
725
726        assert!(matches!(
727            stream.next().await.map(|event| event.kind),
728            Some(AccountEventKind::BalanceSnapshot(_))
729        ));
730        assert!(matches!(
731            stream.next().await.map(|event| event.kind),
732            Some(AccountEventKind::StreamTerminated(_))
733        ));
734        assert_eq!(
735            futures::FutureExt::now_or_never(stream.next()),
736            Some(None),
737            "the stream must end on the poll after its terminal event, with the sender still open"
738        );
739        assert_eq!(
740            futures::FutureExt::now_or_never(stream.next()),
741            Some(None),
742            "and stay ended however often it is polled"
743        );
744        drop(event_tx);
745    }
746}