1use bot_core::{now_ms, AccountState};
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11use crate::performance_metrics::PerformanceMetricsSnapshot;
12
13#[derive(Debug, Clone)]
15pub struct AccountSyncerConfig {
16 pub bot_id: String,
18 pub upstream_url: String,
20 pub sync_interval_ms: u64,
22 pub timeout_secs: u64,
24 pub max_retries: u32,
26 pub retry_delay_ms: u64,
28 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#[derive(Debug, Clone, Serialize)]
48pub struct ClearingHouseStateRequest {
49 pub account_value: String,
51 pub unrealized_pnl: String,
53 pub positions: Vec<PositionInfo>,
55 pub ts: i64,
57 pub stop_bot: bool,
59 pub stop_reason: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub metadata: Option<serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct PositionInfo {
69 pub instrument_id: String,
71 pub qty: String,
73 pub entry_px: String,
75 pub unrealized_pnl: String,
77}
78
79#[derive(Debug, Clone, Deserialize)]
81pub struct SyncResponse {
82 pub pnl: f64,
84}
85
86#[derive(Debug, thiserror::Error)]
88pub enum SyncError {
89 #[error("HTTP request failed: {0}")]
91 Http(String),
92
93 #[error("Network error: {0}")]
95 Network(String),
96
97 #[error("Parse error: {0}")]
99 Parse(String),
100
101 #[error("API error: status={status}, body={body}")]
103 Api {
104 status: u16,
106 body: String,
108 },
109
110 #[error("Configuration error: {0}")]
112 Config(String),
113
114 #[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#[derive(Debug, Clone)]
133pub struct SyncResult {
134 pub success: bool,
136 pub pnl: Option<f64>,
138}
139
140pub struct AccountSyncer {
142 config: AccountSyncerConfig,
143 client: Client,
144 last_sync_ts: i64,
146 last_pnl: Option<f64>,
148 metrics_snapshot: Option<PerformanceMetricsSnapshot>,
150}
151
152impl AccountSyncer {
153 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 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 pub fn last_pnl(&self) -> Option<f64> {
186 self.last_pnl
187 }
188
189 pub fn set_metrics_snapshot(&mut self, snapshot: Option<PerformanceMetricsSnapshot>) {
191 self.metrics_snapshot = snapshot;
192 }
193
194 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 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, 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 let response = self.execute_with_retry(&request).await?;
247
248 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 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; }
306 }
307 }
308 }
309
310 Err(last_error.unwrap_or(SyncError::MaxRetries))
311 }
312
313 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 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#[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 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 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 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}