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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1838
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "scout"
version = "0.1.0"
edition = "2021"
description = "Autonomous strategy search agent for swym backtesting platform"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["serde"] }

93
readme.md Normal file
View File

@@ -0,0 +1,93 @@
# scout
Autonomous strategy search agent for the [swym](https://swym.rs) backtesting platform.
Runs a loop: asks Claude to generate trading strategies → submits backtests to swym →
evaluates results → feeds learnings back → repeats. Promising strategies are automatically
validated on out-of-sample data to filter overfitting.
## Quick start
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
cargo run -- \
--swym-url https://dev.swym.hanzalova.internal/api/v1 \
--max-iterations 50 \
--instruments binance_spot:BTCUSDC,binance_spot:ETHUSDC,binance_spot:SOLUSDC \
--backtest-from 2025-01-01T00:00:00Z \
--backtest-to 2025-10-01T00:00:00Z \
--oos-from 2025-10-01T00:00:00Z \
--oos-to 2026-03-01T00:00:00Z
```
## How it works
1. **Coverage check** — verifies candle data exists for all instruments and finds
common available intervals.
2. **Strategy generation** — sends the DSL schema + prior results to Claude, which
produces a new strategy JSON each iteration.
3. **In-sample backtest** — submits the strategy against all instruments for the
training period. Evaluates Sharpe ratio, profit factor, win rate, net PnL.
4. **Out-of-sample validation** — if any instrument shows Sharpe > threshold with
enough trades, the strategy is re-tested on held-out data. Only strategies that
pass both phases are saved as "validated".
5. **Learning loop** — all results (including failures) are fed back to Claude so
it can learn from what works and what doesn't. The conversation is trimmed to
avoid context exhaustion while the full results history is passed as structured
text.
## Configuration
All options are available as CLI flags and environment variables:
| Flag | Env | Default | Description |
|------|-----|---------|-------------|
| `--swym-url` | `SWYM_API_URL` | `https://dev.swym.hanzalova.internal/api/v1` | Swym API base URL |
| `--anthropic-key` | `ANTHROPIC_API_KEY` | required | Anthropic API key |
| `--model` | `CLAUDE_MODEL` | `claude-sonnet-4-20250514` | Claude model |
| `--max-iterations` | | `50` | Maximum search iterations |
| `--min-sharpe` | | `1.0` | Minimum Sharpe for "promising" |
| `--min-trades` | | `10` | Minimum trades for significance |
| `--instruments` | | BTC,ETH,SOL vs USDC | Comma-separated exchange:SYMBOL |
| `--backtest-from` | | `2025-01-01` | In-sample start |
| `--backtest-to` | | `2025-10-01` | In-sample end |
| `--oos-from` | | `2025-10-01` | Out-of-sample start |
| `--oos-to` | | `2026-03-01` | Out-of-sample end |
| `--initial-balance` | | `10000` | Starting USDC balance |
| `--fees-percent` | | `0.001` | Fee per trade (0.1%) |
| `--output-dir` | | `./results` | Where to save strategies and reports |
## Output
```
results/
├── strategy_001.json # Every strategy attempted
├── strategy_002.json
├── ...
├── validated_017.json # Strategies that passed OOS validation
├── validated_031.json # (includes in-sample + OOS metrics)
└── best_strategy.json # Highest avg Sharpe across instruments
```
## Tips
- **Start with Sonnet** (`claude-sonnet-4-20250514`) for cost efficiency during
exploration. Switch to Opus for refinement of promising strategies.
- **50 iterations** is a reasonable starting point. The agent typically finds
interesting patterns within 20-30 iterations if they exist.
- **Watch the logs** — the per-iteration summaries show you what the agent is
learning in real time.
- **Adjust dates** to match your actual candle coverage. The agent checks coverage
at startup and will fail fast if data is missing.
- **The OOS validation threshold is intentionally relaxed** (70% of in-sample
Sharpe, half the trade count) because out-of-sample degradation is expected.
Strategies that maintain edge through this filter are genuinely interesting.

420
src/agent.rs Normal file
View File

@@ -0,0 +1,420 @@
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use serde_json::Value;
use tracing::{error, info, warn};
use crate::claude::{self, ClaudeClient, Message};
use crate::config::{Cli, Instrument};
use crate::prompts;
use crate::swym::{BacktestResult, SwymClient};
/// A single iteration's record: strategy + results across instruments.
#[derive(Debug)]
struct IterationRecord {
iteration: u32,
strategy: Value,
results: Vec<BacktestResult>,
}
impl IterationRecord {
fn best_sharpe(&self) -> f64 {
self.results
.iter()
.filter_map(|r| r.sharpe_ratio)
.fold(f64::NEG_INFINITY, f64::max)
}
fn avg_sharpe(&self) -> f64 {
let sharpes: Vec<f64> = self.results.iter().filter_map(|r| r.sharpe_ratio).collect();
if sharpes.is_empty() {
return f64::NEG_INFINITY;
}
sharpes.iter().sum::<f64>() / sharpes.len() as f64
}
fn summary(&self) -> String {
let mut lines = vec![format!("=== Iteration {} ===", self.iteration)];
for r in &self.results {
lines.push(r.summary_line());
}
lines.push(format!(
" avg_sharpe={:.3} best_sharpe={:.3}",
self.avg_sharpe(),
self.best_sharpe()
));
lines.join("\n")
}
}
pub async fn run(cli: &Cli) -> Result<()> {
// Parse instruments
let instruments: Vec<Instrument> = cli
.instruments
.iter()
.map(|s| Instrument::parse(s))
.collect::<Result<_>>()?;
// Ensure output directory exists
std::fs::create_dir_all(&cli.output_dir)?;
// Init clients
let swym = SwymClient::new(&cli.swym_url)?;
let claude = ClaudeClient::new(&cli.anthropic_key, &cli.model);
// Check candle coverage for all instruments
info!(
"checking candle coverage for {} instruments...",
instruments.len()
);
let mut available_intervals: Vec<String> = Vec::new();
for inst in &instruments {
match swym.candle_coverage(&inst.exchange, &inst.symbol).await {
Ok(coverage) => {
let intervals: Vec<&str> = coverage.iter().map(|c| c.interval.as_str()).collect();
info!("{}: intervals {:?}", inst.symbol, intervals);
if available_intervals.is_empty() {
available_intervals = coverage.iter().map(|c| c.interval.clone()).collect();
} else {
// Intersect — only keep intervals available for ALL instruments
available_intervals.retain(|iv| intervals.contains(&iv.as_str()));
}
}
Err(e) => {
warn!("could not check coverage for {}: {e}", inst.symbol);
}
}
}
if available_intervals.is_empty() {
anyhow::bail!("no common candle intervals available across all instruments");
}
info!("common intervals: {:?}", available_intervals);
// Load DSL schema for the system prompt
let schema = include_str!("dsl-schema.json");
let system = prompts::system_prompt(schema);
// Agent state
let mut history: Vec<IterationRecord> = Vec::new();
let mut conversation: Vec<Message> = Vec::new();
let mut best_strategy: Option<(f64, Value)> = None; // (avg_sharpe, strategy)
let mut consecutive_failures = 0u32;
let instrument_names: Vec<String> = instruments.iter().map(|i| i.symbol.clone()).collect();
for iteration in 1..=cli.max_iterations {
info!("━━━ iteration {}/{} ━━━", iteration, cli.max_iterations);
// Build the user prompt
let user_msg = if iteration == 1 {
prompts::initial_prompt(&instrument_names, &available_intervals)
} else {
let results_text = history
.iter()
.map(|r| r.summary())
.collect::<Vec<_>>()
.join("\n\n");
let best_json = best_strategy
.as_ref()
.map(|(_, v)| serde_json::to_string_pretty(v).unwrap());
prompts::iteration_prompt(iteration, &results_text, best_json.as_deref())
};
conversation.push(Message {
role: "user".to_string(),
content: user_msg,
});
// Ask Claude for a strategy
info!("requesting strategy from Claude...");
let (response_text, usage) = match claude.chat(&system, &conversation).await {
Ok(r) => r,
Err(e) => {
error!("Claude API error: {e}");
consecutive_failures += 1;
if consecutive_failures >= 3 {
error!("3 consecutive failures, aborting");
break;
}
// Remove the last user message so we can retry
conversation.pop();
continue;
}
};
if let Some(u) = &usage {
info!(
"tokens: in={} out={}",
u.input_tokens.unwrap_or(0),
u.output_tokens.unwrap_or(0)
);
}
// Add assistant response to conversation history
conversation.push(Message {
role: "assistant".to_string(),
content: response_text.clone(),
});
// Extract strategy JSON
let strategy = match claude::extract_json(&response_text) {
Ok(s) => s,
Err(e) => {
warn!("failed to extract strategy JSON: {e}");
warn!(
"raw response: {}",
&response_text[..response_text.len().min(500)]
);
consecutive_failures += 1;
if consecutive_failures >= 3 {
error!("3 consecutive JSON extraction failures, aborting");
break;
}
continue;
}
};
consecutive_failures = 0;
// Validate basic structure
if strategy.get("type").and_then(|v| v.as_str()) != Some("rule_based") {
warn!("strategy missing type=rule_based, skipping");
continue;
}
info!(
"strategy: interval={}, rules={}",
strategy["candle_interval"].as_str().unwrap_or("?"),
strategy["rules"].as_array().map(|r| r.len()).unwrap_or(0)
);
// Save the strategy JSON
let strat_path = cli.output_dir.join(format!("strategy_{iteration:03}.json"));
std::fs::write(&strat_path, serde_json::to_string_pretty(&strategy)?)?;
// Run backtests against all instruments (in-sample)
let mut results: Vec<BacktestResult> = Vec::new();
for inst in &instruments {
info!("backtesting {} on {}...", inst.symbol, cli.backtest_from);
match run_single_backtest(
&swym,
inst,
&strategy,
&cli.backtest_from,
&cli.backtest_to,
&cli.initial_balance,
&cli.fees_percent,
cli.poll_interval_secs,
cli.backtest_timeout_secs,
)
.await
{
Ok(result) => {
info!(" {}", result.summary_line());
results.push(result);
}
Err(e) => {
warn!(" backtest failed for {}: {e}", inst.symbol);
results.push(BacktestResult {
run_id: uuid::Uuid::nil(),
instrument: inst.symbol.clone(),
status: "failed".to_string(),
total_positions: None,
winning_positions: None,
losing_positions: None,
win_rate: None,
profit_factor: None,
total_pnl: None,
net_pnl: None,
sharpe_ratio: None,
total_fees: None,
avg_bars_in_trade: None,
error_message: Some(e.to_string()),
condition_audit_summary: None,
});
}
}
}
let record = IterationRecord {
iteration,
strategy: strategy.clone(),
results,
};
info!("{}", record.summary());
// Check if any result is promising → run out-of-sample validation
let promising_instruments: Vec<&BacktestResult> = record
.results
.iter()
.filter(|r| r.is_promising(cli.min_sharpe, cli.min_trades))
.collect();
if !promising_instruments.is_empty() {
info!(
"🎯 promising on {} instrument(s)! running out-of-sample validation...",
promising_instruments.len()
);
let mut oos_results: Vec<BacktestResult> = Vec::new();
for inst in &instruments {
match run_single_backtest(
&swym,
inst,
&strategy,
&cli.oos_from,
&cli.oos_to,
&cli.initial_balance,
&cli.fees_percent,
cli.poll_interval_secs,
cli.backtest_timeout_secs,
)
.await
{
Ok(result) => {
info!(" OOS {}", result.summary_line());
oos_results.push(result);
}
Err(e) => {
warn!(" OOS backtest failed for {}: {e}", inst.symbol);
}
}
}
// Check if out-of-sample results confirm the edge
let oos_promising: Vec<&BacktestResult> = oos_results
.iter()
.filter(|r| r.is_promising(cli.min_sharpe * 0.7, cli.min_trades / 2))
.collect();
if !oos_promising.is_empty() {
info!(
"✅ OUT-OF-SAMPLE VALIDATED on {} instruments!",
oos_promising.len()
);
save_validated_strategy(
&cli.output_dir,
iteration,
&strategy,
&record.results,
&oos_results,
)?;
} else {
info!("❌ out-of-sample did not confirm edge (likely overfit)");
}
}
// Track best strategy by average Sharpe across instruments
let avg_sharpe = record.avg_sharpe();
if avg_sharpe
> best_strategy
.as_ref()
.map(|(s, _)| *s)
.unwrap_or(f64::NEG_INFINITY)
{
best_strategy = Some((avg_sharpe, strategy.clone()));
info!("new best avg_sharpe: {avg_sharpe:.3}");
}
history.push(record);
// Trim conversation to avoid context window exhaustion.
// Keep system + last 6 messages (3 exchanges).
if conversation.len() > 10 {
// Summarize old history in the next prompt via results_text instead
conversation.drain(..conversation.len() - 6);
}
}
// Final summary
info!("━━━ search complete: {} iterations ━━━", history.len());
if let Some((sharpe, ref strat)) = best_strategy {
info!("best avg_sharpe: {sharpe:.3}");
let path = cli.output_dir.join("best_strategy.json");
std::fs::write(&path, serde_json::to_string_pretty(strat)?)?;
info!("saved to {}", path.display());
} else {
info!("no strategies showed positive Sharpe across instruments");
}
Ok(())
}
async fn run_single_backtest(
swym: &SwymClient,
inst: &Instrument,
strategy: &Value,
starts_at: &str,
finishes_at: &str,
initial_balance: &str,
fees_percent: &str,
poll_interval_secs: u64,
timeout_secs: u64,
) -> Result<BacktestResult> {
let resp = swym
.submit_backtest(
&inst.exchange,
&inst.symbol,
&inst.base(),
&inst.quote(),
strategy,
starts_at,
finishes_at,
initial_balance,
fees_percent,
)
.await
.context("submit")?;
let run_id = resp.id;
let final_resp = swym
.poll_until_done(
run_id,
Duration::from_secs(poll_interval_secs),
Duration::from_secs(timeout_secs),
)
.await
.context("poll")?;
Ok(BacktestResult::from_response(&final_resp, &inst.symbol))
}
fn save_validated_strategy(
output_dir: &Path,
iteration: u32,
strategy: &Value,
in_sample: &[BacktestResult],
out_of_sample: &[BacktestResult],
) -> Result<()> {
let report = serde_json::json!({
"iteration": iteration,
"strategy": strategy,
"in_sample": in_sample.iter().map(|r| serde_json::json!({
"instrument": r.instrument,
"sharpe": r.sharpe_ratio,
"net_pnl": r.net_pnl,
"profit_factor": r.profit_factor,
"win_rate": r.win_rate,
"total_trades": r.total_positions,
})).collect::<Vec<_>>(),
"out_of_sample": out_of_sample.iter().map(|r| serde_json::json!({
"instrument": r.instrument,
"sharpe": r.sharpe_ratio,
"net_pnl": r.net_pnl,
"profit_factor": r.profit_factor,
"win_rate": r.win_rate,
"total_trades": r.total_positions,
})).collect::<Vec<_>>(),
});
let path = output_dir.join(format!("validated_{iteration:03}.json"));
std::fs::write(&path, serde_json::to_string_pretty(&report)?)?;
info!("saved validated strategy to {}", path.display());
Ok(())
}

136
src/claude.rs Normal file
View File

@@ -0,0 +1,136 @@
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub struct ClaudeClient {
client: Client,
api_key: String,
model: String,
}
#[derive(Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
system: String,
messages: Vec<Message>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Message {
pub role: String,
pub content: String,
}
#[derive(Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
usage: Option<Usage>,
}
#[derive(Deserialize)]
struct ContentBlock {
text: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Usage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
}
impl ClaudeClient {
pub fn new(api_key: &str, model: &str) -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.expect("build http client");
Self {
client,
api_key: api_key.to_string(),
model: model.to_string(),
}
}
/// Send a conversation to Claude and get the text response.
pub async fn chat(
&self,
system: &str,
messages: &[Message],
) -> Result<(String, Option<Usage>)> {
let body = MessagesRequest {
model: self.model.clone(),
max_tokens: 4096,
system: system.to_string(),
messages: messages.to_vec(),
};
let resp = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await
.context("claude API request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Claude API {status}: {body}");
}
let parsed: MessagesResponse = resp.json().await.context("parse claude response")?;
let text = parsed
.content
.into_iter()
.filter_map(|b| b.text)
.collect::<Vec<_>>()
.join("\n");
Ok((text, parsed.usage))
}
}
/// Extract a JSON object from Claude's response text.
/// Looks for the first `{` ... `}` block, handling markdown code fences.
pub fn extract_json(text: &str) -> Result<Value> {
// Strip markdown fences if present
let cleaned = text
.replace("```json", "")
.replace("```", "");
// Find the outermost JSON object
let mut depth = 0i32;
let mut start = None;
let mut end = None;
for (i, ch) in cleaned.char_indices() {
match ch {
'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
break;
}
}
_ => {}
}
}
let (s, e) = match (start, end) {
(Some(s), Some(e)) => (s, e),
_ => anyhow::bail!("no JSON object found in Claude response"),
};
serde_json::from_str(&cleaned[s..e]).context("parse extracted JSON")
}

