Skip to main content

rustrade_execution/client/mock/
mod.rs

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