Files
scout/src/swym.rs
rob thijssen ee260ea4d5 fix: parse flat result_summary structure per updated API doc
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>
2026-03-10 09:37:55 +02:00

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")
}
}