122
src/config.rs Normal file
View File

@@ -0,0 +1,122 @@
use std::path::PathBuf;
use clap::Parser;
/// Autonomous strategy search agent for the swym backtesting platform.
///
/// Runs a loop: ask Claude to generate/refine strategies → submit backtests to swym →
/// evaluate results → feed learnings back to Claude → repeat.
#[derive(Parser, Debug)]
#[command(name = "scout", version)]
pub struct Cli {
/// Swym API base URL (no trailing slash).
#[arg(long, default_value = "https://dev.swym.hanzalova.internal/api/v1")]
pub swym_url: String,
/// Anthropic API key.
#[arg(long)]
pub anthropic_key: String,
/// Claude model to use for strategy generation.
#[arg(long, default_value = "claude-sonnet-4-20250514")]
pub model: String,
/// Maximum agent iterations (generate → backtest → evaluate cycles).
#[arg(long, default_value_t = 50)]
pub max_iterations: u32,
/// Minimum Sharpe ratio to consider a strategy "interesting".
#[arg(long, default_value_t = 1.0)]
pub min_sharpe: f64,
/// Minimum number of trades for a result to be meaningful.
#[arg(long, default_value_t = 10)]
pub min_trades: u32,
/// Instruments to test against, as "EXCHANGE:SYMBOL" (e.g. "binance_spot:BTCUSDC").
/// Strategies are tested against all listed instruments.
#[arg(long, value_delimiter = ',', default_values_t = vec![
"binance_spot:BTCUSDC".to_string(),
"binance_spot:ETHUSDC".to_string(),
"binance_spot:SOLUSDC".to_string(),
])]
pub instruments: Vec<String>,
/// Backtest start date (ISO-8601).
#[arg(long, default_value = "2025-01-01T00:00:00Z")]
pub backtest_from: String,
/// Backtest end date (ISO-8601).
#[arg(long, default_value = "2025-10-01T00:00:00Z")]
pub backtest_to: String,
/// Out-of-sample start (for validation of promising strategies).
#[arg(long, default_value = "2025-10-01T00:00:00Z")]
pub oos_from: String,
/// Out-of-sample end.
#[arg(long, default_value = "2026-03-01T00:00:00Z")]
pub oos_to: String,
/// Initial quote balance for backtests.
#[arg(long, default_value = "10000")]
pub initial_balance: String,
/// Trading fee as decimal (0.001 = 0.1%).
#[arg(long, default_value = "0.001")]
pub fees_percent: String,
/// Directory to persist promising strategies and results.
#[arg(long, default_value = "./results")]
pub output_dir: PathBuf,
/// Poll interval in seconds when waiting for backtest completion.
#[arg(long, default_value_t = 2)]
pub poll_interval_secs: u64,
/// Maximum seconds to wait for a single backtest to complete.
#[arg(long, default_value_t = 300)]
pub backtest_timeout_secs: u64,
}
/// Parsed instrument reference.
#[derive(Debug, Clone)]
pub struct Instrument {
pub exchange: String,
pub symbol: String,
}
impl Instrument {
pub fn parse(s: &str) -> anyhow::Result<Self> {
let (exchange, symbol) = s
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("instrument must be 'exchange:SYMBOL', got '{s}'"))?;
Ok(Self {
exchange: exchange.to_string(),
symbol: symbol.to_string(),
})
}
/// Base asset, lowercase, derived from symbol convention (e.g. BTCUSDC → btc).
pub fn base(&self) -> String {
// Strip known quote suffixes
let s = &self.symbol;
for quote in ["USDC", "USDT", "BUSD"] {
if let Some(base) = s.strip_suffix(quote) {
return base.to_lowercase();
}
}
s.to_lowercase()
}
/// Quote asset, lowercase.
pub fn quote(&self) -> String {
let s = &self.symbol;
for quote in ["USDC", "USDT", "BUSD"] {
if s.ends_with(quote) {
return quote.to_lowercase();
}
}
"usdc".to_string()
}
}

