1use async_lock::RwLock;
4use bot_core::AccountState;
5use rust_decimal::Decimal;
6use std::sync::Arc;
7use std::time::Duration;
8
9pub use crate::account_syncer::{SyncError, SyncResult};
11pub use bot_core::Fill;
12
13#[derive(Debug, Clone)]
17pub struct AccountSyncCall {
18 pub account_value: Decimal,
20 pub unrealized_pnl: Decimal,
22 pub positions: Vec<PositionInfo>,
24 pub ts: i64,
26 pub stop_bot: bool,
28 pub stop_reason: String,
30}
31
32#[derive(Debug, Clone)]
34pub struct PositionInfo {
35 pub instrument_id: String,
37 pub qty: String,
39 pub entry_px: String,
41 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
52pub struct MockAccountSyncer {
54 inner: Arc<RwLock<MockAccountSyncerState>>,
55}
56
57impl MockAccountSyncer {
58 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 pub async fn set_should_fail(&self, should_fail: bool) {
74 self.inner.write().await.should_fail = should_fail;
75 }
76
77 pub async fn set_simulated_pnl(&self, pnl: f64) {
79 self.inner.write().await.simulated_pnl = pnl;
80 }
81
82 pub async fn set_network_delay(&self, ms: u64) {
84 self.inner.write().await.network_delay_ms = ms;
85 }
86
87 pub async fn sync_calls(&self) -> Vec<AccountSyncCall> {
91 self.inner.read().await.sync_calls.clone()
92 }
93
94 pub async fn last_sync(&self) -> Option<AccountSyncCall> {
96 self.inner.read().await.sync_calls.last().cloned()
97 }
98
99 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 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 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 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 if state.network_delay_ms > 0 {
144 let delay_ms = state.network_delay_ms;
145 drop(state); tokio::time::sleep(Duration::from_millis(delay_ms)).await;
147 state = self.inner.write().await;
148 }
149
150 if state.should_fail {
152 return Err(SyncError::Network("Mock network failure".into()));
153 }
154
155 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 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#[async_trait::async_trait]
200impl crate::sync_traits::AccountSync for MockAccountSyncer {
201 fn should_sync(&self) -> bool {
202 true }
204
205 fn last_pnl(&self) -> Option<f64> {
206 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#[derive(Debug, Clone)]
247pub struct TradeSyncCall {
248 pub fills: Vec<Fill>,
250 pub current_price: Option<Decimal>,
252 pub stop_bot: bool,
254 pub stop_reason: String,
256 pub timestamp: i64,
258}
259
260struct MockTradeSyncerState {
261 sync_calls: Vec<TradeSyncCall>,
262 should_fail: bool,
263 simulated_pnl: f64,
264}
265
266pub struct MockTradeSyncer {
268 inner: Arc<RwLock<MockTradeSyncerState>>,
269}
270
271impl MockTradeSyncer {
272 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 pub async fn set_should_fail(&self, should_fail: bool) {
287 self.inner.write().await.should_fail = should_fail;
288 }
289
290 pub async fn set_simulated_pnl(&self, pnl: f64) {
292 self.inner.write().await.simulated_pnl = pnl;
293 }
294
295 pub async fn sync_calls(&self) -> Vec<TradeSyncCall> {
299 self.inner.read().await.sync_calls.clone()
300 }
301
302 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 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 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#[async_trait::async_trait]
358impl crate::sync_traits::TradeSync for MockTradeSyncer {
359 fn add_fill(&mut self, _fill: Fill) {
360 }
363
364 fn should_sync(&self) -> bool {
365 true }
367
368 fn pending_count(&self) -> usize {
369 0 }
371
372 fn last_pnl(&self) -> Option<f64> {
373 None }
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 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), 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 let calls = syncer.sync_calls().await;
477 assert_eq!(calls.len(), 0);
478 }
479}