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::{MarketPrices, MockExchangeRequest},
10    fee::FeeModelConfig,
11    fill::SimFillConfig,
12    order::{
13        Order, OrderEvent, 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, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
24};
25use serde::{Deserialize, Serialize};
26use tokio::sync::{broadcast, mpsc, oneshot};
27use tokio_stream::wrappers::BroadcastStream;
28use tracing::error;
29
30#[derive(
31    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
32)]
33pub struct MockExecutionConfig {
34    pub mocked_exchange: ExchangeId,
35    pub initial_state: UnindexedAccountSnapshot,
36    pub latency_ms: u64,
37    /// Fee model used by the mock exchange to compute trading fees.
38    ///
39    /// Defaults to [`FeeModelConfig::Zero`]. Use [`FeeModelConfig::Percentage`]
40    /// for spot/futures simulation (e.g. 0.1% taker fee).
41    #[serde(default)]
42    pub fee_model: FeeModelConfig,
43    /// Fill model used by the mock exchange to compute execution prices.
44    ///
45    /// Defaults to [`SimFillConfig::LastPrice`], which fills at the
46    /// order price (identical to pre-FillModel behaviour). Switch to
47    /// [`SimFillConfig::BidAsk`] or [`SimFillConfig::Midpoint`] for
48    /// more realistic spread-cost simulation when market prices are injected
49    /// alongside orders.
50    #[serde(default)]
51    pub fill_model: SimFillConfig,
52}
53
54#[derive(Debug, Constructor)]
55pub struct MockExecutionClientConfig<FnTime> {
56    pub mocked_exchange: ExchangeId,
57    pub clock: FnTime,
58    pub request_tx: mpsc::UnboundedSender<MockExchangeRequest>,
59    pub event_rx: broadcast::Receiver<UnindexedAccountEvent>,
60}
61
62impl<FnTime> Clone for MockExecutionClientConfig<FnTime>
63where
64    FnTime: Clone,
65{
66    fn clone(&self) -> Self {
67        Self {
68            mocked_exchange: self.mocked_exchange,
69            clock: self.clock.clone(),
70            request_tx: self.request_tx.clone(),
71            event_rx: self.event_rx.resubscribe(),
72        }
73    }
74}
75
76#[derive(Debug, Constructor)]
77pub struct MockExecution<FnTime> {
78    pub mocked_exchange: ExchangeId,
79    pub clock: FnTime,
80    pub request_tx: mpsc::UnboundedSender<MockExchangeRequest>,
81    pub event_rx: broadcast::Receiver<UnindexedAccountEvent>,
82}
83
84impl<FnTime> Clone for MockExecution<FnTime>
85where
86    FnTime: Clone,
87{
88    fn clone(&self) -> Self {
89        Self {
90            mocked_exchange: self.mocked_exchange,
91            clock: self.clock.clone(),
92            request_tx: self.request_tx.clone(),
93            event_rx: self.event_rx.resubscribe(),
94        }
95    }
96}
97
98impl<FnTime> MockExecution<FnTime>
99where
100    FnTime: Fn() -> DateTime<Utc>,
101{
102    pub fn time_request(&self) -> DateTime<Utc> {
103        (self.clock)()
104    }
105}
106
107impl<FnTime> ExecutionClient for MockExecution<FnTime>
108where
109    FnTime: Fn() -> DateTime<Utc> + Clone + Send + Sync,
110{
111    const EXCHANGE: ExchangeId = ExchangeId::Mock;
112    type Config = MockExecutionClientConfig<FnTime>;
113    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
114
115    fn new(config: Self::Config) -> Self {
116        Self {
117            mocked_exchange: config.mocked_exchange,
118            clock: config.clock,
119            request_tx: config.request_tx,
120            event_rx: config.event_rx,
121        }
122    }
123
124    async fn account_snapshot(
125        &self,
126        _: &[AssetNameExchange],
127        _: &[InstrumentNameExchange],
128    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
129        let (response_tx, response_rx) = oneshot::channel();
130
131        self.request_tx
132            .send(MockExchangeRequest::fetch_account_snapshot(
133                self.time_request(),
134                response_tx,
135            ))
136            .map_err(|_| {
137                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
138                    self.mocked_exchange,
139                ))
140            })?;
141
142        response_rx.await.map_err(|_| {
143            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
144                self.mocked_exchange,
145            ))
146        })
147    }
148
149    async fn account_stream(
150        &self,
151        _: &[AssetNameExchange],
152        _: &[InstrumentNameExchange],
153    ) -> Result<Self::AccountStream, UnindexedClientError> {
154        // `scan` (not `map_while`) so the broadcast-lag terminal is delivered in-band: on lag we
155        // yield one StreamTerminated event, set `done`, and the next poll ends the stream — whereas
156        // `map_while` would end on lag with a silent EOF and no terminal event.
157        let exchange = self.mocked_exchange;
158        Ok(BroadcastStream::new(self.event_rx.resubscribe())
159            .scan(false, move |done, result| {
160                // `futures::StreamExt::scan` ends the stream when the closure resolves to None.
161                let item = if *done {
162                    // Terminal already emitted on the prior poll — end the stream now.
163                    None
164                } else {
165                    match result {
166                        Ok(event) => Some(event),
167                        Err(error) => {
168                            error!(
169                                ?error,
170                                "MockExchange Broadcast AccountStream lagged - terminating"
171                            );
172                            *done = true;
173                            Some(UnindexedAccountEvent::stream_terminated(
174                                exchange,
175                                StreamTerminationReason::Error(
176                                    "mock broadcast stream lagged".to_string(),
177                                ),
178                            ))
179                        }
180                    }
181                };
182                futures::future::ready(item)
183            })
184            .boxed())
185    }
186
187    async fn cancel_order(
188        &self,
189        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
190    ) -> Option<UnindexedOrderResponseCancel> {
191        let (response_tx, response_rx) = oneshot::channel();
192
193        let key = OrderKey {
194            exchange: request.key.exchange,
195            instrument: request.key.instrument.clone(),
196            strategy: request.key.strategy.clone(),
197            cid: request.key.cid.clone(),
198        };
199
200        if self
201            .request_tx
202            .send(MockExchangeRequest::cancel_order(
203                self.time_request(),
204                response_tx,
205                into_owned_request(request),
206            ))
207            .is_err()
208        {
209            return Some(UnindexedOrderResponseCancel {
210                key,
211                state: Err(UnindexedOrderError::Connectivity(
212                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
213                )),
214            });
215        }
216
217        Some(match response_rx.await {
218            Ok(response) => response,
219            Err(_) => UnindexedOrderResponseCancel {
220                key,
221                state: Err(UnindexedOrderError::Connectivity(
222                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
223                )),
224            },
225        })
226    }
227
228    async fn open_order(
229        &self,
230        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
231    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
232        let (response_tx, response_rx) = oneshot::channel();
233
234        let request = into_owned_request(request);
235
236        if self
237            .request_tx
238            .send(MockExchangeRequest::open_order(
239                self.time_request(),
240                response_tx,
241                request.clone(),
242                MarketPrices::default(), // no market-data subscription; FillModel uses last_price=Some(request.state.price) as fallback, so fill equals request price
243            ))
244            .is_err()
245        {
246            return Some(Order {
247                key: request.key,
248                side: request.state.side,
249                price: request.state.price,
250                quantity: request.state.quantity,
251                kind: request.state.kind,
252                time_in_force: request.state.time_in_force,
253                state: OrderState::inactive(OrderError::Connectivity(
254                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
255                )),
256            });
257        }
258
259        Some(match response_rx.await {
260            Ok(response) => response,
261            Err(_) => Order {
262                key: request.key,
263                side: request.state.side,
264                price: request.state.price,
265                quantity: request.state.quantity,
266                kind: request.state.kind,
267                time_in_force: request.state.time_in_force,
268                state: OrderState::inactive(OrderError::Connectivity(
269                    ConnectivityError::ExchangeOffline(self.mocked_exchange),
270                )),
271            },
272        })
273    }
274
275    async fn fetch_balances(
276        &self,
277        assets: &[AssetNameExchange],
278    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
279        let (response_tx, response_rx) = oneshot::channel();
280
281        self.request_tx
282            .send(MockExchangeRequest::fetch_balances(
283                self.time_request(),
284                assets.to_vec(),
285                response_tx,
286            ))
287            .map_err(|_| {
288                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
289                    self.mocked_exchange,
290                ))
291            })?;
292
293        response_rx.await.map_err(|_| {
294            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
295                self.mocked_exchange,
296            ))
297        })
298    }
299
300    async fn fetch_open_orders(
301        &self,
302        instruments: &[InstrumentNameExchange],
303    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
304        let (response_tx, response_rx) = oneshot::channel();
305
306        self.request_tx
307            .send(MockExchangeRequest::fetch_orders_open(
308                self.time_request(),
309                instruments.to_vec(),
310                response_tx,
311            ))
312            .map_err(|_| {
313                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
314                    self.mocked_exchange,
315                ))
316            })?;
317
318        response_rx.await.map_err(|_| {
319            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
320                self.mocked_exchange,
321            ))
322        })
323    }
324
325    async fn fetch_trades(
326        &self,
327        time_since: DateTime<Utc>,
328        // MockExchange fetch_trades doesn't filter by instrument
329        _instruments: &[InstrumentNameExchange],
330    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
331        let (response_tx, response_rx) = oneshot::channel();
332
333        self.request_tx
334            .send(MockExchangeRequest::fetch_trades(
335                self.time_request(),
336                response_tx,
337                time_since,
338            ))
339            .map_err(|_| {
340                UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
341                    self.mocked_exchange,
342                ))
343            })?;
344
345        response_rx.await.map_err(|_| {
346            UnindexedClientError::Connectivity(ConnectivityError::ExchangeOffline(
347                self.mocked_exchange,
348            ))
349        })
350    }
351}
352
353fn into_owned_request<Kind>(
354    request: OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>,
355) -> OrderEvent<Kind, ExchangeId, InstrumentNameExchange> {
356    let OrderEvent {
357        key:
358            OrderKey {
359                exchange,
360                instrument,
361                strategy,
362                cid,
363            },
364        state,
365    } = request;
366
367    OrderEvent {
368        key: OrderKey {
369            exchange,
370            instrument: instrument.clone(),
371            strategy,
372            cid,
373        },
374        state,
375    }
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
380mod tests {
381    use super::*;
382    use crate::{AccountEventKind, balance::Balance};
383    use rust_decimal::Decimal;
384    use rustrade_integration::collection::snapshot::Snapshot;
385
386    fn filler_event() -> UnindexedAccountEvent {
387        // Content is irrelevant — these events are overwritten by the lag before they can be
388        // delivered; they exist only to overflow the broadcast buffer.
389        UnindexedAccountEvent::new(
390            ExchangeId::Mock,
391            AccountEventKind::BalanceSnapshot(Snapshot::new(AssetBalance {
392                asset: AssetNameExchange::new("btc"),
393                balance: Balance::new(Decimal::ONE, Decimal::ONE),
394                time_exchange: Utc::now(),
395            })),
396        )
397    }
398
399    /// Broadcast lag is a terminal stream death: the account stream must deliver an in-band
400    /// `StreamTerminated(Error)` (not a silent EOF) and then end.
401    #[tokio::test]
402    async fn account_stream_emits_stream_terminated_on_broadcast_lag() {
403        // Capacity 1 so a burst of sends with no reader immediately overflows → lag.
404        let (event_tx, event_rx) = broadcast::channel::<UnindexedAccountEvent>(1);
405        let (request_tx, _request_rx) = mpsc::unbounded_channel();
406        // `MockExecution` derives `Constructor`, so its inherent `new` takes the four fields
407        // directly (the `ExecutionClient::new(config)` trait method is shadowed here).
408        let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
409
410        let mut stream = client.account_stream(&[], &[]).await.unwrap();
411
412        // Overflow the buffer before the stream's receiver reads anything → force a lag.
413        for _ in 0..3 {
414            event_tx.send(filler_event()).unwrap();
415        }
416
417        // The lag is surfaced in-band as the terminal event...
418        let event = stream.next().await.expect("expected a terminal event");
419        assert!(
420            matches!(
421                event.kind,
422                AccountEventKind::StreamTerminated(StreamTerminationReason::Error(_))
423            ),
424            "expected StreamTerminated(Error) on broadcast lag, got {:?}",
425            event.kind
426        );
427        assert_eq!(event.exchange, ExchangeId::Mock);
428
429        // ...after which the stream ends.
430        assert!(
431            stream.next().await.is_none(),
432            "stream must end after the terminal event"
433        );
434    }
435}