chore: init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1838
Cargo.lock
generated
Normal file
1838
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
93
readme.md
Normal 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
420
src/agent.rs
Normal 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
136
src/claude.rs
Normal 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
122
src/config.rs
Normal 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
422
src/dsl-schema.json
Normal 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
31
src/main.rs
Normal 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
180
src/prompts.rs
Normal 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
249
src/swym.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user