422
src/dsl-schema.json Normal file
View File

@@ -0,0 +1,422 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://swym.internal/strategy/schema.json",
"title": "SwymRuleBasedStrategy",
"description": "Swym rules-based strategy DSL. Submit as the 'strategy' field of a paper run config. All decimal values must be JSON strings.",
"type": "object",
"required": ["type", "candle_interval", "rules"],
"additionalProperties": false,
"properties": {
"type": {
"const": "rule_based"
},
"candle_interval": {
"type": "string",
"enum": ["1m", "5m", "15m", "1h", "4h", "1d"],
"description": "Primary candle interval driving strategy evaluation cadence."
},
"rules": {
"type": "array",
"items": { "$ref": "#/definitions/Rule" },
"minItems": 1,
"description": "All rules whose 'when' is true on a primary candle close fire simultaneously."
}
},
"definitions": {
"DecimalString": {
"description": "A decimal number encoded as a JSON string, e.g. \"3.14\", \"-0.5\", \"0.001\".",
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]+)?$"
},
"CandleField": {
"type": "string",
"enum": ["open", "high", "low", "close", "volume"]
},
"TimeframeInterval": {
"type": "string",
"enum": ["1m", "5m", "15m", "1h", "4h", "1d"],
"description": "An additional candle interval to read from. When absent, uses the primary candle_interval. Requires backfilled candle data for the backtest window."
},
"FuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "rsi", "std_dev", "sum", "atr", "supertrend", "adx", "bollinger_upper", "bollinger_lower"],
"description": "Rolling-window function. atr/adx/supertrend ignore 'field' and use OHLC internally. bollinger_upper/bollinger_lower use multiplier for num_std_dev (default 2.0)."
},
"ApplyFuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "std_dev", "sum", "bollinger_upper", "bollinger_lower"],
"description": "Subset of FuncName valid inside apply_func. atr, supertrend, adx, and rsi are excluded. bollinger_upper/bollinger_lower use fixed num_std_dev=2.0 in apply_func context."
},
"CmpOp": {
"type": "string",
"enum": [">", "<", ">=", "<=", "=="]
},
"ArithOp": {
"type": "string",
"enum": ["add", "sub", "mul", "div"]
},
"UnaryOpKind": {
"type": "string",
"enum": ["abs", "sqrt", "neg", "log"]
},
"Action": {
"type": "object",
"required": ["side", "quantity"],
"additionalProperties": false,
"properties": {
"side": { "type": "string", "enum": ["buy", "sell"] },
"quantity": {
"$ref": "#/definitions/DecimalString",
"description": "Per-order size in base asset units, e.g. \"0.001\" for BTC."
}
}
},
"Rule": {
"type": "object",
"required": ["when", "then"],
"additionalProperties": false,
"properties": {
"comment": {
"type": "string",
"description": "Optional human-readable annotation. Included in the content hash so keep stable."
},
"when": { "$ref": "#/definitions/Condition" },
"then": { "$ref": "#/definitions/Action" }
}
},
"Condition": {
"description": "A boolean condition evaluated at candle close. Returns false (safe no-op) when there is insufficient history.",
"oneOf": [
{ "$ref": "#/definitions/ConditionAllOf" },
{ "$ref": "#/definitions/ConditionAnyOf" },
{ "$ref": "#/definitions/ConditionNot" },
{ "$ref": "#/definitions/ConditionPosition" },
{ "$ref": "#/definitions/ConditionEmaCrossover" },
{ "$ref": "#/definitions/ConditionEmaTrend" },
{ "$ref": "#/definitions/ConditionRsi" },
{ "$ref": "#/definitions/ConditionBollinger" },
{ "$ref": "#/definitions/ConditionPriceLevel" },
{ "$ref": "#/definitions/ConditionCompare" },
{ "$ref": "#/definitions/ConditionCrossOver" },
{ "$ref": "#/definitions/ConditionCrossUnder" },
{ "$ref": "#/definitions/ConditionEventCount" }
]
},
"ConditionComment": {
"description": "Optional human-readable annotation on any condition node. Ignored by the evaluator; serde drops unknown fields silently.",
"type": "string"
},
"ConditionAllOf": {
"type": "object",
"required": ["kind", "conditions"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "all_of" },
"conditions": {
"type": "array",
"items": { "$ref": "#/definitions/Condition" },
"minItems": 1
}
}
},
"ConditionAnyOf": {
"type": "object",
"required": ["kind", "conditions"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "any_of" },
"conditions": {
"type": "array",
"items": { "$ref": "#/definitions/Condition" },
"minItems": 1
}
}
},
"ConditionNot": {
"type": "object",
"required": ["kind", "condition"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "not" },
"condition": { "$ref": "#/definitions/Condition" }
}
},
"ConditionPosition": {
"type": "object",
"required": ["kind", "state"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "position" },
"state": { "type": "string", "enum": ["flat", "long", "short"] }
}
},
"ConditionEmaCrossover": {
"description": "True on the bar where fast EMA crosses above (or below) slow EMA. Fires once per cross event.",
"type": "object",
"required": ["kind", "fast_period", "slow_period", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "ema_crossover" },
"fast_period": { "type": "integer", "minimum": 1 },
"slow_period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionEmaTrend": {
"description": "True while close price is above (or below) the EMA. Persistent condition, not a crossover event.",
"type": "object",
"required": ["kind", "period", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "ema_trend" },
"period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionRsi": {
"description": "True while RSI (Wilder's method) is above or below the threshold.",
"type": "object",
"required": ["kind", "threshold", "comparison"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "rsi" },
"period": {
"type": "integer",
"minimum": 1,
"default": 14,
"description": "RSI period. Defaults to 14."
},
"threshold": { "$ref": "#/definitions/DecimalString" },
"comparison": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionBollinger": {
"description": "True when close breaks above the upper or below the lower Bollinger Band.",
"type": "object",
"required": ["kind", "band"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "bollinger" },
"period": {
"type": "integer",
"minimum": 1,
"default": 20
},
"num_std_dev": {
"$ref": "#/definitions/DecimalString",
"description": "Number of standard deviations. Defaults to \"2\"."
},
"band": { "type": "string", "enum": ["above_upper", "below_lower"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionPriceLevel": {
"description": "True while close is above or below a fixed price level.",
"type": "object",
"required": ["kind", "price", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "price_level" },
"price": { "$ref": "#/definitions/DecimalString" },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionCompare": {
"description": "True when left op right holds. Use for any indicator comparison not covered by the legacy shortcuts.",
"type": "object",
"required": ["kind", "left", "op", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "compare" },
"left": { "$ref": "#/definitions/Expr" },
"op": { "$ref": "#/definitions/CmpOp" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ConditionCrossOver": {
"description": "True on the single bar where left crosses above right (left was <= right, now > right).",
"type": "object",
"required": ["kind", "left", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "cross_over" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ConditionCrossUnder": {
"description": "True on the single bar where left crosses below right (left was >= right, now < right).",
"type": "object",
"required": ["kind", "left", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "cross_under" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"Expr": {
"description": "A numeric expression evaluating to Option<Decimal>. Returns None (condition → false) when history is insufficient.",
"oneOf": [
{ "$ref": "#/definitions/ExprLiteral" },
{ "$ref": "#/definitions/ExprField" },
{ "$ref": "#/definitions/ExprFunc" },
{ "$ref": "#/definitions/ExprBinOp" },
{ "$ref": "#/definitions/ExprApplyFunc" },
{ "$ref": "#/definitions/ExprUnaryOp" },
{ "$ref": "#/definitions/ExprBarsSince" }
]
},
"ExprLiteral": {
"description": "A constant numeric value.",
"type": "object",
"required": ["kind", "value"],
"additionalProperties": false,
"properties": {
"kind": { "const": "literal" },
"value": { "$ref": "#/definitions/DecimalString" }
}
},
"ExprField": {
"description": "An OHLCV candle field, optionally from N bars ago and/or from a different timeframe.",
"type": "object",
"required": ["kind", "field"],
"additionalProperties": false,
"properties": {
"kind": { "const": "field" },
"field": { "$ref": "#/definitions/CandleField" },
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Bars ago. 0 = current bar, 1 = previous bar."
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprFunc": {
"description": "A rolling-window function applied to a candle field over the trailing N bars, optionally on a different timeframe.",
"type": "object",
"required": ["kind", "name", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "func" },
"name": { "$ref": "#/definitions/FuncName" },
"field": {
"$ref": "#/definitions/CandleField",
"description": "Which candle field to use. Defaults to 'close'. Ignored for atr, adx, supertrend."
},
"period": {
"type": "integer",
"minimum": 1,
"description": "Window size in bars. Minimum bars required: period (most funcs), period+1 (atr/rsi), 2*period+1 (adx), period+2 (supertrend)."
},
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Shift the window back N bars. 0 = window ending on current bar."
},
"multiplier": {
"$ref": "#/definitions/DecimalString",
"description": "Numeric multiplier: ATR multiplier for supertrend (e.g. \"3.0\"); num_std_dev for bollinger_upper/bollinger_lower (e.g. \"2.0\", default). Omit for all other functions."
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprBinOp": {
"description": "Arithmetic combination of two sub-expressions. Division by zero returns None.",
"type": "object",
"required": ["kind", "op", "left", "right"],
"additionalProperties": false,
"properties": {
"kind": { "const": "bin_op" },
"op": { "$ref": "#/definitions/ArithOp" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ExprApplyFunc": {
"description": "Applies a rolling function to an arbitrary sub-expression evaluated at each bar in the window. Use for function composition: EMA-of-EMA, Hull MA, VWAP, etc. NOT valid with atr, adx, supertrend, or rsi.",
"type": "object",
"required": ["kind", "name", "input", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "apply_func" },
"name": { "$ref": "#/definitions/ApplyFuncName" },
"input": { "$ref": "#/definitions/Expr" },
"period": { "type": "integer", "minimum": 1 },
"offset": {
"type": "integer",
"minimum": 0,
"default": 0
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprUnaryOp": {
"description": "Unary math operation. sqrt and log return None for invalid inputs (negative / non-positive).",
"type": "object",
"required": ["kind", "op", "operand"],
"additionalProperties": false,
"properties": {
"kind": { "const": "unary_op" },
"op": { "$ref": "#/definitions/UnaryOpKind" },
"operand": { "$ref": "#/definitions/Expr" }
}
},
"ConditionEventCount": {
"description": "Counts how many of the trailing `period` bars (offsets 1..=period, excluding the current bar) the sub-condition was true, then applies op against count. Returns false during warm-up. Only Compare, CrossOver, CrossUnder, AllOf, AnyOf, Not, and nested EventCount sub-conditions are offset-aware; Position/EmaCrossover/EmaTrend/Rsi/Bollinger return false at non-zero offsets.",
"type": "object",
"required": ["kind", "condition", "period", "op", "count"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "event_count" },
"condition": { "$ref": "#/definitions/Condition" },
"period": {
"type": "integer",
"minimum": 1,
"description": "Number of trailing bars to scan (not including the current bar)."
},
"op": { "$ref": "#/definitions/CmpOp" },
"count": {
"type": "integer",
"minimum": 0,
"description": "Integer threshold to compare the fire count against."
}
}
},
"ExprBarsSince": {
"description": "Scans back up to `period` bars and returns how many bars ago the sub-condition was last true (1 = previous bar). Returns None if the condition never fired within the window. Use inside compare to express recency constraints.",
"type": "object",
"required": ["kind", "condition", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "bars_since" },
"condition": { "$ref": "#/definitions/Condition" },
"period": {
"type": "integer",
"minimum": 1,
"description": "Maximum bars to look back."
}
}
}
}
}

31
src/main.rs Normal file
View File

@@ -0,0 +1,31 @@
mod agent;
mod claude;
mod config;
mod prompts;
mod swym;
use clap::Parser;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(false)
.init();
let cli = config::Cli::parse();
tracing::info!("scout starting");
tracing::info!(" swym API: {}", cli.swym_url);
tracing::info!(" model: {}", cli.model);
tracing::info!(" iterations: {}", cli.max_iterations);
tracing::info!(" instruments: {:?}", cli.instruments);
tracing::info!(" in-sample: {} → {}", cli.backtest_from, cli.backtest_to);
tracing::info!(" OOS: {} → {}", cli.oos_from, cli.oos_to);
tracing::info!(" output: {}", cli.output_dir.display());
agent::run(&cli).await
}

180
src/prompts.rs Normal file
View File

@@ -0,0 +1,180 @@
/// System prompt for the strategy-generation Claude instance.
///
/// This is the most important part of the agent — it defines how Claude
/// thinks about strategy design, what it knows about the DSL, and how
/// it should interpret backtest results.
pub fn system_prompt(dsl_schema: &str) -> String {
format!(
r##"You are a quantitative trading strategy researcher. Your task is to design,
evaluate, and iteratively refine trading strategies expressed in the swym JSON DSL.
## Your goal
Find strategies with genuine statistical edge — not curve-fitted artifacts. A good
strategy has:
- Sharpe ratio > 1.0 (ideally > 1.5)
- Profit factor > 1.3
- At least 15+ trades (more is better — sparse strategies are unverifiable)
- Positive net PnL after fees
- Consistent performance across multiple instruments (BTC, ETH, SOL vs USDC)
## Strategy DSL
Strategies are JSON objects. Here is the complete JSON Schema:
```json
{dsl_schema}
```
## Key DSL capabilities
### Indicators (func)
sma, ema, wma, rsi, std_dev, sum, highest, lowest, atr, supertrend, adx,
bollinger_upper, bollinger_lower — applied to any candle field (open/high/low/close/volume)
with configurable period and optional offset.
### Composed indicators (apply_func)
Apply rolling functions to arbitrary expressions: EMA of EMA, Hull MA (WMA of expression),
VWAP (sum of close*volume / sum of volume), standard deviation of returns, etc.
### Conditions
compare (>, <, >=, <=, ==), cross_over, cross_under — for event detection.
all_of, any_of, not — boolean combinators.
event_count — count how many times a condition fired in last N bars.
bars_since — how many bars since a condition was last true.
### Position state (Phase 1 — newly available)
entry_price — average entry price of current position
position_quantity — size of current position
unrealised_pnl — current unrealised P&L
bars_since_entry — complete bars elapsed since position was opened
balance — free balance of a named asset (e.g. "usdt", "usdc")
### Dynamic quantity
Action quantity can be a fixed string ("0.001") or an Expr for dynamic sizing.
ATR-based sizing, percent-of-balance, etc.
### Multi-timeframe
Any expression can reference a different timeframe via "timeframe" field.
Use higher timeframes as trend filters, lower timeframes for entry precision.
## Strategy families to explore
1. **Trend-following**: Moving average crossovers, breakouts above N-bar highs,
ADX filter for trend strength. Risk: whipsaws in ranging markets.
2. **Mean reversion**: RSI oversold/overbought, Bollinger band touches, deviation
from moving average. Risk: trending markets run against you.
3. **Momentum**: Rate of change, volume confirmation, relative strength.
Risk: momentum exhaustion, late entry.
4. **Volatility breakout**: ATR-based bands, Bollinger squeeze → expansion,
Supertrend flips. Risk: false breakouts.
5. **Multi-timeframe filtered**: Higher TF trend filter + lower TF entry signal.
E.g. daily EMA trend + 4h RSI entry. Generally more robust than single-TF.
6. **Composite / hybrid**: Combine families. Trend filter + mean-reversion entry.
Momentum confirmation + volatility sizing.
## Risk management (always include)
Every strategy MUST have:
- A stop-loss: use entry_price with a percentage or ATR-based offset
- A time-based exit: use bars_since_entry to avoid holding losers indefinitely
- Reasonable position sizing: prefer ATR-based or percent-of-balance over fixed quantity
## How to respond
You must respond with ONLY a valid JSON object — the strategy config.
No prose, no markdown explanation, no commentary.
Just the raw JSON starting with {{ and ending with }}.
The JSON must be a valid strategy with "type": "rule_based".
Use "usdc" (not "usdt") as the quote asset for balance expressions.
## Interpreting backtest results
When I share results from previous iterations, use them to guide your next strategy:
- **Zero trades**: The entry conditions are too restrictive or never co-occur.
Relax thresholds, simplify conditions, or check if the indicator periods make
sense for the candle interval.
- **Many trades but negative PnL**: The entry signal has no edge, or the exit
logic is poor. Try different indicator combinations, add trend filters, or
improve stop-loss placement.
- **Few trades, slightly positive**: Promising direction but not statistically
significant. Try to make the signal fire more often (lower thresholds, shorter
periods) while preserving the edge.
- **Good Sharpe but low profit factor**: Wins are small relative to losses.
Tighten stop-losses or add a profit target.
- **Good profit factor but negative Sharpe**: High variance. Add position sizing
or volatility filters to reduce exposure during chaotic periods.
- **Condition audit shows one condition always true/false**: That condition is
redundant or broken. Remove it or adjust its parameters.
## Anti-patterns to avoid
- Don't use the same indicator for both entry and exit (circular logic)
- Don't set RSI thresholds at extreme values (< 10 or > 90) — too rare to fire
- Don't use very short periods (< 5) on high timeframes — noisy
- Don't use very long periods (> 100) on low timeframes — too slow to react
- Don't create strategies with more than 5-6 conditions — overfitting risk
- Don't ignore fees — a strategy needs to overcome 0.1% per round trip
- Always gate buy rules with position state "flat" and sell rules with "long"
"##
)
}
/// Build the user message for the first iteration (no prior results).
pub fn initial_prompt(instruments: &[String], candle_intervals: &[String]) -> String {
format!(
r#"Design a trading strategy for crypto spot markets.
Available instruments: {}
Available candle intervals: {}
Start with a multi-timeframe trend-following approach with proper risk management
(stop-loss, time exit, and ATR-based position sizing). Use "usdc" as the quote asset.
Respond with ONLY the strategy JSON."#,
instruments.join(", "),
candle_intervals.join(", "),
)
}
/// Build the user message for subsequent iterations, including prior results.
pub fn iteration_prompt(
iteration: u32,
results_history: &str,
best_so_far: Option<&str>,
) -> String {
let best_section = match best_so_far {
Some(strat) => format!(
"\n\nBest strategy so far:\n```json\n{strat}\n```\n\n\
You may refine this strategy or try something completely different."
),
None => String::from(
"\n\nNo promising strategies found yet. Try a different approach — \
different indicator family, different timeframe, different entry logic."
),
};
format!(
r#"Iteration {iteration}. Here are the results from all previous backtests:
{results_history}
{best_section}
Based on these results, design the next strategy to test. Learn from what worked
and what didn't. If a strategy family consistently fails, try a different one.
Respond with ONLY the strategy JSON."#,
)
}

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