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        let (response_tx, response_rx) = oneshot::channel();
472
473        self.request_tx
474            .send(MockExchangeRequest::fetch_borrow_capacity(
475                self.time_request(),
476                instrument.clone(),
477                side,
478                response_tx,
479            ))
480            .map_err(|_| {
481                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
482                    self.mocked_exchange,
483                ))
484            })?;
485
486        response_rx
487            .await
488            .map_err(|_| {
489                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
490                    self.mocked_exchange,
491                ))
492            })?
493            .map_err(ClientError::Api)
494    }
495}
496
497#[cfg(test)]
498#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
499mod tests {
500    use super::*;
501    use crate::{AccountEventKind, balance::Balance};
502    use rust_decimal::Decimal;
503    use rustrade_integration::collection::snapshot::Snapshot;
504
505    fn filler_event() -> UnindexedAccountEvent {
506        // Content is irrelevant — these events are overwritten by the lag before they can be
507        // delivered; they exist only to overflow the broadcast buffer.
508        UnindexedAccountEvent::new(
509            ExchangeId::Mock,
510            AccountEventKind::BalanceSnapshot(Snapshot::new(AssetBalance {
511                asset: AssetNameExchange::new("btc"),
512                balance: Balance::new(Decimal::ONE, Decimal::ONE),
513                time_exchange: Utc::now(),
514            })),
515        )
516    }
517
518    /// `fetch_trades` asks the mock exchange for its span and keeps the requested instruments'
519    /// trades, reading the span whole.
520    #[tokio::test]
521    async fn fetch_trades_reads_the_span_for_the_requested_instruments() {
522        use crate::{
523            order::id::{OrderId, StrategyId},
524            trade::{AssetFees, Trade, TradeId},
525        };
526        use rustrade_instrument::Side;
527
528        let (_event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
529        let (request_tx, mut request_rx) = mpsc::unbounded_channel();
530        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
531        let start = Utc::now() - chrono::TimeDelta::hours(1);
532        let end = Utc::now();
533
534        let trade = move |instrument: &str| {
535            Trade::new(
536                TradeId::new(instrument),
537                OrderId::new("order"),
538                InstrumentNameExchange::new(instrument),
539                StrategyId::unknown(),
540                start,
541                Side::Buy,
542                Decimal::ONE,
543                Decimal::ONE,
544                None,
545                AssetFees::new(AssetNameExchange::new("usdt"), Decimal::ZERO, None),
546            )
547        };
548        let exchange = tokio::spawn(async move {
549            let request = request_rx.recv().await.expect("a request");
550            let crate::exchange::mock::request::MockExchangeRequestKind::FetchTrades {
551                response_tx,
552                start: asked_start,
553                end: asked_end,
554            } = request.kind
555            else {
556                panic!("expected FetchTrades, got {:?}", request.kind);
557            };
558            assert_eq!((asked_start, asked_end), (start, end));
559            response_tx
560                .send(vec![trade("btc_usdt"), trade("eth_usdt")])
561                .expect("the client awaits the answer");
562        });
563
564        let read = client
565            .fetch_trades(start, end, &[InstrumentNameExchange::new("eth_usdt")])
566            .await
567            .unwrap();
568        exchange.await.unwrap();
569        assert_eq!(read, TradesRead::complete(vec![trade("eth_usdt")]));
570    }
571
572    /// Broadcast lag is a terminal stream death: the account stream must deliver an in-band
573    /// `StreamTerminated(Error)` (not a silent EOF) and then end.
574    #[tokio::test]
575    async fn account_stream_emits_stream_terminated_on_broadcast_lag() {
576        // Capacity 1 so a burst of sends with no reader immediately overflows → lag.
577        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
578        let (request_tx, _request_rx) = mpsc::unbounded_channel();
579        // `MockExecution` derives `Constructor`, so its inherent `new` takes the four fields
580        // directly (the `ExecutionClient::new(config)` trait method is shadowed here).
581        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
582
583        let mut stream = client.account_stream(&[], &[]).await.unwrap();
584
585        // Overflow the buffer before the stream's receiver reads anything → force a lag.
586        for _ in 0..3 {
587            event_tx.send(filler_event()).unwrap();
588        }
589
590        // The lag is surfaced in-band as the terminal event...
591        let event = stream.next().await.expect("expected a terminal event");
592        assert!(
593            matches!(
594                event.kind,
595                AccountEventKind::StreamTerminated(StreamTerminationReason::Error(_))
596            ),
597            "expected StreamTerminated(Error) on broadcast lag, got {:?}",
598            event.kind
599        );
600        assert_eq!(event.exchange, ExchangeId::Mock);
601
602        // ...after which the stream ends.
603        assert!(
604            stream.next().await.is_none(),
605            "stream must end after the terminal event"
606        );
607    }
608
609    /// A terminal event the venue sends itself, as a market-driven one does when its market feed
610    /// lags, ends the stream on the next poll, without waiting for an event a quiet venue may
611    /// never send.
612    #[tokio::test]
613    async fn account_stream_ends_right_after_a_terminal_event_from_the_venue() {
614        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(16);
615        let (request_tx, _request_rx) = mpsc::unbounded_channel();
616        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
617        let mut stream = client.account_stream(&[], &[]).await.unwrap();
618
619        event_tx.send(filler_event()).unwrap();
620        event_tx
621            .send(UnindexedAccountEvent::stream_terminated(
622                ExchangeId::Mock,
623                StreamTerminationReason::Error("market feed lagged".to_string()),
624            ))
625            .unwrap();
626
627        assert!(matches!(
628            stream.next().await.map(|event| event.kind),
629            Some(AccountEventKind::BalanceSnapshot(_))
630        ));
631        assert!(matches!(
632            stream.next().await.map(|event| event.kind),
633            Some(AccountEventKind::StreamTerminated(_))
634        ));
635        assert_eq!(
636            futures::FutureExt::now_or_never(stream.next()),
637            Some(None),
638            "the stream must end on the poll after its terminal event, with the sender still open"
639        );
640        assert_eq!(
641            futures::FutureExt::now_or_never(stream.next()),
642            Some(None),
643            "and stay ended however often it is polled"
644        );
645        drop(event_tx);
646    }
647}