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