chore: init

This commit is contained in:
2026-03-09 10:15:33 +02:00
commit 934566879e
11 changed files with 3509 additions and 0 deletions

249
src/swym.rs Normal file
View 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")
}
}