1use bot_core::{now_ms, Fill, InstrumentId};
7use reqwest::Client;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use std::time::Duration;
12
13use crate::performance_metrics::PerformanceMetricsSnapshot;
14pub use crate::account_syncer::SyncError;
16
17#[derive(Debug, Clone)]
19pub struct TradeSyncerConfig {
20 pub bot_id: String,
22 pub upstream_url: String,
24 pub sync_interval_ms: u64,
26 pub timeout_secs: u64,
28 pub max_retries: u32,
30 pub retry_delay_ms: u64,
32 pub instruments: Vec<InstrumentId>,
35 pub strategy_type: Option<String>,
37 pub sync_secret: Option<String>,
39}
40
41impl Default for TradeSyncerConfig {
42 fn default() -> Self {
43 Self {
44 bot_id: String::new(),
45 upstream_url: String::new(),
46 sync_interval_ms: 10_000,
47 timeout_secs: 10,
48 max_retries: 3,
49 retry_delay_ms: 1000,
50 instruments: Vec::new(),
51 strategy_type: None,
52 sync_secret: None,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UpstreamTrade {
60 pub trade_id: String,
62 pub client_order_id: String,
64 pub venue_order_id: String,
66 pub instrument_id: String,
68 pub side: String,
70 pub order_type: String,
72 pub qty: String,
74 pub price: String,
76 pub quote_notional: String,
78 pub fee: String,
80 pub fee_currency: String,
82 pub liquidity: String,
84 pub ts_event: i64,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct SyncRequest {
91 pub trades: Vec<UpstreamTrade>,
93 pub ts: i64,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub current_price: Option<String>,
98 pub stop_bot: bool,
100 pub stop_reason: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub metadata: Option<serde_json::Value>,
105}
106
107#[derive(Debug, Clone, Deserialize)]
109pub struct SyncResponse {
110 pub synced: SyncedInfo,
112 #[serde(default)]
113 pub pnl: f64,
115}
116
117#[derive(Debug, Clone, Deserialize)]
119pub struct SyncedInfo {
120 pub trade_id: String,
122 pub ts: i64,
124}
125
126#[derive(Debug, Clone)]
128pub struct SyncResult {
129 pub success: bool,
131 pub pnl: Option<f64>,
133 pub last_synced_trade_id: Option<String>,
135 pub trades_synced: usize,
137}
138
139pub struct TradeSyncer {
141 config: TradeSyncerConfig,
142 client: Client,
143 synced_trade_ids: HashSet<String>,
145 pending_fills: Vec<Fill>,
147 last_sync_ts: i64,
149 last_pnl: Option<f64>,
151 start_timestamp: i64,
153 metrics_snapshot: Option<PerformanceMetricsSnapshot>,
155}
156
157impl TradeSyncer {
158 pub fn new(config: TradeSyncerConfig) -> Result<Self, SyncError> {
160 if config.bot_id.is_empty() {
161 return Err(SyncError::Config("bot_id is required".to_string()));
162 }
163 if config.upstream_url.is_empty() {
164 return Err(SyncError::Config("upstream_url is required".to_string()));
165 }
166
167 let client = Client::builder()
168 .timeout(Duration::from_secs(config.timeout_secs))
169 .build()
170 .map_err(|e| SyncError::Http(e.to_string()))?;
171
172 let start_timestamp = now_ms();
173 tracing::info!(
174 "[TradeSyncer] Initialized with start_timestamp={} - only fills after this time will be synced",
175 start_timestamp
176 );
177
178 Ok(Self {
179 config,
180 client,
181 synced_trade_ids: HashSet::new(),
182 pending_fills: Vec::new(),
183 last_sync_ts: 0,
184 last_pnl: None,
185 start_timestamp,
186 metrics_snapshot: None,
187 })
188 }
189
190 pub fn add_fill(&mut self, fill: Fill) {
192 if fill.ts < self.start_timestamp {
194 tracing::debug!(
195 "Skipping historical fill: {} (ts={} < start_ts={})",
196 fill.trade_id,
197 fill.ts,
198 self.start_timestamp
199 );
200 return;
201 }
202
203 if self.synced_trade_ids.contains(&fill.trade_id.0) {
205 tracing::debug!("Skipping already-synced fill: {}", fill.trade_id);
206 return;
207 }
208
209 if !self.config.instruments.is_empty()
211 && !self.config.instruments.contains(&fill.instrument)
212 {
213 tracing::debug!(
214 "Skipping fill for untracked instrument: {} (tracking: {:?})",
215 fill.instrument,
216 self.config.instruments
217 );
218 return;
219 }
220
221 tracing::info!(
222 "[TradeSyncer] Adding fill to pending queue: {} (ts={})",
223 fill.trade_id,
224 fill.ts
225 );
226 self.pending_fills.push(fill);
227 }
228
229 pub fn should_sync(&self) -> bool {
231 let now = now_ms();
232 now - self.last_sync_ts >= self.config.sync_interval_ms as i64
233 }
234
235 pub fn last_pnl(&self) -> Option<f64> {
237 self.last_pnl
238 }
239
240 pub fn pending_count(&self) -> usize {
242 self.pending_fills.len()
243 }
244
245 pub fn set_metrics_snapshot(&mut self, snapshot: Option<PerformanceMetricsSnapshot>) {
247 self.metrics_snapshot = snapshot;
248 }
249
250 pub async fn sync(
254 &mut self,
255 current_price: Option<Decimal>,
256 stop_bot: bool,
257 stop_reason: &str,
258 ) -> Result<SyncResult, SyncError> {
259 let now = now_ms();
260
261 let trades: Vec<UpstreamTrade> = self
263 .pending_fills
264 .iter()
265 .filter(|f| !self.synced_trade_ids.contains(&f.trade_id.0))
266 .map(|f| self.fill_to_trade(f))
267 .collect();
268
269 let trades_count = trades.len();
270
271 tracing::info!(
272 "[TradeSyncer] Syncing {} trades to upstream (pending={}, synced={})",
273 trades_count,
274 self.pending_fills.len(),
275 self.synced_trade_ids.len()
276 );
277
278 let request = SyncRequest {
279 trades,
280 ts: now / 1000, current_price: current_price.map(|p| p.to_string()),
282 stop_bot,
283 stop_reason: stop_reason.to_string(),
284 metadata: self.metadata_payload(),
285 };
286
287 let response = self.execute_with_retry(&request).await?;
289
290 for fill in &self.pending_fills {
292 self.synced_trade_ids.insert(fill.trade_id.0.clone());
293 }
294
295 self.pending_fills.clear();
297
298 self.last_sync_ts = now;
300 self.last_pnl = Some(response.pnl);
301
302 tracing::info!(
303 "[TradeSyncer] Sync successful: pnl={:.4}, last_trade_id={}",
304 response.pnl,
305 response.synced.trade_id
306 );
307
308 Ok(SyncResult {
309 success: true,
310 pnl: Some(response.pnl),
311 last_synced_trade_id: Some(response.synced.trade_id),
312 trades_synced: trades_count,
313 })
314 }
315
316 fn metadata_payload(&self) -> Option<serde_json::Value> {
317 let mut metadata = serde_json::Map::new();
318
319 if let Some(strategy_type) = self
320 .config
321 .strategy_type
322 .as_deref()
323 .filter(|s| !s.is_empty())
324 {
325 metadata.insert(
326 "strategy_type".to_string(),
327 serde_json::json!(strategy_type),
328 );
329 }
330
331 if let Some(snapshot) = self.metrics_snapshot.as_ref() {
332 metadata.insert(
333 "performance_metrics".to_string(),
334 serde_json::json!(snapshot),
335 );
336 }
337
338 (!metadata.is_empty()).then(|| serde_json::Value::Object(metadata))
339 }
340
341 async fn execute_with_retry(&self, request: &SyncRequest) -> Result<SyncResponse, SyncError> {
343 let url = format!(
344 "{}/sync/{}",
345 self.config.upstream_url.trim_end_matches('/'),
346 self.config.bot_id
347 );
348
349 let mut last_error: Option<SyncError> = None;
350 let mut delay_ms = self.config.retry_delay_ms;
351
352 for attempt in 1..=self.config.max_retries {
353 tracing::debug!(
354 "[TradeSyncer] Sync attempt {}/{} to {}",
355 attempt,
356 self.config.max_retries,
357 url
358 );
359
360 match self.execute_request(&url, request).await {
361 Ok(response) => return Ok(response),
362 Err(e) => {
363 tracing::warn!(
364 "[TradeSyncer] Sync attempt {}/{} failed: {}",
365 attempt,
366 self.config.max_retries,
367 e
368 );
369 last_error = Some(e);
370
371 if attempt < self.config.max_retries {
372 tracing::debug!("[TradeSyncer] Retrying in {}ms...", delay_ms);
373 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
374 delay_ms *= 2; }
376 }
377 }
378 }
379
380 Err(last_error.unwrap_or(SyncError::MaxRetries))
381 }
382
383 async fn execute_request(
385 &self,
386 url: &str,
387 request: &SyncRequest,
388 ) -> Result<SyncResponse, SyncError> {
389 let mut request_builder = self
390 .client
391 .post(url)
392 .header("Content-Type", "application/json");
393 if let Some(secret) = self.config.sync_secret.as_deref().filter(|s| !s.is_empty()) {
394 request_builder = request_builder.header("x-bot-sync-secret", secret);
395 }
396 let response = request_builder.json(request).send().await?;
397
398 let status = response.status();
399 if !status.is_success() {
400 let body = response.text().await.unwrap_or_default();
401 return Err(SyncError::Api {
402 status: status.as_u16(),
403 body,
404 });
405 }
406
407 let sync_response: SyncResponse = response
408 .json()
409 .await
410 .map_err(|e| SyncError::Parse(e.to_string()))?;
411
412 Ok(sync_response)
413 }
414
415 fn fill_to_trade(&self, fill: &Fill) -> UpstreamTrade {
417 let side = format!("{}", fill.side);
419
420 let quote_notional = fill.price.0 * fill.qty.0;
422
423 UpstreamTrade {
424 trade_id: fill.trade_id.0.clone(),
425 client_order_id: fill
426 .client_id
427 .as_ref()
428 .map(|c| c.0.clone())
429 .unwrap_or_default(),
430 venue_order_id: fill
431 .exchange_order_id
432 .as_ref()
433 .map(|e| e.0.clone())
434 .unwrap_or_default(),
435 instrument_id: fill.instrument.0.clone(),
436 side,
437 order_type: "LIMIT".to_string(),
438 qty: fill.qty.0.to_string(),
439 price: fill.price.0.to_string(),
440 quote_notional: quote_notional.to_string(),
441 fee: fill.fee.amount.to_string(),
442 fee_currency: fill.fee.asset.0.clone(),
443 liquidity: "UNKNOWN".to_string(), ts_event: fill.ts,
445 }
446 }
447
448 pub async fn shutdown_sync(
450 &mut self,
451 current_price: Option<Decimal>,
452 stop_reason: &str,
453 ) -> Result<SyncResult, SyncError> {
454 tracing::info!(
455 "[TradeSyncer] Performing shutdown sync with reason: {}, price: {:?}",
456 stop_reason,
457 current_price
458 );
459 self.sync(current_price, true, stop_reason).await
460 }
461}
462
463#[async_trait::async_trait]
465impl crate::sync_traits::TradeSync for TradeSyncer {
466 fn add_fill(&mut self, fill: Fill) {
467 TradeSyncer::add_fill(self, fill)
468 }
469
470 fn should_sync(&self) -> bool {
471 TradeSyncer::should_sync(self)
472 }
473
474 fn pending_count(&self) -> usize {
475 TradeSyncer::pending_count(self)
476 }
477
478 fn last_pnl(&self) -> Option<f64> {
479 TradeSyncer::last_pnl(self)
480 }
481
482 fn set_metrics_snapshot(
483 &mut self,
484 snapshot: Option<crate::performance_metrics::PerformanceMetricsSnapshot>,
485 ) {
486 TradeSyncer::set_metrics_snapshot(self, snapshot)
487 }
488
489 async fn sync(
490 &mut self,
491 current_price: Option<Decimal>,
492 stop_bot: bool,
493 stop_reason: &str,
494 ) -> Result<crate::sync_traits::TradeSyncResult, SyncError> {
495 let result = TradeSyncer::sync(self, current_price, stop_bot, stop_reason).await?;
496 Ok(crate::sync_traits::TradeSyncResult {
497 success: result.success,
498 pnl: result.pnl,
499 })
500 }
501
502 async fn shutdown_sync(
503 &mut self,
504 current_price: Option<Decimal>,
505 stop_reason: &str,
506 ) -> Result<crate::sync_traits::TradeSyncResult, SyncError> {
507 let result = TradeSyncer::shutdown_sync(self, current_price, stop_reason).await?;
508 Ok(crate::sync_traits::TradeSyncResult {
509 success: result.success,
510 pnl: result.pnl,
511 })
512 }
513}
514
515#[cfg(test)]
516
517mod tests {
518 use super::*;
519 use crate::performance_metrics::{
520 PerformanceBenchmark, PerformanceMetrics, PerformanceMetricsSnapshot,
521 };
522 use bot_core::{AssetId, Fee, OrderSide, Price, Qty, TradeId};
523
524 fn make_metrics_snapshot() -> PerformanceMetricsSnapshot {
525 PerformanceMetricsSnapshot {
526 schema_version: 1,
527 mode: "backtest".to_string(),
528 scope: "backtest_window".to_string(),
529 run_started_at_ms: None,
530 metrics: PerformanceMetrics {
531 period_return_pct: Some(1.0),
532 apr_pct: Some(365.0),
533 sharpe: Some(1.5),
534 max_drawdown_pct: Some(0.5),
535 max_drawdown_usdc: "5".to_string(),
536 win_rate_pct: Some(100.0),
537 closed_trade_count: 1,
538 winning_trade_count: 1,
539 losing_trade_count: 0,
540 fill_count: 2,
541 total_fees: "0.2".to_string(),
542 total_volume: "200".to_string(),
543 net_pnl: "10".to_string(),
544 fee_drag_pct: Some(0.02),
545 },
546 benchmark: PerformanceBenchmark {
547 start_ts_ms: Some(1),
548 end_ts_ms: Some(2),
549 duration_ms: Some(1),
550 quote_count: 2,
551 starting_balance_usdc: Some("1000".to_string()),
552 ending_balance_usdc: Some("1010".to_string()),
553 instrument: Some("BTC-PERP".to_string()),
554 },
555 latest_equity: None,
556 }
557 }
558
559 fn make_test_fill(trade_id: &str, instrument: &str) -> Fill {
560 Fill {
562 trade_id: TradeId::new(trade_id),
563 client_id: None,
564 exchange_order_id: None,
565 instrument: InstrumentId::new(instrument),
566 side: OrderSide::Buy,
567 price: Price::new(Decimal::new(100, 0)),
568 qty: Qty::new(Decimal::new(1, 0)),
569 fee: Fee::new(Decimal::new(1, 2), AssetId::new("USDC")),
570 ts: now_ms() + 1000, }
572 }
573
574 fn make_test_fill_with_ts(trade_id: &str, instrument: &str, ts: i64) -> Fill {
575 Fill {
576 trade_id: TradeId::new(trade_id),
577 client_id: None,
578 exchange_order_id: None,
579 instrument: InstrumentId::new(instrument),
580 side: OrderSide::Buy,
581 price: Price::new(Decimal::new(100, 0)),
582 qty: Qty::new(Decimal::new(1, 0)),
583 fee: Fee::new(Decimal::new(1, 2), AssetId::new("USDC")),
584 ts,
585 }
586 }
587
588 #[test]
589 fn test_config_validation() {
590 let config = TradeSyncerConfig {
592 bot_id: String::new(),
593 upstream_url: "http://test.com".to_string(),
594 ..Default::default()
595 };
596 assert!(TradeSyncer::new(config).is_err());
597
598 let config = TradeSyncerConfig {
600 bot_id: "test-bot".to_string(),
601 upstream_url: String::new(),
602 ..Default::default()
603 };
604 assert!(TradeSyncer::new(config).is_err());
605
606 let config = TradeSyncerConfig {
608 bot_id: "test-bot".to_string(),
609 upstream_url: "http://test.com".to_string(),
610 ..Default::default()
611 };
612 assert!(TradeSyncer::new(config).is_ok());
613 }
614
615 #[test]
616 fn test_add_fill_deduplication() {
617 let config = TradeSyncerConfig {
618 bot_id: "test-bot".to_string(),
619 upstream_url: "http://test.com".to_string(),
620 ..Default::default()
621 };
622 let mut syncer = TradeSyncer::new(config).unwrap();
623
624 let fill = make_test_fill("trade-1", "BTC-PERP");
626 syncer.add_fill(fill.clone());
627 syncer.add_fill(fill);
628
629 assert_eq!(syncer.pending_count(), 2);
631
632 syncer.synced_trade_ids.insert("trade-1".to_string());
634
635 syncer.pending_fills.clear();
637
638 let fill = make_test_fill("trade-1", "BTC-PERP");
640 syncer.add_fill(fill);
641 assert_eq!(syncer.pending_count(), 0);
642 }
643
644 #[test]
645 fn test_start_timestamp_filter() {
646 let config = TradeSyncerConfig {
647 bot_id: "test-bot".to_string(),
648 upstream_url: "http://test.com".to_string(),
649 ..Default::default()
650 };
651 let mut syncer = TradeSyncer::new(config).unwrap();
652
653 let old_fill =
655 make_test_fill_with_ts("trade-old", "BTC-PERP", syncer.start_timestamp - 1000);
656 syncer.add_fill(old_fill);
657 assert_eq!(
658 syncer.pending_count(),
659 0,
660 "Historical fill should be filtered"
661 );
662
663 let new_fill =
665 make_test_fill_with_ts("trade-new", "BTC-PERP", syncer.start_timestamp + 1000);
666 syncer.add_fill(new_fill);
667 assert_eq!(syncer.pending_count(), 1, "New fill should be accepted");
668 }
669
670 #[test]
671 fn test_instrument_filter() {
672 let config = TradeSyncerConfig {
674 bot_id: "test-bot".to_string(),
675 upstream_url: "http://test.com".to_string(),
676 instruments: vec![InstrumentId::new("BTC-PERP")],
677 ..Default::default()
678 };
679 let mut syncer = TradeSyncer::new(config).unwrap();
680
681 syncer.add_fill(make_test_fill("trade-1", "BTC-PERP"));
683 assert_eq!(syncer.pending_count(), 1);
684
685 syncer.add_fill(make_test_fill("trade-2", "ETH-PERP"));
687 assert_eq!(syncer.pending_count(), 1); }
689
690 #[test]
691 fn test_multi_instrument_filter() {
692 let config = TradeSyncerConfig {
694 bot_id: "test-bot".to_string(),
695 upstream_url: "http://test.com".to_string(),
696 instruments: vec![
697 InstrumentId::new("UBTC-SPOT"),
698 InstrumentId::new("BTC-PERP"),
699 ],
700 ..Default::default()
701 };
702 let mut syncer = TradeSyncer::new(config).unwrap();
703
704 syncer.add_fill(make_test_fill("trade-1", "UBTC-SPOT"));
706 syncer.add_fill(make_test_fill("trade-2", "BTC-PERP"));
707 assert_eq!(syncer.pending_count(), 2);
708
709 syncer.add_fill(make_test_fill("trade-3", "ETH-PERP"));
711 assert_eq!(syncer.pending_count(), 2); }
713
714 #[test]
715 fn test_fill_to_trade_conversion() {
716 let config = TradeSyncerConfig {
717 bot_id: "test-bot".to_string(),
718 upstream_url: "http://test.com".to_string(),
719 ..Default::default()
720 };
721 let syncer = TradeSyncer::new(config).unwrap();
722
723 let fill = Fill {
724 trade_id: TradeId::new("trade-123"),
725 client_id: Some(bot_core::ClientOrderId::new("client-456")),
726 exchange_order_id: Some(bot_core::ExchangeOrderId::new("exchange-789")),
727 instrument: InstrumentId::new("BTC-PERP"),
728 side: OrderSide::Sell,
729 price: Price::new(Decimal::new(50000, 0)),
730 qty: Qty::new(Decimal::new(5, 1)), fee: Fee::new(Decimal::new(25, 2), AssetId::new("USDC")), ts: 1700000000000,
733 };
734
735 let trade = syncer.fill_to_trade(&fill);
736
737 assert_eq!(trade.trade_id, "trade-123");
738 assert_eq!(trade.client_order_id, "client-456");
739 assert_eq!(trade.venue_order_id, "exchange-789");
740 assert_eq!(trade.instrument_id, "BTC-PERP");
741 assert_eq!(trade.side, "SELL");
742 assert_eq!(trade.price, "50000");
743 assert_eq!(trade.qty, "0.5");
744 assert!(
746 trade.quote_notional.starts_with("25000"),
747 "Expected quote_notional to start with 25000, got: {}",
748 trade.quote_notional
749 );
750 assert_eq!(trade.fee, "0.25");
751 assert_eq!(trade.fee_currency, "USDC");
752 assert_eq!(trade.ts_event, 1700000000000);
753 }
754
755 #[test]
756 fn test_metadata_payload_includes_performance_metrics() {
757 let config = TradeSyncerConfig {
758 bot_id: "test-bot".to_string(),
759 upstream_url: "http://test.com".to_string(),
760 strategy_type: Some("orchestrator".to_string()),
761 ..Default::default()
762 };
763 let mut syncer = TradeSyncer::new(config).unwrap();
764 syncer.set_metrics_snapshot(Some(make_metrics_snapshot()));
765
766 let metadata = syncer.metadata_payload().expect("metadata");
767 assert_eq!(metadata["strategy_type"], serde_json::json!("orchestrator"));
768 assert_eq!(
769 metadata["performance_metrics"]["metrics"]["net_pnl"],
770 serde_json::json!("10")
771 );
772 assert_eq!(
773 metadata["performance_metrics"]["mode"],
774 serde_json::json!("backtest")
775 );
776 }
777
778 #[test]
779 fn test_metadata_payload_includes_strategy_type_without_metrics() {
780 let config = TradeSyncerConfig {
781 bot_id: "test-bot".to_string(),
782 upstream_url: "http://test.com".to_string(),
783 strategy_type: Some("grid".to_string()),
784 ..Default::default()
785 };
786 let syncer = TradeSyncer::new(config).unwrap();
787
788 let metadata = syncer.metadata_payload().expect("metadata");
789 assert_eq!(metadata["strategy_type"], serde_json::json!("grid"));
790 assert!(metadata.get("performance_metrics").is_none());
791 }
792
793 #[test]
794 fn test_metadata_payload_omitted_without_strategy_type_or_metrics() {
795 let config = TradeSyncerConfig {
796 bot_id: "test-bot".to_string(),
797 upstream_url: "http://test.com".to_string(),
798 ..Default::default()
799 };
800 let syncer = TradeSyncer::new(config).unwrap();
801
802 assert!(syncer.metadata_payload().is_none());
803 }
804}