chore: init
This commit is contained in:
249
src/swym.rs
Normal file
249
src/swym.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Client for the swym backtesting API.
|
||||
pub struct SwymClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaperRunResponse {
|
||||
pub id: Uuid,
|
||||
pub status: String,
|
||||
pub result_summary: Option<Value>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PositionsResponse {
|
||||
pub total: u32,
|
||||
pub positions: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CandleCoverage {
|
||||
pub interval: String,
|
||||
pub first_open: String,
|
||||
pub last_close: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestResult {
|
||||
pub run_id: Uuid,
|
||||
pub instrument: String,
|
||||
pub status: String,
|
||||
pub total_positions: Option<u32>,
|
||||
pub winning_positions: Option<u32>,
|
||||
pub losing_positions: Option<u32>,
|
||||
pub win_rate: Option<f64>,
|
||||
pub profit_factor: Option<f64>,
|
||||
pub total_pnl: Option<f64>,
|
||||
pub net_pnl: Option<f64>,
|
||||
pub sharpe_ratio: Option<f64>,
|
||||
pub total_fees: Option<f64>,
|
||||
pub avg_bars_in_trade: Option<f64>,
|
||||
pub error_message: Option<String>,
|
||||
pub condition_audit_summary: Option<Value>,
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self {
|
||||
let summary = resp.result_summary.as_ref();
|
||||
Self {
|
||||
run_id: resp.id,
|
||||
instrument: instrument.to_string(),
|
||||
status: resp.status.clone(),
|
||||
total_positions: summary.and_then(|s| s["total_positions"].as_u64().map(|v| v as u32)),
|
||||
winning_positions: summary.and_then(|s| s["winning_positions"].as_u64().map(|v| v as u32)),
|
||||
losing_positions: summary.and_then(|s| s["losing_positions"].as_u64().map(|v| v as u32)),
|
||||
win_rate: summary.and_then(|s| s["win_rate"].as_f64()),
|
||||
profit_factor: summary.and_then(|s| s["profit_factor"].as_f64()),
|
||||
total_pnl: summary.and_then(|s| s["total_pnl"].as_f64()),
|
||||
net_pnl: summary.and_then(|s| s["net_pnl"].as_f64()),
|
||||
sharpe_ratio: summary.and_then(|s| s["sharpe_ratio"].as_f64()),
|
||||
total_fees: summary.and_then(|s| s["total_fees"].as_f64()),
|
||||
avg_bars_in_trade: summary.and_then(|s| s["avg_bars_in_trade"].as_f64()),
|
||||
error_message: resp.error_message.clone(),
|
||||
condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary for logging and feeding back to Claude.
|
||||
pub fn summary_line(&self) -> String {
|
||||
if self.status == "failed" {
|
||||
return format!(
|
||||
"[{}] FAILED: {}",
|
||||
self.instrument,
|
||||
self.error_message.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"[{}] trades={} win_rate={:.1}% pf={:.2} net_pnl={:.2} sharpe={:.2} avg_bars={:.1}",
|
||||
self.instrument,
|
||||
self.total_positions.unwrap_or(0),
|
||||
self.win_rate.unwrap_or(0.0) * 100.0,
|
||||
self.profit_factor.unwrap_or(0.0),
|
||||
self.net_pnl.unwrap_or(0.0),
|
||||
self.sharpe_ratio.unwrap_or(0.0),
|
||||
self.avg_bars_in_trade.unwrap_or(0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Is this result promising enough to warrant out-of-sample validation?
|
||||
pub fn is_promising(&self, min_sharpe: f64, min_trades: u32) -> bool {
|
||||
self.status == "complete"
|
||||
&& self.sharpe_ratio.unwrap_or(0.0) > min_sharpe
|
||||
&& self.total_positions.unwrap_or(0) >= min_trades
|
||||
&& self.net_pnl.unwrap_or(0.0) > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SwymClient {
|
||||
pub fn new(base_url: &str) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true) // internal TLS with self-signed certs
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check candle coverage for an instrument.
|
||||
pub async fn candle_coverage(
|
||||
&self,
|
||||
exchange: &str,
|
||||
symbol: &str,
|
||||
) -> Result<Vec<CandleCoverage>> {
|
||||
let url = format!(
|
||||
"{}/market-candles/coverage/{}/{}",
|
||||
self.base_url, exchange, symbol
|
||||
);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("candle coverage request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("candle coverage {status}: {body}");
|
||||
}
|
||||
resp.json().await.context("parse candle coverage")
|
||||
}
|
||||
|
||||
/// Submit a backtest run.
|
||||
pub async fn submit_backtest(
|
||||
&self,
|
||||
instrument_exchange: &str,
|
||||
instrument_symbol: &str,
|
||||
base_asset: &str,
|
||||
quote_asset: &str,
|
||||
strategy: &Value,
|
||||
starts_at: &str,
|
||||
finishes_at: &str,
|
||||
initial_balance: &str,
|
||||
fees_percent: &str,
|
||||
) -> Result<PaperRunResponse> {
|
||||
let body = serde_json::json!({
|
||||
"mode": "backtest",
|
||||
"starts_at": starts_at,
|
||||
"finishes_at": finishes_at,
|
||||
"risk_free_return": "0.05",
|
||||
"config": {
|
||||
"instrument": {
|
||||
"exchange": instrument_exchange,
|
||||
"name_exchange": instrument_symbol,
|
||||
"underlying": { "base": base_asset, "quote": quote_asset },
|
||||
"quote": "underlying_quote",
|
||||
"kind": "spot"
|
||||
},
|
||||
"execution": {
|
||||
"mocked_exchange": instrument_exchange,
|
||||
"latency_ms": 100,
|
||||
"fees_percent": fees_percent,
|
||||
"initial_state": {
|
||||
"exchange": instrument_exchange,
|
||||
"balances": [{
|
||||
"asset": quote_asset,
|
||||
"balance": { "total": initial_balance, "free": initial_balance },
|
||||
"time_exchange": starts_at
|
||||
}],
|
||||
"instrument": {
|
||||
"instrument_name": instrument_symbol,
|
||||
"orders": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"strategy": strategy,
|
||||
"audit_conditions": true
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("{}/paper-runs", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("submit backtest")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("submit backtest {status}: {body_text}");
|
||||
}
|
||||
resp.json().await.context("parse backtest response")
|
||||
}
|
||||
|
||||
/// Poll a run until it reaches a terminal state.
|
||||
pub async fn poll_until_done(
|
||||
&self,
|
||||
run_id: Uuid,
|
||||
poll_interval: std::time::Duration,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<PaperRunResponse> {
|
||||
let url = format!("{}/paper-runs/{}", self.base_url, run_id);
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let resp: PaperRunResponse = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("poll backtest")?
|
||||
.json()
|
||||
.await
|
||||
.context("parse poll response")?;
|
||||
|
||||
match resp.status.as_str() {
|
||||
"complete" | "failed" | "cancelled" => return Ok(resp),
|
||||
_ => {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
anyhow::bail!("backtest {run_id} timed out after {}s", timeout.as_secs());
|
||||
}
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch condition audit summary for a completed run.
|
||||
pub async fn condition_audit(&self, run_id: Uuid) -> Result<Value> {
|
||||
let url = format!("{}/paper-runs/{}/condition-audit", self.base_url, run_id);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("condition audit {}: {}", resp.status(), resp.text().await?);
|
||||
}
|
||||
resp.json().await.context("parse condition audit")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user