Compare commits
3 Commits
fc9b7e094a
...
fb1145acae
| Author | SHA1 | Date | |
|---|---|---|---|
|
fb1145acae
|
|||
|
c7a2d65539
|
|||
|
292c101859
|
39
src/agent.rs
39
src/agent.rs
@@ -69,17 +69,24 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
instruments.len()
|
||||
);
|
||||
let mut available_intervals: Vec<String> = Vec::new();
|
||||
// Store coverage so we can compute the effective backtest start after intersecting intervals.
|
||||
let mut all_coverage: Vec<(&Instrument, Vec<crate::swym::CandleCoverage>)> = 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);
|
||||
let interval_ranges: Vec<String> = coverage
|
||||
.iter()
|
||||
.map(|c| format!("{}({} – {})", c.interval, c.first_open, c.last_close))
|
||||
.collect();
|
||||
info!("{}: {}", inst.symbol, interval_ranges.join(", "));
|
||||
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()));
|
||||
}
|
||||
all_coverage.push((inst, coverage));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("could not check coverage for {}: {e}", inst.symbol);
|
||||
@@ -91,6 +98,24 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
}
|
||||
info!("common intervals: {:?}", available_intervals);
|
||||
|
||||
// Derive the effective backtest start: take the latest first_open across all instruments
|
||||
// for the common intervals so that strategies can always find their required candle data.
|
||||
let mut effective_backtest_from = cli.backtest_from.clone();
|
||||
for (inst, coverage) in &all_coverage {
|
||||
for c in coverage {
|
||||
if available_intervals.contains(&c.interval) && c.first_open > effective_backtest_from {
|
||||
warn!(
|
||||
"{} {}: data starts {}, clamping backtest_from from {} → {}",
|
||||
inst.symbol, c.interval, c.first_open, effective_backtest_from, c.first_open
|
||||
);
|
||||
effective_backtest_from = c.first_open.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective_backtest_from != cli.backtest_from {
|
||||
info!("effective backtest_from: {effective_backtest_from}");
|
||||
}
|
||||
|
||||
// Load DSL schema for the system prompt
|
||||
let schema = include_str!("dsl-schema.json");
|
||||
let system = prompts::system_prompt(schema);
|
||||
@@ -200,12 +225,12 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
let mut results: Vec<BacktestResult> = Vec::new();
|
||||
|
||||
for inst in &instruments {
|
||||
info!("backtesting {} on {}...", inst.symbol, cli.backtest_from);
|
||||
info!("backtesting {} on {}...", inst.symbol, effective_backtest_from);
|
||||
match run_single_backtest(
|
||||
&swym,
|
||||
inst,
|
||||
&strategy,
|
||||
&cli.backtest_from,
|
||||
&effective_backtest_from,
|
||||
&cli.backtest_to,
|
||||
&cli.initial_balance,
|
||||
&cli.fees_percent,
|
||||
@@ -393,7 +418,13 @@ async fn run_single_backtest(
|
||||
.await
|
||||
.context("poll")?;
|
||||
|
||||
Ok(BacktestResult::from_response(&final_resp, &inst.symbol))
|
||||
Ok(BacktestResult::from_response(
|
||||
&final_resp,
|
||||
&inst.symbol,
|
||||
&inst.exchange,
|
||||
&inst.base(),
|
||||
&inst.quote(),
|
||||
))
|
||||
}
|
||||
|
||||
fn save_validated_strategy(
|
||||
|
||||
190
src/prompts.rs
190
src/prompts.rs
@@ -50,9 +50,9 @@ 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.
|
||||
### Quantity
|
||||
Action quantity MUST be a fixed decimal string, e.g. `"quantity": "0.001"`.
|
||||
NEVER use an expression object for quantity — only plain decimal strings are accepted.
|
||||
|
||||
### Multi-timeframe
|
||||
Any expression can reference a different timeframe via "timeframe" field.
|
||||
@@ -119,6 +119,188 @@ When I share results from previous iterations, use them to guide your next strat
|
||||
- **Condition audit shows one condition always true/false**: That condition is
|
||||
redundant or broken. Remove it or adjust its parameters.
|
||||
|
||||
## Critical: expression kinds (common mistakes)
|
||||
|
||||
These are the ONLY valid values for `"kind"` inside an `Expr` object:
|
||||
`literal`, `field`, `func`, `bin_op`, `apply_func`, `unary_op`, `bars_since`,
|
||||
`entry_price`, `position_quantity`, `unrealised_pnl`, `bars_since_entry`, `balance`
|
||||
|
||||
Common mistakes to NEVER make:
|
||||
- `"kind": "rsi"` inside an Expr is WRONG. `rsi` is a *Condition* kind, not an Expr.
|
||||
To use RSI value in a `compare` expression use: `{{"kind":"func","name":"rsi","period":14}}`
|
||||
- `"kind": "bars_since_entry"` is a valid standalone Expr (no extra fields needed).
|
||||
Do NOT put `"bars_since_entry"` as a `"name"` inside `{{"kind":"func",...}}` — that is WRONG.
|
||||
- `"kind": "expr_field"` does NOT exist. Use `{{"kind":"field","field":"close"}}`.
|
||||
- `rsi`, `adx`, `supertrend` are NOT valid inside `apply_func`. Use only `apply_func`
|
||||
with `ApplyFuncName` values: `highest`, `lowest`, `sma`, `ema`, `wma`, `std_dev`, `sum`,
|
||||
`bollinger_upper`, `bollinger_lower`.
|
||||
|
||||
## Working examples
|
||||
|
||||
### Example 1 — EMA crossover with trend filter and position exits
|
||||
|
||||
```json
|
||||
{{
|
||||
"type": "rule_based",
|
||||
"candle_interval": "1h",
|
||||
"rules": [
|
||||
{{
|
||||
"comment": "Buy: EMA9 crosses above EMA21 while price is above EMA50",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "flat"}},
|
||||
{{"kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above"}},
|
||||
{{"kind": "ema_trend", "period": 50, "direction": "above"}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: EMA9 crosses below EMA21, OR 2% stop-loss, OR 72-bar time exit",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "long"}},
|
||||
{{
|
||||
"kind": "any_of",
|
||||
"conditions": [
|
||||
{{"kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "below"}},
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "field", "field": "close"}},
|
||||
"op": "<",
|
||||
"right": {{"kind": "bin_op", "op": "mul", "left": {{"kind": "entry_price"}}, "right": {{"kind": "literal", "value": "0.98"}}}}
|
||||
}},
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "bars_since_entry"}},
|
||||
"op": ">=",
|
||||
"right": {{"kind": "literal", "value": "72"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
### Example 2 — RSI mean-reversion with Bollinger band confirmation
|
||||
|
||||
```json
|
||||
{{
|
||||
"type": "rule_based",
|
||||
"candle_interval": "4h",
|
||||
"rules": [
|
||||
{{
|
||||
"comment": "Buy: RSI below 35 AND price below lower Bollinger band",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "flat"}},
|
||||
{{"kind": "rsi", "period": 14, "threshold": "35", "comparison": "below"}},
|
||||
{{"kind": "bollinger", "period": 20, "band": "below_lower"}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: RSI recovers above 55, OR 3% stop-loss, OR 48-bar time exit",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "long"}},
|
||||
{{
|
||||
"kind": "any_of",
|
||||
"conditions": [
|
||||
{{"kind": "rsi", "period": 14, "threshold": "55", "comparison": "above"}},
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "field", "field": "close"}},
|
||||
"op": "<",
|
||||
"right": {{"kind": "bin_op", "op": "mul", "left": {{"kind": "entry_price"}}, "right": {{"kind": "literal", "value": "0.97"}}}}
|
||||
}},
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "bars_since_entry"}},
|
||||
"op": ">=",
|
||||
"right": {{"kind": "literal", "value": "48"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
### Example 3 — ATR breakout with ATR-based stop-loss
|
||||
|
||||
```json
|
||||
{{
|
||||
"type": "rule_based",
|
||||
"candle_interval": "1h",
|
||||
"rules": [
|
||||
{{
|
||||
"comment": "Buy: close crosses above 20-bar high while EMA50 confirms uptrend",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "flat"}},
|
||||
{{"kind": "ema_trend", "period": 50, "direction": "above"}},
|
||||
{{
|
||||
"kind": "cross_over",
|
||||
"left": {{"kind": "field", "field": "close"}},
|
||||
"right": {{"kind": "func", "name": "highest", "field": "high", "period": 20, "offset": 1}}
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: 2-ATR stop-loss below entry price, OR 48-bar time exit",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "long"}},
|
||||
{{
|
||||
"kind": "any_of",
|
||||
"conditions": [
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "field", "field": "close"}},
|
||||
"op": "<",
|
||||
"right": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "entry_price"}},
|
||||
"right": {{
|
||||
"kind": "bin_op", "op": "mul",
|
||||
"left": {{"kind": "func", "name": "atr", "period": 14}},
|
||||
"right": {{"kind": "literal", "value": "2.0"}}
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"kind": "compare",
|
||||
"left": {{"kind": "bars_since_entry"}},
|
||||
"op": ">=",
|
||||
"right": {{"kind": "literal", "value": "48"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
- Don't use the same indicator for both entry and exit (circular logic)
|
||||
@@ -128,6 +310,8 @@ When I share results from previous iterations, use them to guide your next strat
|
||||
- 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"
|
||||
- Never add a short-entry (sell when flat) rule — spot markets are long-only
|
||||
- Never use an expression object for `quantity` — it must always be a plain decimal string like `"0.001"`
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
133
src/swym.rs
133
src/swym.rs
@@ -52,22 +52,47 @@ pub struct BacktestResult {
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self {
|
||||
/// Parse a backtest response.
|
||||
///
|
||||
/// `exchange`, `base`, `quote` are needed to derive the instrument key used
|
||||
/// in the `result_summary.instruments` map (e.g. `binancespot-eth_usdc`).
|
||||
pub fn from_response(
|
||||
resp: &PaperRunResponse,
|
||||
instrument: &str,
|
||||
exchange: &str,
|
||||
base: &str,
|
||||
quote: &str,
|
||||
) -> Self {
|
||||
let summary = resp.result_summary.as_ref();
|
||||
if let Some(s) = summary {
|
||||
tracing::debug!("[{instrument}] result_summary: {}", serde_json::to_string(s).unwrap_or_default());
|
||||
} else {
|
||||
tracing::debug!("[{instrument}] result_summary: null");
|
||||
}
|
||||
|
||||
// The API key for per-instrument stats: "binance_spot" + "eth" + "usdc" → "binancespot-eth_usdc"
|
||||
let inst_key = format!("{}-{}_{}", exchange.replace('_', ""), base, quote);
|
||||
|
||||
let total_positions = summary.and_then(|s| {
|
||||
s["backtest_metadata"]["position_count"].as_u64().map(|v| v as u32)
|
||||
});
|
||||
|
||||
let inst_stats = summary.and_then(|s| s["instruments"].get(&inst_key));
|
||||
|
||||
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()),
|
||||
total_positions,
|
||||
winning_positions: None,
|
||||
losing_positions: None,
|
||||
win_rate: inst_stats.and_then(|s| parse_ratio_value(&s["win_rate"])),
|
||||
profit_factor: inst_stats.and_then(|s| parse_ratio_value(&s["profit_factor"])),
|
||||
total_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])),
|
||||
net_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])),
|
||||
sharpe_ratio: inst_stats.and_then(|s| parse_ratio_value(&s["sharpe_ratio"])),
|
||||
total_fees: None,
|
||||
avg_bars_in_trade: None,
|
||||
error_message: resp.error_message.clone(),
|
||||
condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()),
|
||||
}
|
||||
@@ -83,14 +108,13 @@ impl BacktestResult {
|
||||
);
|
||||
}
|
||||
let mut s = format!(
|
||||
"[{}] trades={} win_rate={:.1}% pf={:.2} net_pnl={:.2} sharpe={:.2} avg_bars={:.1}",
|
||||
"[{}] trades={} win_rate={:.1}% pf={:.2} net_pnl={:.2} sharpe={:.2}",
|
||||
self.instrument,
|
||||
self.total_positions.unwrap_or(0),
|
||||
self.win_rate.unwrap_or(0.0) * 100.0,
|
||||
self.profit_factor.unwrap_or(0.0),
|
||||
self.net_pnl.unwrap_or(0.0),
|
||||
self.sharpe_ratio.unwrap_or(0.0),
|
||||
self.avg_bars_in_trade.unwrap_or(0.0),
|
||||
);
|
||||
if self.total_positions.unwrap_or(0) == 0 {
|
||||
if let Some(audit) = &self.condition_audit_summary {
|
||||
@@ -113,33 +137,82 @@ impl BacktestResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `{"interval": null, "value": "123.45"}` ratio wrapper.
|
||||
/// Returns `None` for null, missing, or sentinel values (Decimal::MAX ≈ 7.9e28).
|
||||
fn parse_ratio_value(v: &Value) -> Option<f64> {
|
||||
let s = v.get("value")?.as_str()?;
|
||||
let f: f64 = s.parse().ok()?;
|
||||
if f.abs() > 1e20 { None } else { Some(f) }
|
||||
}
|
||||
|
||||
/// Parse a plain decimal string JSON value.
|
||||
/// Returns `None` for null, missing, or sentinel values.
|
||||
fn parse_decimal_str(v: &Value) -> Option<f64> {
|
||||
let f: f64 = v.as_str()?.parse().ok()?;
|
||||
if f.abs() > 1e20 { None } else { Some(f) }
|
||||
}
|
||||
|
||||
/// Render a condition_audit_summary Value into a compact one-line string.
|
||||
/// Handles both object and array shapes we might receive from the API.
|
||||
///
|
||||
/// Handles the primary shape from the swym API:
|
||||
/// `{"rules": [{rule_index, times_fired, conditions: [{kind, path, true_count, insufficient_data_count}]}], "total_bars": N}`
|
||||
fn format_audit_summary(audit: &Value) -> String {
|
||||
match audit {
|
||||
Value::Object(map) => map
|
||||
// Primary shape: {"rules": [...], "total_bars": N}
|
||||
if let Some(rules) = audit.get("rules").and_then(|r| r.as_array()) {
|
||||
let total_bars = audit["total_bars"].as_u64().unwrap_or(0);
|
||||
let parts: Vec<String> = rules
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
Value::Array(arr) => arr
|
||||
.map(|rule| {
|
||||
let idx = rule["rule_index"].as_u64().unwrap_or(0);
|
||||
let fired = rule["times_fired"].as_u64().unwrap_or(0);
|
||||
let cond_summary = rule["conditions"]
|
||||
.as_array()
|
||||
.map(|conds| {
|
||||
conds
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let kind = c["kind"].as_str().unwrap_or("?");
|
||||
let true_count = c["true_count"].as_u64().unwrap_or(0);
|
||||
let nodata = c["insufficient_data_count"].as_u64().unwrap_or(0);
|
||||
let evaluated = total_bars.saturating_sub(nodata);
|
||||
if nodata > total_bars / 2 {
|
||||
format!("{kind}:{true_count}/{evaluated}[{nodata}nd]")
|
||||
} else {
|
||||
format!("{kind}:{true_count}/{evaluated}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("R{idx}(f={fired})[{cond_summary}]")
|
||||
})
|
||||
.collect();
|
||||
return parts.join(" | ");
|
||||
}
|
||||
// Fallback: plain array of named conditions
|
||||
if let Some(arr) = audit.as_array() {
|
||||
return arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let name = item.get("name").or_else(|| item.get("condition"))?.as_str()?;
|
||||
// Try common field names for hit counts
|
||||
if let (Some(true_count), Some(total)) = (
|
||||
item.get("true_count").or_else(|| item.get("hit_count")).or_else(|| item.get("true_bars")).and_then(|v| v.as_u64()),
|
||||
item.get("total").or_else(|| item.get("total_bars")).or_else(|| item.get("evaluated")).and_then(|v| v.as_u64()),
|
||||
) {
|
||||
Some(format!("{name}: {true_count}/{total}"))
|
||||
} else {
|
||||
Some(format!("{name}: {item}"))
|
||||
let true_count = item
|
||||
.get("true_count")
|
||||
.or_else(|| item.get("hit_count"))
|
||||
.and_then(|v| v.as_u64());
|
||||
let total = item
|
||||
.get("total")
|
||||
.or_else(|| item.get("total_bars"))
|
||||
.and_then(|v| v.as_u64());
|
||||
match (true_count, total) {
|
||||
(Some(t), Some(n)) => Some(format!("{name}: {t}/{n}")),
|
||||
_ => Some(format!("{name}: {item}")),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
other => other.to_string(),
|
||||
.join(", ");
|
||||
}
|
||||
audit.to_string()
|
||||
}
|
||||
|
||||
impl SwymClient {
|
||||
|
||||
Reference in New Issue
Block a user