Skip to main content

bot_engine/
account_syncer.rs

1//! Account syncer: syncs account state snapshots to upstream API.
2//!
3//! This module provides async HTTP syncing of clearinghouse state snapshots
4//! to an external API for Arbitrage and other snapshot-based strategies.
5
6use bot_core::{now_ms, AccountState};
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11use crate::performance_metrics::PerformanceMetricsSnapshot;
12
13/// Configuration for account syncing
14#[derive(Debug, Clone)]
15pub struct AccountSyncerConfig {
16    /// Bot ID for upstream API
17    pub bot_id: String,
18    /// Upstream API base URL (e.g., `<https://api.example.com/bot-api>`)
19    pub upstream_url: String,
20    /// Sync interval in milliseconds (default: 10000)
21    pub sync_interval_ms: u64,
22    /// HTTP timeout in seconds
23    pub timeout_secs: u64,
24    /// Maximum retries for failed syncs
25    pub max_retries: u32,
26    /// Initial retry delay in milliseconds
27    pub retry_delay_ms: u64,
28    /// Optional shared secret for upstream sync auth.
29    pub sync_secret: Option<String>,
30}
31
32impl Default for AccountSyncerConfig {
33    fn default() -> Self {
34        Self {
35            bot_id: String::new(),
36            upstream_url: String::new(),
37            sync_interval_ms: 10_000,
38            timeout_secs: 10,
39            max_retries: 3,
40            retry_delay_ms: 1000,
41            sync_secret: None,
42        }
43    }
44}
45
46/// Request payload for clearinghouse state sync API
47#[derive(Debug, Clone, Serialize)]
48pub struct ClearingHouseStateRequest {
49    /// Account value as a decimal string.
50    pub account_value: String,
51    /// Unrealized PnL as a decimal string.
52    pub unrealized_pnl: String,
53    /// Position payloads.
54    pub positions: Vec<PositionInfo>,
55    /// Timestamp in seconds.
56    pub ts: i64,
57    /// Whether this request marks bot shutdown.
58    pub stop_bot: bool,
59    /// Shutdown reason when `stop_bot` is true.
60    pub stop_reason: String,
61    /// Optional metadata for strategy-specific data
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub metadata: Option<serde_json::Value>,
64}
65
66/// Position information for sync
67#[derive(Debug, Clone, Serialize)]
68pub struct PositionInfo {
69    /// Instrument ID.
70    pub instrument_id: String,
71    /// Position quantity string.
72    pub qty: String,
73    /// Entry price string.
74    pub entry_px: String,
75    /// Unrealized PnL string.
76    pub unrealized_pnl: String,
77}
78
79/// Response from clearinghouse state sync API
80#[derive(Debug, Clone, Deserialize)]
81pub struct SyncResponse {
82    /// Upstream-calculated PnL.
83    pub pnl: f64,
84}
85
86/// Error types for account syncing
87#[derive(Debug, thiserror::Error)]
88pub enum SyncError {
89    /// HTTP request failed after construction.
90    #[error("HTTP request failed: {0}")]
91    Http(String),
92
93    /// Network transport failure.
94    #[error("Network error: {0}")]
95    Network(String),
96
97    /// Response parsing failed.
98    #[error("Parse error: {0}")]
99    Parse(String),
100
101    /// Upstream API returned a non-success status.
102    #[error("API error: status={status}, body={body}")]
103    Api {
104        /// HTTP status code.
105        status: u16,
106        /// Response body.
107        body: String,
108    },
109
110    /// Invalid syncer configuration.
111    #[error("Configuration error: {0}")]
112    Config(String),
113
114    /// Retry policy was exhausted.
115    #[error("Max retries exceeded")]
116    MaxRetries,
117}
118
119impl From<reqwest::Error> for SyncError {
120    fn from(e: reqwest::Error) -> Self {
121        if e.is_timeout() {
122            SyncError::Network("Request timeout".to_string())
123        } else if e.is_connect() {
124            SyncError::Network(format!("Connection failed: {}", e))
125        } else {
126            SyncError::Http(e.to_string())
127        }
128    }
129}
130
131/// Result of a sync operation
132#[derive(Debug, Clone)]
133pub struct SyncResult {
134    /// Whether the upstream sync succeeded.
135    pub success: bool,
136    /// Upstream-calculated PnL, if returned.
137    pub pnl: Option<f64>,
138}
139
140/// Account syncer - handles syncing clearinghouse state to upstream API
141pub struct AccountSyncer {
142    config: AccountSyncerConfig,
143    client: Client,
144    /// Last successful sync timestamp
145    last_sync_ts: i64,
146    /// Last sync PnL
147    last_pnl: Option<f64>,
148    /// Latest performance metrics to include in upstream metadata
149    metrics_snapshot: Option<PerformanceMetricsSnapshot>,
150}
151
152impl AccountSyncer {
153    /// Create a new account syncer with the given configuration
154    pub fn new(config: AccountSyncerConfig) -> Result<Self, SyncError> {
155        if config.bot_id.is_empty() {
156            return Err(SyncError::Config("bot_id is required".to_string()));
157        }
158        if config.upstream_url.is_empty() {
159            return Err(SyncError::Config("upstream_url is required".to_string()));
160        }
161
162        let client = Client::builder()
163            .timeout(Duration::from_secs(config.timeout_secs))
164            .build()
165            .map_err(|e| SyncError::Http(e.to_string()))?;
166
167        tracing::info!("[AccountSyncer] Initialized for bot_id={}", config.bot_id);
168
169        Ok(Self {
170            config,
171            client,
172            last_sync_ts: 0,
173            last_pnl: None,
174            metrics_snapshot: None,
175        })
176    }
177
178    /// Check if it's time to sync (based on interval)
179    pub fn should_sync(&self) -> bool {
180        let now = now_ms();
181        now - self.last_sync_ts >= self.config.sync_interval_ms as i64
182    }
183
184    /// Get the last known PnL
185    pub fn last_pnl(&self) -> Option<f64> {
186        self.last_pnl
187    }
188
189    /// Set the latest performance metrics snapshot for sync metadata.
190    pub fn set_metrics_snapshot(&mut self, snapshot: Option<PerformanceMetricsSnapshot>) {
191        self.metrics_snapshot = snapshot;
192    }
193
194    /// Sync account state to upstream API
195    ///
196    /// Returns the PnL from the upstream API on success
197    pub async fn sync(
198        &mut self,
199        account_state: &AccountState,
200        stop_bot: bool,
201        stop_reason: &str,
202    ) -> Result<SyncResult, SyncError> {
203        let now = now_ms();
204
205        // Convert AccountState to request format
206        let positions: Vec<PositionInfo> = account_state
207            .positions
208            .iter()
209            .map(|p| PositionInfo {
210                instrument_id: p.instrument.0.clone(),
211                qty: p.qty.to_string(),
212                entry_px: p
213                    .avg_entry_px
214                    .map(|px| px.0.to_string())
215                    .unwrap_or_else(|| "0".to_string()),
216                unrealized_pnl: p
217                    .unrealized_pnl
218                    .map(|pnl| pnl.to_string())
219                    .unwrap_or_else(|| "0".to_string()),
220            })
221            .collect();
222
223        let request = ClearingHouseStateRequest {
224            account_value: account_state
225                .account_value
226                .map(|v| v.to_string())
227                .unwrap_or_else(|| "0".to_string()),
228            unrealized_pnl: account_state
229                .unrealized_pnl
230                .map(|pnl| pnl.to_string())
231                .unwrap_or_else(|| "0".to_string()),
232            positions,
233            ts: now / 1000, // seconds
234            stop_bot,
235            stop_reason: stop_reason.to_string(),
236            metadata: self.metadata_payload(),
237        };
238
239        tracing::info!(
240            "[AccountSyncer] Syncing account state: positions={}, pnl={:?}",
241            account_state.positions.len(),
242            account_state.unrealized_pnl
243        );
244
245        // Execute with retry
246        let response = self.execute_with_retry(&request).await?;
247
248        // Update state
249        self.last_sync_ts = now;
250        self.last_pnl = Some(response.pnl);
251
252        tracing::info!("[AccountSyncer] Sync successful: pnl={:.4}", response.pnl);
253
254        Ok(SyncResult {
255            success: true,
256            pnl: Some(response.pnl),
257        })
258    }
259
260    fn metadata_payload(&self) -> Option<serde_json::Value> {
261        self.metrics_snapshot.as_ref().map(|snapshot| {
262            serde_json::json!({
263                "performance_metrics": snapshot
264            })
265        })
266    }
267
268    /// Execute sync request with retry logic
269    async fn execute_with_retry(
270        &self,
271        request: &ClearingHouseStateRequest,
272    ) -> Result<SyncResponse, SyncError> {
273        let url = format!(
274            "{}/sync-clearingHouseState/{}",
275            self.config.upstream_url.trim_end_matches('/'),
276            self.config.bot_id
277        );
278
279        let mut last_error: Option<SyncError> = None;
280        let mut delay_ms = self.config.retry_delay_ms;
281
282        for attempt in 1..=self.config.max_retries {
283            tracing::debug!(
284                "[AccountSyncer] Sync attempt {}/{} to {}",
285                attempt,
286                self.config.max_retries,
287                url
288            );
289
290            match self.execute_request(&url, request).await {
291                Ok(response) => return Ok(response),
292                Err(e) => {
293                    tracing::warn!(
294                        "[AccountSyncer] Sync attempt {}/{} failed: {}",
295                        attempt,
296                        self.config.max_retries,
297                        e
298                    );
299                    last_error = Some(e);
300
301                    if attempt < self.config.max_retries {
302                        tracing::debug!("[AccountSyncer] Retrying in {}ms...", delay_ms);
303                        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
304                        delay_ms *= 2; // exponential backoff
305                    }
306                }
307            }
308        }
309
310        Err(last_error.unwrap_or(SyncError::MaxRetries))
311    }
312
313    /// Execute a single sync request
314    async fn execute_request(
315        &self,
316        url: &str,
317        request: &ClearingHouseStateRequest,
318    ) -> Result<SyncResponse, SyncError> {
319        let mut request_builder = self
320            .client
321            .post(url)
322            .header("Content-Type", "application/json");
323        if let Some(secret) = self.config.sync_secret.as_deref().filter(|s| !s.is_empty()) {
324            request_builder = request_builder.header("x-bot-sync-secret", secret);
325        }
326        let response = request_builder.json(request).send().await?;
327
328        let status = response.status();
329        if !status.is_success() {
330            let body = response.text().await.unwrap_or_default();
331            return Err(SyncError::Api {
332                status: status.as_u16(),
333                body,
334            });
335        }
336
337        let sync_response: SyncResponse = response
338            .json()
339            .await
340            .map_err(|e| SyncError::Parse(e.to_string()))?;
341
342        Ok(sync_response)
343    }
344
345    /// Perform final sync on shutdown (with stop_bot=true)
346    pub async fn shutdown_sync(
347        &mut self,
348        account_state: &AccountState,
349        stop_reason: &str,
350    ) -> Result<SyncResult, SyncError> {
351        tracing::info!(
352            "[AccountSyncer] Performing shutdown sync with reason: {}",
353            stop_reason
354        );
355        self.sync(account_state, true, stop_reason).await
356    }
357}
358
359// Implement AccountSync trait for drop-in substitution with MockAccountSyncer
360#[async_trait::async_trait]
361impl crate::sync_traits::AccountSync for AccountSyncer {
362    fn should_sync(&self) -> bool {
363        AccountSyncer::should_sync(self)
364    }
365
366    fn last_pnl(&self) -> Option<f64> {
367        AccountSyncer::last_pnl(self)
368    }
369
370    fn set_metrics_snapshot(
371        &mut self,
372        snapshot: Option<crate::performance_metrics::PerformanceMetricsSnapshot>,
373    ) {
374        AccountSyncer::set_metrics_snapshot(self, snapshot)
375    }
376
377    async fn sync(
378        &mut self,
379        account_state: &AccountState,
380        stop_bot: bool,
381        stop_reason: &str,
382    ) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
383        let result = AccountSyncer::sync(self, account_state, stop_bot, stop_reason).await?;
384        Ok(crate::sync_traits::AccountSyncResult {
385            success: result.success,
386            pnl: result.pnl,
387        })
388    }
389
390    async fn shutdown_sync(
391        &mut self,
392        account_state: &AccountState,
393        stop_reason: &str,
394    ) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
395        let result = AccountSyncer::shutdown_sync(self, account_state, stop_reason).await?;
396        Ok(crate::sync_traits::AccountSyncResult {
397            success: result.success,
398            pnl: result.pnl,
399        })
400    }
401}
402
403#[cfg(test)]
404
405mod tests {
406    use super::*;
407    use crate::performance_metrics::{
408        PerformanceBenchmark, PerformanceMetrics, PerformanceMetricsSnapshot,
409    };
410
411    fn make_metrics_snapshot() -> PerformanceMetricsSnapshot {
412        PerformanceMetricsSnapshot {
413            schema_version: 1,
414            mode: "paper".to_string(),
415            scope: "current_run".to_string(),
416            run_started_at_ms: Some(1),
417            metrics: PerformanceMetrics {
418                period_return_pct: Some(1.0),
419                apr_pct: Some(365.0),
420                sharpe: Some(1.5),
421                max_drawdown_pct: Some(0.5),
422                max_drawdown_usdc: "5".to_string(),
423                win_rate_pct: Some(100.0),
424                closed_trade_count: 1,
425                winning_trade_count: 1,
426                losing_trade_count: 0,
427                fill_count: 2,
428                total_fees: "0.2".to_string(),
429                total_volume: "200".to_string(),
430                net_pnl: "10".to_string(),
431                fee_drag_pct: Some(0.02),
432            },
433            benchmark: PerformanceBenchmark {
434                start_ts_ms: Some(1),
435                end_ts_ms: Some(2),
436                duration_ms: Some(1),
437                quote_count: 2,
438                starting_balance_usdc: Some("1000".to_string()),
439                ending_balance_usdc: Some("1010".to_string()),
440                instrument: Some("BTC-PERP".to_string()),
441            },
442            latest_equity: None,
443        }
444    }
445
446    #[test]
447    fn test_config_validation() {
448        // Empty bot_id should fail
449        let config = AccountSyncerConfig {
450            bot_id: String::new(),
451            upstream_url: "http://test.com".to_string(),
452            ..Default::default()
453        };
454        assert!(AccountSyncer::new(config).is_err());
455
456        // Empty upstream_url should fail
457        let config = AccountSyncerConfig {
458            bot_id: "test-bot".to_string(),
459            upstream_url: String::new(),
460            ..Default::default()
461        };
462        assert!(AccountSyncer::new(config).is_err());
463
464        // Valid config should succeed
465        let config = AccountSyncerConfig {
466            bot_id: "test-bot".to_string(),
467            upstream_url: "http://test.com".to_string(),
468            ..Default::default()
469        };
470        assert!(AccountSyncer::new(config).is_ok());
471    }
472
473    #[test]
474    fn test_metadata_payload_includes_performance_metrics() {
475        let config = AccountSyncerConfig {
476            bot_id: "test-bot".to_string(),
477            upstream_url: "http://test.com".to_string(),
478            ..Default::default()
479        };
480        let mut syncer = AccountSyncer::new(config).unwrap();
481        syncer.set_metrics_snapshot(Some(make_metrics_snapshot()));
482
483        let metadata = syncer.metadata_payload().expect("metadata");
484        assert_eq!(
485            metadata["performance_metrics"]["metrics"]["net_pnl"],
486            serde_json::json!("10")
487        );
488        assert_eq!(
489            metadata["performance_metrics"]["mode"],
490            serde_json::json!("paper")
491        );
492    }
493}