Skip to main content

rustrade_execution/client/mock/
mod.rs

1use crate::{
2    UnindexedAccountEvent, UnindexedAccountSnapshot,
3    balance::AssetBalance,
4    client::ExecutionClient,
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,
14        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
15        state::{Open, OrderState, UnindexedOrderState},
16    },
17    trade::Trade,
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    ) -> Option<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 Some(UnindexedOrderResponseCancel {
220                key,
221                state: Err(UnindexedOrderError::Connectivity(
222                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
223                )),
224            });
225        }
226
227        Some(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    ) -> Option<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 Some(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        Some(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    async fn fetch_trades(
339        &self,
340        time_since: DateTime<Utc>,
341        // MockExchange fetch_trades doesn't filter by instrument
342        _instruments: &[InstrumentNameExchange],
343    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
344        let (response_tx, response_rx) = oneshot::channel();
345
346        self.request_tx
347            .send(MockExchangeRequest::fetch_trades(
348                self.time_request(),
349                response_tx,
350                time_since,
351            ))
352            .map_err(|_| {
353                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
354                    self.mocked_exchange,
355                ))
356            })?;
357
358        response_rx.await.map_err(|_| {
359            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
360                self.mocked_exchange,
361            ))
362        })
363    }
364}
365
366#[cfg(test)]
367#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
368mod tests {
369    use super::*;
370    use crate::{AccountEventKind, balance::Balance};
371    use rust_decimal::Decimal;
372    use rustrade_integration::collection::snapshot::Snapshot;
373
374    fn filler_event() -> UnindexedAccountEvent {
375        // Content is irrelevant — these events are overwritten by the lag before they can be
376        // delivered; they exist only to overflow the broadcast buffer.
377        UnindexedAccountEvent::new(
378            ExchangeId::Mock,
379            AccountEventKind::BalanceSnapshot(Snapshot::new(AssetBalance {
380                asset: AssetNameExchange::new("btc"),
381                balance: Balance::new(Decimal::ONE, Decimal::ONE),
382                time_exchange: Utc::now(),
383            })),
384        )
385    }
386
387    /// Broadcast lag is a terminal stream death: the account stream must deliver an in-band
388    /// `StreamTerminated(Error)` (not a silent EOF) and then end.
389    #[tokio::test]
390    async fn account_stream_emits_stream_terminated_on_broadcast_lag() {
391        // Capacity 1 so a burst of sends with no reader immediately overflows → lag.
392        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
393        let (request_tx, _request_rx) = mpsc::unbounded_channel();
394        // `MockExecution` derives `Constructor`, so its inherent `new` takes the four fields
395        // directly (the `ExecutionClient::new(config)` trait method is shadowed here).
396        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
397
398        let mut stream = client.account_stream(&[], &[]).await.unwrap();
399
400        // Overflow the buffer before the stream's receiver reads anything → force a lag.
401        for _ in 0..3 {
402            event_tx.send(filler_event()).unwrap();
403        }
404
405        // The lag is surfaced in-band as the terminal event...
406        let event = stream.next().await.expect("expected a terminal event");
407        assert!(
408            matches!(
409                event.kind,
410                AccountEventKind::StreamTerminated(StreamTerminationReason::Error(_))
411            ),
412            "expected StreamTerminated(Error) on broadcast lag, got {:?}",
413            event.kind
414        );
415        assert_eq!(event.exchange, ExchangeId::Mock);
416
417        // ...after which the stream ends.
418        assert!(
419            stream.next().await.is_none(),
420            "stream must end after the terminal event"
421        );
422    }
423}