Skip to main content

bot_engine/testing/
mock_syncer.rs

1//! Mock Syncers for testing backend integration without real API calls.
2
3use async_lock::RwLock;
4use bot_core::AccountState;
5use rust_decimal::Decimal;
6use std::sync::Arc;
7use std::time::Duration;
8
9// Re-export error types
10pub use crate::account_syncer::{SyncError, SyncResult};
11pub use bot_core::Fill;
12
13// === MockAccountSyncer (for Arbitrage/Snapshot strategies) ===
14
15/// Recorded account-sync call for assertions.
16#[derive(Debug, Clone)]
17pub struct AccountSyncCall {
18    /// Account value sent to the syncer.
19    pub account_value: Decimal,
20    /// Unrealized PnL sent to the syncer.
21    pub unrealized_pnl: Decimal,
22    /// Position payload sent to the syncer.
23    pub positions: Vec<PositionInfo>,
24    /// Sync timestamp in seconds.
25    pub ts: i64,
26    /// Whether this was a shutdown sync.
27    pub stop_bot: bool,
28    /// Stop reason for shutdown syncs.
29    pub stop_reason: String,
30}
31
32/// Position payload recorded by the account mock.
33#[derive(Debug, Clone)]
34pub struct PositionInfo {
35    /// Instrument ID string.
36    pub instrument_id: String,
37    /// Position quantity string.
38    pub qty: String,
39    /// Entry price string.
40    pub entry_px: String,
41    /// Unrealized PnL string.
42    pub unrealized_pnl: String,
43}
44
45struct MockAccountSyncerState {
46    sync_calls: Vec<AccountSyncCall>,
47    should_fail: bool,
48    simulated_pnl: f64,
49    network_delay_ms: u64,
50}
51
52/// Mock account syncer for snapshot-account strategies.
53pub struct MockAccountSyncer {
54    inner: Arc<RwLock<MockAccountSyncerState>>,
55}
56
57impl MockAccountSyncer {
58    /// Create a mock account syncer.
59    pub fn new() -> Self {
60        Self {
61            inner: Arc::new(RwLock::new(MockAccountSyncerState {
62                sync_calls: Vec::new(),
63                should_fail: false,
64                simulated_pnl: 0.0,
65                network_delay_ms: 0,
66            })),
67        }
68    }
69
70    // === KNOBS ===
71
72    /// Configure whether sync calls should fail.
73    pub async fn set_should_fail(&self, should_fail: bool) {
74        self.inner.write().await.should_fail = should_fail;
75    }
76
77    /// Set the PnL returned by successful sync calls.
78    pub async fn set_simulated_pnl(&self, pnl: f64) {
79        self.inner.write().await.simulated_pnl = pnl;
80    }
81
82    /// Set simulated network delay in milliseconds.
83    pub async fn set_network_delay(&self, ms: u64) {
84        self.inner.write().await.network_delay_ms = ms;
85    }
86
87    // === VERIFICATION ===
88
89    /// Return all recorded account sync calls.
90    pub async fn sync_calls(&self) -> Vec<AccountSyncCall> {
91        self.inner.read().await.sync_calls.clone()
92    }
93
94    /// Return the latest recorded account sync call.
95    pub async fn last_sync(&self) -> Option<AccountSyncCall> {
96        self.inner.read().await.sync_calls.last().cloned()
97    }
98
99    /// Return recorded shutdown sync calls.
100    pub async fn shutdown_syncs(&self) -> Vec<AccountSyncCall> {
101        self.inner
102            .read()
103            .await
104            .sync_calls
105            .iter()
106            .filter(|c| c.stop_bot)
107            .cloned()
108            .collect()
109    }
110
111    /// Assert exactly one shutdown sync was recorded.
112    pub async fn assert_shutdown_sync_sent(&self) {
113        let shutdowns = self.shutdown_syncs().await;
114        assert!(!shutdowns.is_empty(), "No shutdown sync was sent");
115        assert_eq!(shutdowns.len(), 1, "Multiple shutdown syncs sent");
116    }
117
118    /// Assert at least `min_count` non-shutdown syncs were recorded.
119    pub async fn assert_periodic_syncs(&self, min_count: usize) {
120        let state = self.inner.read().await;
121        let active_syncs: Vec<_> = state.sync_calls.iter().filter(|c| !c.stop_bot).collect();
122
123        assert!(
124            active_syncs.len() >= min_count,
125            "Expected at least {} periodic syncs, got {}",
126            min_count,
127            active_syncs.len()
128        );
129    }
130
131    // === SYNCER IMPLEMENTATION ===
132
133    /// Record an account sync call and return the configured mock result.
134    pub async fn sync(
135        &mut self,
136        account_state: &AccountState,
137        stop_bot: bool,
138        stop_reason: &str,
139    ) -> Result<SyncResult, SyncError> {
140        let mut state = self.inner.write().await;
141
142        // Simulate network delay
143        if state.network_delay_ms > 0 {
144            let delay_ms = state.network_delay_ms;
145            drop(state); // Release lock during sleep
146            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
147            state = self.inner.write().await;
148        }
149
150        // Check failure mode
151        if state.should_fail {
152            return Err(SyncError::Network("Mock network failure".into()));
153        }
154
155        // Record the sync call
156        let positions: Vec<PositionInfo> = account_state
157            .positions
158            .iter()
159            .map(|p| PositionInfo {
160                instrument_id: p.instrument.to_string(),
161                qty: p.qty.to_string(),
162                entry_px: p
163                    .avg_entry_px
164                    .map(|px| px.0.to_string())
165                    .unwrap_or_else(|| "0".into()),
166                unrealized_pnl: p
167                    .unrealized_pnl
168                    .map(|pnl| pnl.to_string())
169                    .unwrap_or_else(|| "0".into()),
170            })
171            .collect();
172
173        state.sync_calls.push(AccountSyncCall {
174            account_value: account_state.account_value.unwrap_or_default(),
175            unrealized_pnl: account_state.unrealized_pnl.unwrap_or_default(),
176            positions,
177            ts: bot_core::now_ms() / 1000,
178            stop_bot,
179            stop_reason: stop_reason.to_string(),
180        });
181
182        Ok(SyncResult {
183            success: true,
184            pnl: Some(state.simulated_pnl),
185        })
186    }
187
188    /// Record a shutdown account sync call.
189    pub async fn shutdown_sync(
190        &mut self,
191        account_state: &AccountState,
192        stop_reason: &str,
193    ) -> Result<SyncResult, SyncError> {
194        self.sync(account_state, true, stop_reason).await
195    }
196}
197
198// Implement AccountSync trait for drop-in substitution
199#[async_trait::async_trait]
200impl crate::sync_traits::AccountSync for MockAccountSyncer {
201    fn should_sync(&self) -> bool {
202        true // Mock always returns true, tests control when to call sync
203    }
204
205    fn last_pnl(&self) -> Option<f64> {
206        // Return from inner state synchronously would require blocking
207        // For mock, we just return None - tests should use sync_calls() to verify
208        None
209    }
210
211    async fn sync(
212        &mut self,
213        account_state: &AccountState,
214        stop_bot: bool,
215        stop_reason: &str,
216    ) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
217        let result = MockAccountSyncer::sync(self, account_state, stop_bot, stop_reason).await?;
218        Ok(crate::sync_traits::AccountSyncResult {
219            success: result.success,
220            pnl: result.pnl,
221        })
222    }
223
224    async fn shutdown_sync(
225        &mut self,
226        account_state: &AccountState,
227        stop_reason: &str,
228    ) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
229        let result = MockAccountSyncer::shutdown_sync(self, account_state, stop_reason).await?;
230        Ok(crate::sync_traits::AccountSyncResult {
231            success: result.success,
232            pnl: result.pnl,
233        })
234    }
235}
236
237impl Default for MockAccountSyncer {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243// === MockTradeSyncer (for Grid/MM strategies) ===
244
245/// Recorded trade-sync call for assertions.
246#[derive(Debug, Clone)]
247pub struct TradeSyncCall {
248    /// Fills sent to the syncer.
249    pub fills: Vec<Fill>,
250    /// Current market price sent with the sync.
251    pub current_price: Option<Decimal>,
252    /// Whether this was a shutdown sync.
253    pub stop_bot: bool,
254    /// Stop reason for shutdown syncs.
255    pub stop_reason: String,
256    /// Sync timestamp in milliseconds.
257    pub timestamp: i64,
258}
259
260struct MockTradeSyncerState {
261    sync_calls: Vec<TradeSyncCall>,
262    should_fail: bool,
263    simulated_pnl: f64,
264}
265
266/// Mock trade syncer for fill-based strategies.
267pub struct MockTradeSyncer {
268    inner: Arc<RwLock<MockTradeSyncerState>>,
269}
270
271impl MockTradeSyncer {
272    /// Create a mock trade syncer.
273    pub fn new() -> Self {
274        Self {
275            inner: Arc::new(RwLock::new(MockTradeSyncerState {
276                sync_calls: Vec::new(),
277                should_fail: false,
278                simulated_pnl: 0.0,
279            })),
280        }
281    }
282
283    // === KNOBS ===
284
285    /// Configure whether sync calls should fail.
286    pub async fn set_should_fail(&self, should_fail: bool) {
287        self.inner.write().await.should_fail = should_fail;
288    }
289
290    /// Set the PnL returned by successful sync calls.
291    pub async fn set_simulated_pnl(&self, pnl: f64) {
292        self.inner.write().await.simulated_pnl = pnl;
293    }
294
295    // === VERIFICATION ===
296
297    /// Return all recorded trade sync calls.
298    pub async fn sync_calls(&self) -> Vec<TradeSyncCall> {
299        self.inner.read().await.sync_calls.clone()
300    }
301
302    /// Count fills across all recorded trade sync calls.
303    pub async fn total_fills_synced(&self) -> usize {
304        self.inner
305            .read()
306            .await
307            .sync_calls
308            .iter()
309            .map(|c| c.fills.len())
310            .sum()
311    }
312
313    /// Assert that the expected number of fills was synced.
314    pub async fn assert_all_fills_synced(&self, expected_fills: &[Fill]) {
315        let total = self.total_fills_synced().await;
316        assert_eq!(
317            total,
318            expected_fills.len(),
319            "Expected {} fills synced, got {}",
320            expected_fills.len(),
321            total
322        );
323    }
324
325    // === SYNCER IMPLEMENTATION ===
326
327    /// Record a trade sync call and return the configured mock result.
328    pub async fn sync(
329        &mut self,
330        fills: Vec<Fill>,
331        current_price: Option<Decimal>,
332        stop_bot: bool,
333        stop_reason: &str,
334    ) -> Result<SyncResult, SyncError> {
335        let mut state = self.inner.write().await;
336
337        if state.should_fail {
338            return Err(SyncError::Network("Mock sync failure".into()));
339        }
340
341        state.sync_calls.push(TradeSyncCall {
342            fills,
343            current_price,
344            stop_bot,
345            stop_reason: stop_reason.to_string(),
346            timestamp: bot_core::now_ms(),
347        });
348
349        Ok(SyncResult {
350            success: true,
351            pnl: Some(state.simulated_pnl),
352        })
353    }
354}
355
356// Implement TradeSync trait for drop-in substitution
357#[async_trait::async_trait]
358impl crate::sync_traits::TradeSync for MockTradeSyncer {
359    fn add_fill(&mut self, _fill: Fill) {
360        // For MockTradeSyncer, fills are passed directly to sync()
361        // This is a no-op, tests use sync() directly with fills
362    }
363
364    fn should_sync(&self) -> bool {
365        true // Mock always returns true
366    }
367
368    fn pending_count(&self) -> usize {
369        0 // Mock doesn't accumulate, passes fills directly to sync
370    }
371
372    fn last_pnl(&self) -> Option<f64> {
373        None // Tests use sync_calls() to verify
374    }
375
376    async fn sync(
377        &mut self,
378        current_price: Option<rust_decimal::Decimal>,
379        stop_bot: bool,
380        stop_reason: &str,
381    ) -> Result<crate::sync_traits::TradeSyncResult, SyncError> {
382        // For trait impl, we sync with empty fills (trait-based usage)
383        let result =
384            MockTradeSyncer::sync(self, vec![], current_price, stop_bot, stop_reason).await?;
385        Ok(crate::sync_traits::TradeSyncResult {
386            success: result.success,
387            pnl: result.pnl,
388        })
389    }
390
391    async fn shutdown_sync(
392        &mut self,
393        current_price: Option<rust_decimal::Decimal>,
394        stop_reason: &str,
395    ) -> Result<crate::sync_traits::TradeSyncResult, SyncError> {
396        let result = MockTradeSyncer::sync(self, vec![], current_price, true, stop_reason).await?;
397        Ok(crate::sync_traits::TradeSyncResult {
398            success: result.success,
399            pnl: result.pnl,
400        })
401    }
402}
403
404impl Default for MockTradeSyncer {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use bot_core::{InstrumentId, PositionSnapshot};
414
415    #[tokio::test]
416    async fn test_account_syncer_recording() {
417        let mut syncer = MockAccountSyncer::new();
418
419        let account_state = AccountState {
420            positions: vec![PositionSnapshot {
421                instrument: InstrumentId::new("ETH-PERP"),
422                qty: Decimal::new(-1, 1), // -0.1
423                avg_entry_px: Some(bot_core::Price::new(Decimal::new(3000, 0))),
424                unrealized_pnl: Some(Decimal::new(10, 0)),
425                liquidation_px: None,
426            }],
427            account_value: Some(Decimal::new(50000, 0)),
428            unrealized_pnl: Some(Decimal::new(10, 0)),
429        };
430
431        syncer.sync(&account_state, false, "").await.unwrap();
432
433        let calls = syncer.sync_calls().await;
434        assert_eq!(calls.len(), 1);
435        assert_eq!(calls[0].stop_bot, false);
436        assert_eq!(calls[0].positions.len(), 1);
437    }
438
439    #[tokio::test]
440    async fn test_shutdown_sync() {
441        let mut syncer = MockAccountSyncer::new();
442
443        let account_state = AccountState {
444            positions: vec![],
445            account_value: Some(Decimal::new(50000, 0)),
446            unrealized_pnl: Some(Decimal::ZERO),
447        };
448
449        syncer
450            .shutdown_sync(&account_state, "shutdown:external")
451            .await
452            .unwrap();
453
454        syncer.assert_shutdown_sync_sent().await;
455
456        let shutdown = syncer.last_sync().await.unwrap();
457        assert_eq!(shutdown.stop_bot, true);
458        assert_eq!(shutdown.stop_reason, "shutdown:external");
459    }
460
461    #[tokio::test]
462    async fn test_syncer_failure_mode() {
463        let mut syncer = MockAccountSyncer::new();
464        syncer.set_should_fail(true).await;
465
466        let account_state = AccountState {
467            positions: vec![],
468            account_value: None,
469            unrealized_pnl: None,
470        };
471
472        let result = syncer.sync(&account_state, false, "").await;
473        assert!(result.is_err());
474
475        // No calls recorded on failure
476        let calls = syncer.sync_calls().await;
477        assert_eq!(calls.len(), 0);
478    }
479}