The API result_summary is a flat object with top-level fields (total_positions, win_rate, profit_factor, net_pnl, sharpe_ratio, etc.) not a nested backtest_metadata/instruments map. This was causing all metrics to parse as None/zero for every completed run. - Rewrite BacktestResult::from_response() to read flat fields directly - Replace parse_ratio_value/parse_decimal_str with a single parse_number() that accepts both JSON numbers and decimal strings - Populate winning_positions, losing_positions, total_fees, avg_bars_in_trade (previously always None) - Simplify from_response signature — exchange/base/quote no longer needed - Add expected_count and coverage_pct to CandleCoverage struct - Update all example sell rules to use position_quantity instead of "0.01" - Note that "9999" is a valid sell-all alias (auto-capped by the API) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
381 lines
14 KiB
Rust
381 lines
14 KiB
Rust
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<ValidationError>,
|
|
}
|
|
|
|
#[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<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,
|
|
pub expected_count: Option<u64>,
|
|
pub coverage_pct: Option<f64>,
|
|
}
|
|
|
|
#[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 {
|
|
/// 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<f64> {
|
|
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<String> = 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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.join(", ");
|
|
}
|
|
audit.to_string()
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
/// 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<Vec<ValidationError>> {
|
|
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<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")
|
|
}
|
|
}
|