use anyhow::{Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; /// Response from `POST /api/v1/strategies/validate`. #[derive(Debug, Deserialize)] pub struct ValidationResponse { pub valid: bool, #[serde(default)] pub errors: Vec, } #[derive(Debug, Deserialize, Clone)] pub struct ValidationError { pub path: String, pub message: String, } /// 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, pub error_message: Option, } #[derive(Debug, Deserialize)] pub struct PositionsResponse { pub total: u32, pub positions: Vec, } #[derive(Debug, Deserialize)] pub struct CandleCoverage { pub interval: String, pub first_open: String, pub last_close: String, pub count: u64, pub expected_count: Option, pub coverage_pct: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BacktestResult { pub run_id: Uuid, pub instrument: String, pub status: String, pub total_positions: Option, pub winning_positions: Option, pub losing_positions: Option, pub win_rate: Option, pub profit_factor: Option, pub total_pnl: Option, pub net_pnl: Option, pub sharpe_ratio: Option, pub total_fees: Option, pub avg_bars_in_trade: Option, pub error_message: Option, pub condition_audit_summary: Option, } impl BacktestResult { /// Parse a backtest response from the flat `result_summary` structure. pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self { let summary = resp.result_summary.as_ref(); if let Some(s) = summary { tracing::debug!("[{instrument}] result_summary: {}", serde_json::to_string(s).unwrap_or_default()); } else { tracing::debug!("[{instrument}] result_summary: null"); } 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| parse_number(&s["win_rate"])), profit_factor: summary.and_then(|s| parse_number(&s["profit_factor"])), total_pnl: summary.and_then(|s| parse_number(&s["total_pnl"])), net_pnl: summary.and_then(|s| parse_number(&s["net_pnl"])), sharpe_ratio: summary.and_then(|s| parse_number(&s["sharpe_ratio"])), total_fees: summary.and_then(|s| parse_number(&s["total_fees"])), avg_bars_in_trade: summary.and_then(|s| parse_number(&s["avg_bars_in_trade"])), 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") ); } let mut s = format!( "[{}] trades={} win_rate={:.1}% pf={:.2} net_pnl={:.2} sharpe={:.2}", 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), ); if self.total_positions.unwrap_or(0) == 0 { if let Some(audit) = &self.condition_audit_summary { let audit_str = format_audit_summary(audit); if !audit_str.is_empty() { s.push_str(" | audit: "); s.push_str(&audit_str); } } } s } /// 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 } } /// Parse a numeric JSON value — accepts either a plain JSON number or a decimal string. /// Returns `None` for null, missing, or sentinel values (>1e20 in magnitude). fn parse_number(v: &Value) -> Option { let f = v.as_f64().or_else(|| v.as_str()?.parse().ok())?; if f.abs() > 1e20 { None } else { Some(f) } } /// Render a condition_audit_summary Value into a compact one-line string. /// /// Handles the primary shape from the swym API: /// `{"rules": [{rule_index, times_fired, conditions: [{kind, path, true_count, insufficient_data_count}]}], "total_bars": N}` fn format_audit_summary(audit: &Value) -> String { // Primary shape: {"rules": [...], "total_bars": N} if let Some(rules) = audit.get("rules").and_then(|r| r.as_array()) { let total_bars = audit["total_bars"].as_u64().unwrap_or(0); let parts: Vec = rules .iter() .map(|rule| { let idx = rule["rule_index"].as_u64().unwrap_or(0); let fired = rule["times_fired"].as_u64().unwrap_or(0); // Include the rule comment so the LLM knows which rule is which. let comment = rule["rule_comment"].as_str().unwrap_or(""); let comment_part = if comment.is_empty() { String::new() } else { format!(" \"{comment}\"") }; let cond_summary = rule["conditions"] .as_array() .map(|conds| { conds .iter() .map(|c| { let kind = c["kind"].as_str().unwrap_or("?"); let true_count = c["true_count"].as_u64().unwrap_or(0); let nodata = c["insufficient_data_count"].as_u64().unwrap_or(0); let evaluated = total_bars.saturating_sub(nodata); if nodata > total_bars / 2 { format!("{kind}:{true_count}/{evaluated}[{nodata}nd]") } else { format!("{kind}:{true_count}/{evaluated}") } }) .collect::>() .join(" ") }) .unwrap_or_default(); format!("R{idx}(f={fired}){comment_part}[{cond_summary}]") }) .collect(); return parts.join(" | "); } // Fallback: plain array of named conditions if let Some(arr) = audit.as_array() { return arr .iter() .filter_map(|item| { let name = item.get("name").or_else(|| item.get("condition"))?.as_str()?; let true_count = item .get("true_count") .or_else(|| item.get("hit_count")) .and_then(|v| v.as_u64()); let total = item .get("total") .or_else(|| item.get("total_bars")) .and_then(|v| v.as_u64()); match (true_count, total) { (Some(t), Some(n)) => Some(format!("{name}: {t}/{n}")), _ => Some(format!("{name}: {item}")), } }) .collect::>() .join(", "); } audit.to_string() } impl SwymClient { pub fn new(base_url: &str) -> Result { 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> { 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") } /// Validate a strategy against the swym DSL schema. /// /// Calls `POST /api/v1/strategies/validate` and returns a structured list /// of all validation errors. Returns `Ok(vec![])` when the strategy is valid. /// Returns `Err` only on network or parse failures, not on DSL errors. pub async fn validate_strategy(&self, strategy: &Value) -> Result> { let url = format!("{}/strategies/validate", self.base_url); let resp = self .client .post(&url) .json(strategy) .send() .await .context("validate strategy request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("validate strategy {status}: {body}"); } let parsed: ValidationResponse = resp.json().await.context("parse validation response")?; Ok(parsed.errors) } /// 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 { 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 { 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 { 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") } }