Skip to main content

rustrade_execution/client/mock/
mod.rs

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