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