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

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