Skip to main content

bot_engine/
trade_syncer.rs

1//! Trade syncer: syncs fills to upstream API for PnL tracking.
2//!
3//! This module provides async HTTP syncing of fills to an external API
4//! (like the AlgoBot API) for centralized PnL calculation and persistence.
5
6use 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;
14// Re-use SyncError from account_syncer to avoid duplication
15pub use crate::account_syncer::SyncError;
16
17/// Configuration for trade syncing
18#[derive(Debug, Clone)]
19pub struct TradeSyncerConfig {
20    /// Bot ID for upstream API
21    pub bot_id: String,
22    /// Upstream API base URL (e.g., `<https://api.example.com/bot-api>`)
23    pub upstream_url: String,
24    /// Sync interval in milliseconds (default: 10000)
25    pub sync_interval_ms: u64,
26    /// HTTP timeout in seconds
27    pub timeout_secs: u64,
28    /// Maximum retries for failed syncs
29    pub max_retries: u32,
30    /// Initial retry delay in milliseconds
31    pub retry_delay_ms: u64,
32    /// Instruments to filter fills (only sync fills for these instruments)
33    /// Empty = sync all fills (no filter)
34    pub instruments: Vec<InstrumentId>,
35    /// Optional strategy type hint for upstream routing/aggregation.
36    pub strategy_type: Option<String>,
37    /// Optional shared secret for upstream sync auth.
38    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/// Trade format for upstream API (matches botRoutes.py Trade schema)
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UpstreamTrade {
60    /// Stable trade ID.
61    pub trade_id: String,
62    /// Client order ID.
63    pub client_order_id: String,
64    /// Venue/exchange order ID.
65    pub venue_order_id: String,
66    /// Instrument ID.
67    pub instrument_id: String,
68    /// Fill side.
69    pub side: String,
70    /// Order type label.
71    pub order_type: String,
72    /// Filled quantity string.
73    pub qty: String,
74    /// Fill price string.
75    pub price: String,
76    /// Quote notional string.
77    pub quote_notional: String,
78    /// Fee amount string.
79    pub fee: String,
80    /// Fee currency.
81    pub fee_currency: String,
82    /// Liquidity role.
83    pub liquidity: String,
84    /// Event timestamp in milliseconds.
85    pub ts_event: i64,
86}
87
88/// Request payload for sync API
89#[derive(Debug, Clone, Serialize)]
90pub struct SyncRequest {
91    /// Trades to sync.
92    pub trades: Vec<UpstreamTrade>,
93    /// Request timestamp in milliseconds.
94    pub ts: i64,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    /// Current price, if known.
97    pub current_price: Option<String>,
98    /// Whether this request marks bot shutdown.
99    pub stop_bot: bool,
100    /// Shutdown reason when `stop_bot` is true.
101    pub stop_reason: String,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    /// Optional metrics or strategy metadata.
104    pub metadata: Option<serde_json::Value>,
105}
106
107/// Response from sync API
108#[derive(Debug, Clone, Deserialize)]
109pub struct SyncResponse {
110    /// Last synced trade info.
111    pub synced: SyncedInfo,
112    #[serde(default)]
113    /// Upstream-calculated PnL.
114    pub pnl: f64,
115}
116
117/// Info about last synced trade
118#[derive(Debug, Clone, Deserialize)]
119pub struct SyncedInfo {
120    /// Last synced trade ID.
121    pub trade_id: String,
122    /// Last synced timestamp.
123    pub ts: i64,
124}
125
126/// Response from sync API
127#[derive(Debug, Clone)]
128pub struct SyncResult {
129    /// Whether the sync succeeded.
130    pub success: bool,
131    /// Upstream-calculated PnL, if returned.
132    pub pnl: Option<f64>,
133    /// Last synced trade ID.
134    pub last_synced_trade_id: Option<String>,
135    /// Number of trades synced.
136    pub trades_synced: usize,
137}
138
139/// Trade syncer - handles syncing fills to upstream API
140pub struct TradeSyncer {
141    config: TradeSyncerConfig,
142    client: Client,
143    /// Trade IDs that have been successfully synced
144    synced_trade_ids: HashSet<String>,
145    /// Accumulated fills waiting to be synced
146    pending_fills: Vec<Fill>,
147    /// Last successful sync timestamp
148    last_sync_ts: i64,
149    /// Last sync PnL
150    last_pnl: Option<f64>,
151    /// Timestamp when the syncer was created (only sync fills after this time)
152    start_timestamp: i64,
153    /// Latest performance metrics to include in upstream metadata
154    metrics_snapshot: Option<PerformanceMetricsSnapshot>,
155}
156
157impl TradeSyncer {
158    /// Create a new trade syncer with the given configuration
159    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    /// Add a fill to the pending queue (will be synced on next sync call)
191    pub fn add_fill(&mut self, fill: Fill) {
192        // Skip fills that happened before the syncer started (historical fills)
193        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        // Skip if already synced
204        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        // Filter by instruments if configured
210        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    /// Check if it's time to sync (based on interval)
230    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    /// Get the last known PnL
236    pub fn last_pnl(&self) -> Option<f64> {
237        self.last_pnl
238    }
239
240    /// Get the number of pending fills
241    pub fn pending_count(&self) -> usize {
242        self.pending_fills.len()
243    }
244
245    /// Set the latest performance metrics snapshot for sync metadata.
246    pub fn set_metrics_snapshot(&mut self, snapshot: Option<PerformanceMetricsSnapshot>) {
247        self.metrics_snapshot = snapshot;
248    }
249
250    /// Sync pending fills to upstream API
251    ///
252    /// Returns the PnL from the upstream API on success
253    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        // Convert pending fills to upstream format
262        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, // seconds
281            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        // Execute with retry
288        let response = self.execute_with_retry(&request).await?;
289
290        // Mark all trades as synced
291        for fill in &self.pending_fills {
292            self.synced_trade_ids.insert(fill.trade_id.0.clone());
293        }
294
295        // Clear pending fills
296        self.pending_fills.clear();
297
298        // Update state
299        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    /// Execute sync request with retry logic
342    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; // exponential backoff
375                    }
376                }
377            }
378        }
379
380        Err(last_error.unwrap_or(SyncError::MaxRetries))
381    }
382
383    /// Execute a single sync request
384    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    /// Convert a Fill to upstream trade format
416    fn fill_to_trade(&self, fill: &Fill) -> UpstreamTrade {
417        // Determine side string
418        let side = format!("{}", fill.side);
419
420        // Calculate quote notional
421        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(), // Fill doesn't have maker/taker info
444            ts_event: fill.ts,
445        }
446    }
447
448    /// Perform final sync on shutdown (with stop_bot=true)
449    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// Implement TradeSync trait for drop-in substitution with MockTradeSyncer
464#[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        // Use a timestamp in the future to ensure it passes the start_timestamp check
561        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, // Future timestamp to pass start_timestamp filter
571        }
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        // Empty bot_id should fail
591        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        // Empty upstream_url should fail
599        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        // Valid config should succeed
607        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        // Add same fill twice (with future timestamp to pass start_timestamp filter)
625        let fill = make_test_fill("trade-1", "BTC-PERP");
626        syncer.add_fill(fill.clone());
627        syncer.add_fill(fill);
628
629        // Should have both since same trade_id isn't marked as synced yet
630        assert_eq!(syncer.pending_count(), 2);
631
632        // Mark as synced
633        syncer.synced_trade_ids.insert("trade-1".to_string());
634
635        // Clear pending
636        syncer.pending_fills.clear();
637
638        // Try to add again - should be skipped (already synced)
639        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        // Add fill with old timestamp (before syncer started) - should be filtered
654        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        // Add fill with new timestamp (after syncer started) - should be accepted
664        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        // Single instrument filter
673        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        // Add fill for correct instrument
682        syncer.add_fill(make_test_fill("trade-1", "BTC-PERP"));
683        assert_eq!(syncer.pending_count(), 1);
684
685        // Add fill for different instrument - should be filtered
686        syncer.add_fill(make_test_fill("trade-2", "ETH-PERP"));
687        assert_eq!(syncer.pending_count(), 1); // Still 1
688    }
689
690    #[test]
691    fn test_multi_instrument_filter() {
692        // Multi-instrument filter (arb: spot + perp)
693        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        // Both instruments should pass
705        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        // Unrelated instrument should be filtered
710        syncer.add_fill(make_test_fill("trade-3", "ETH-PERP"));
711        assert_eq!(syncer.pending_count(), 2); // Still 2
712    }
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)), // 0.5
731            fee: Fee::new(Decimal::new(25, 2), AssetId::new("USDC")), // 0.25
732            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        // quote_notional = 50000 * 0.5 = 25000 (may have trailing decimal depending on Decimal impl)
745        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}