use bot_core::{now_ms, AccountState};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::performance_metrics::PerformanceMetricsSnapshot;
#[derive(Debug, Clone)]
pub struct AccountSyncerConfig {
pub bot_id: String,
pub upstream_url: String,
pub sync_interval_ms: u64,
pub timeout_secs: u64,
pub max_retries: u32,
pub retry_delay_ms: u64,
pub sync_secret: Option<String>,
}
impl Default for AccountSyncerConfig {
fn default() -> Self {
Self {
bot_id: String::new(),
upstream_url: String::new(),
sync_interval_ms: 10_000,
timeout_secs: 10,
max_retries: 3,
retry_delay_ms: 1000,
sync_secret: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ClearingHouseStateRequest {
pub account_value: String,
pub unrealized_pnl: String,
pub positions: Vec<PositionInfo>,
pub ts: i64,
pub stop_bot: bool,
pub stop_reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PositionInfo {
pub instrument_id: String,
pub qty: String,
pub entry_px: String,
pub unrealized_pnl: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SyncResponse {
pub pnl: f64,
}
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error("HTTP request failed: {0}")]
Http(String),
#[error("Network error: {0}")]
Network(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("API error: status={status}, body={body}")]
Api {
status: u16,
body: String,
},
#[error("Configuration error: {0}")]
Config(String),
#[error("Max retries exceeded")]
MaxRetries,
}
impl From<reqwest::Error> for SyncError {
fn from(e: reqwest::Error) -> Self {
if e.is_timeout() {
SyncError::Network("Request timeout".to_string())
} else if e.is_connect() {
SyncError::Network(format!("Connection failed: {}", e))
} else {
SyncError::Http(e.to_string())
}
}
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub success: bool,
pub pnl: Option<f64>,
}
pub struct AccountSyncer {
config: AccountSyncerConfig,
client: Client,
last_sync_ts: i64,
last_pnl: Option<f64>,
metrics_snapshot: Option<PerformanceMetricsSnapshot>,
}
impl AccountSyncer {
pub fn new(config: AccountSyncerConfig) -> Result<Self, SyncError> {
if config.bot_id.is_empty() {
return Err(SyncError::Config("bot_id is required".to_string()));
}
if config.upstream_url.is_empty() {
return Err(SyncError::Config("upstream_url is required".to_string()));
}
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| SyncError::Http(e.to_string()))?;
tracing::info!("[AccountSyncer] Initialized for bot_id={}", config.bot_id);
Ok(Self {
config,
client,
last_sync_ts: 0,
last_pnl: None,
metrics_snapshot: None,
})
}
pub fn should_sync(&self) -> bool {
let now = now_ms();
now - self.last_sync_ts >= self.config.sync_interval_ms as i64
}
pub fn last_pnl(&self) -> Option<f64> {
self.last_pnl
}
pub fn set_metrics_snapshot(&mut self, snapshot: Option<PerformanceMetricsSnapshot>) {
self.metrics_snapshot = snapshot;
}
pub async fn sync(
&mut self,
account_state: &AccountState,
stop_bot: bool,
stop_reason: &str,
) -> Result<SyncResult, SyncError> {
let now = now_ms();
let positions: Vec<PositionInfo> = account_state
.positions
.iter()
.map(|p| PositionInfo {
instrument_id: p.instrument.0.clone(),
qty: p.qty.to_string(),
entry_px: p
.avg_entry_px
.map(|px| px.0.to_string())
.unwrap_or_else(|| "0".to_string()),
unrealized_pnl: p
.unrealized_pnl
.map(|pnl| pnl.to_string())
.unwrap_or_else(|| "0".to_string()),
})
.collect();
let request = ClearingHouseStateRequest {
account_value: account_state
.account_value
.map(|v| v.to_string())
.unwrap_or_else(|| "0".to_string()),
unrealized_pnl: account_state
.unrealized_pnl
.map(|pnl| pnl.to_string())
.unwrap_or_else(|| "0".to_string()),
positions,
ts: now / 1000, stop_bot,
stop_reason: stop_reason.to_string(),
metadata: self.metadata_payload(),
};
tracing::info!(
"[AccountSyncer] Syncing account state: positions={}, pnl={:?}",
account_state.positions.len(),
account_state.unrealized_pnl
);
let response = self.execute_with_retry(&request).await?;
self.last_sync_ts = now;
self.last_pnl = Some(response.pnl);
tracing::info!("[AccountSyncer] Sync successful: pnl={:.4}", response.pnl);
Ok(SyncResult {
success: true,
pnl: Some(response.pnl),
})
}
fn metadata_payload(&self) -> Option<serde_json::Value> {
self.metrics_snapshot.as_ref().map(|snapshot| {
serde_json::json!({
"performance_metrics": snapshot
})
})
}
async fn execute_with_retry(
&self,
request: &ClearingHouseStateRequest,
) -> Result<SyncResponse, SyncError> {
let url = format!(
"{}/sync-clearingHouseState/{}",
self.config.upstream_url.trim_end_matches('/'),
self.config.bot_id
);
let mut last_error: Option<SyncError> = None;
let mut delay_ms = self.config.retry_delay_ms;
for attempt in 1..=self.config.max_retries {
tracing::debug!(
"[AccountSyncer] Sync attempt {}/{} to {}",
attempt,
self.config.max_retries,
url
);
match self.execute_request(&url, request).await {
Ok(response) => return Ok(response),
Err(e) => {
tracing::warn!(
"[AccountSyncer] Sync attempt {}/{} failed: {}",
attempt,
self.config.max_retries,
e
);
last_error = Some(e);
if attempt < self.config.max_retries {
tracing::debug!("[AccountSyncer] Retrying in {}ms...", delay_ms);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
delay_ms *= 2; }
}
}
}
Err(last_error.unwrap_or(SyncError::MaxRetries))
}
async fn execute_request(
&self,
url: &str,
request: &ClearingHouseStateRequest,
) -> Result<SyncResponse, SyncError> {
let mut request_builder = self
.client
.post(url)
.header("Content-Type", "application/json");
if let Some(secret) = self.config.sync_secret.as_deref().filter(|s| !s.is_empty()) {
request_builder = request_builder.header("x-bot-sync-secret", secret);
}
let response = request_builder.json(request).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SyncError::Api {
status: status.as_u16(),
body,
});
}
let sync_response: SyncResponse = response
.json()
.await
.map_err(|e| SyncError::Parse(e.to_string()))?;
Ok(sync_response)
}
pub async fn shutdown_sync(
&mut self,
account_state: &AccountState,
stop_reason: &str,
) -> Result<SyncResult, SyncError> {
tracing::info!(
"[AccountSyncer] Performing shutdown sync with reason: {}",
stop_reason
);
self.sync(account_state, true, stop_reason).await
}
}
#[async_trait::async_trait]
impl crate::sync_traits::AccountSync for AccountSyncer {
fn should_sync(&self) -> bool {
AccountSyncer::should_sync(self)
}
fn last_pnl(&self) -> Option<f64> {
AccountSyncer::last_pnl(self)
}
fn set_metrics_snapshot(
&mut self,
snapshot: Option<crate::performance_metrics::PerformanceMetricsSnapshot>,
) {
AccountSyncer::set_metrics_snapshot(self, snapshot)
}
async fn sync(
&mut self,
account_state: &AccountState,
stop_bot: bool,
stop_reason: &str,
) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
let result = AccountSyncer::sync(self, account_state, stop_bot, stop_reason).await?;
Ok(crate::sync_traits::AccountSyncResult {
success: result.success,
pnl: result.pnl,
})
}
async fn shutdown_sync(
&mut self,
account_state: &AccountState,
stop_reason: &str,
) -> Result<crate::sync_traits::AccountSyncResult, SyncError> {
let result = AccountSyncer::shutdown_sync(self, account_state, stop_reason).await?;
Ok(crate::sync_traits::AccountSyncResult {
success: result.success,
pnl: result.pnl,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::performance_metrics::{
PerformanceBenchmark, PerformanceMetrics, PerformanceMetricsSnapshot,
};
fn make_metrics_snapshot() -> PerformanceMetricsSnapshot {
PerformanceMetricsSnapshot {
schema_version: 1,
mode: "paper".to_string(),
scope: "current_run".to_string(),
run_started_at_ms: Some(1),
metrics: PerformanceMetrics {
period_return_pct: Some(1.0),
apr_pct: Some(365.0),
sharpe: Some(1.5),
max_drawdown_pct: Some(0.5),
max_drawdown_usdc: "5".to_string(),
win_rate_pct: Some(100.0),
closed_trade_count: 1,
winning_trade_count: 1,
losing_trade_count: 0,
fill_count: 2,
total_fees: "0.2".to_string(),
total_volume: "200".to_string(),
net_pnl: "10".to_string(),
fee_drag_pct: Some(0.02),
},
benchmark: PerformanceBenchmark {
start_ts_ms: Some(1),
end_ts_ms: Some(2),
duration_ms: Some(1),
quote_count: 2,
starting_balance_usdc: Some("1000".to_string()),
ending_balance_usdc: Some("1010".to_string()),
instrument: Some("BTC-PERP".to_string()),
},
latest_equity: None,
}
}
#[test]
fn test_config_validation() {
let config = AccountSyncerConfig {
bot_id: String::new(),
upstream_url: "http://test.com".to_string(),
..Default::default()
};
assert!(AccountSyncer::new(config).is_err());
let config = AccountSyncerConfig {
bot_id: "test-bot".to_string(),
upstream_url: String::new(),
..Default::default()
};
assert!(AccountSyncer::new(config).is_err());
let config = AccountSyncerConfig {
bot_id: "test-bot".to_string(),
upstream_url: "http://test.com".to_string(),
..Default::default()
};
assert!(AccountSyncer::new(config).is_ok());
}
#[test]
fn test_metadata_payload_includes_performance_metrics() {
let config = AccountSyncerConfig {
bot_id: "test-bot".to_string(),
upstream_url: "http://test.com".to_string(),
..Default::default()
};
let mut syncer = AccountSyncer::new(config).unwrap();
syncer.set_metrics_snapshot(Some(make_metrics_snapshot()));
let metadata = syncer.metadata_payload().expect("metadata");
assert_eq!(
metadata["performance_metrics"]["metrics"]["net_pnl"],
serde_json::json!("10")
);
assert_eq!(
metadata["performance_metrics"]["mode"],
serde_json::json!("paper")
);
}
}