Compare commits
11 Commits
51e452b607
...
87d31f8d7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
87d31f8d7e
|
|||
|
3892ab37c1
|
|||
|
85896752f2
|
|||
|
ee260ea4d5
|
|||
|
3f8d4de7fb
|
|||
|
7e1ff51ae0
|
|||
|
5146b3f764
|
|||
|
759439313e
|
|||
|
9a7761b452
|
|||
|
8d53d6383d
|
|||
|
55e41b6795
|
92
src/agent.rs
92
src/agent.rs
@@ -265,6 +265,12 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
content: response_text.clone(),
|
||||
});
|
||||
|
||||
// Log R1 reasoning chain at debug level so it can be inspected when
|
||||
// the model makes repeated DSL mistakes (run with RUST_LOG=debug).
|
||||
if let Some(thinking) = claude::extract_think_content(&response_text) {
|
||||
debug!("R1 thinking ({} chars):\n{}", thinking.len(), thinking);
|
||||
}
|
||||
|
||||
// Extract strategy JSON
|
||||
let strategy = match claude::extract_json(&response_text) {
|
||||
Ok(s) => s,
|
||||
@@ -319,7 +325,7 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
let strat_path = cli.output_dir.join(format!("strategy_{iteration:03}.json"));
|
||||
std::fs::write(&strat_path, serde_json::to_string_pretty(&strategy)?)?;
|
||||
|
||||
// Hard validation errors: skip the expensive backtest and give immediate feedback.
|
||||
// Hard client-side validation errors: skip without hitting the API.
|
||||
if !hard_errors.is_empty() {
|
||||
let record = IterationRecord {
|
||||
iteration,
|
||||
@@ -332,6 +338,40 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Server-side validation: call /strategies/validate to get ALL DSL errors
|
||||
// at once before submitting a backtest. This avoids burning a full backtest
|
||||
// round-trip on a structurally invalid strategy and gives the model a
|
||||
// complete list of errors to fix in one shot.
|
||||
match swym.validate_strategy(&strategy).await {
|
||||
Ok(api_errors) if !api_errors.is_empty() => {
|
||||
for e in &api_errors {
|
||||
warn!(" DSL error at {}: {}", e.path.as_deref().unwrap_or("(top-level)"), e.message);
|
||||
}
|
||||
let error_notes: Vec<String> = api_errors
|
||||
.iter()
|
||||
.map(|e| format!("DSL error at {}: {}", e.path.as_deref().unwrap_or("(top-level)"), e.message))
|
||||
.collect();
|
||||
validation_notes.extend(error_notes);
|
||||
let record = IterationRecord {
|
||||
iteration,
|
||||
strategy: strategy.clone(),
|
||||
results: vec![],
|
||||
validation_notes,
|
||||
};
|
||||
info!("{}", record.summary());
|
||||
history.push(record);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Valid — proceed to backtest
|
||||
}
|
||||
Err(e) => {
|
||||
// Network/parse failure from the validate endpoint — log and proceed
|
||||
// anyway so a transient API issue doesn't stall the run.
|
||||
warn!(" strategy validation request failed (proceeding): {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
// Run backtests against all instruments (in-sample)
|
||||
let mut results: Vec<BacktestResult> = Vec::new();
|
||||
|
||||
@@ -530,13 +570,7 @@ async fn run_single_backtest(
|
||||
.await
|
||||
.context("poll")?;
|
||||
|
||||
Ok(BacktestResult::from_response(
|
||||
&final_resp,
|
||||
&inst.symbol,
|
||||
&inst.exchange,
|
||||
&inst.base(),
|
||||
&inst.quote(),
|
||||
))
|
||||
Ok(BacktestResult::from_response(&final_resp, &inst.symbol))
|
||||
}
|
||||
|
||||
fn save_validated_strategy(
|
||||
@@ -665,6 +699,48 @@ pub fn diagnose_history(history: &[IterationRecord]) -> (String, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Repeated API error detection ---
|
||||
// If the same DSL error variant has appeared in 2+ consecutive iterations,
|
||||
// call it out explicitly so the model knows exactly what to fix.
|
||||
{
|
||||
let recent_errors: Vec<String> = history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.flat_map(|rec| rec.results.iter())
|
||||
.filter_map(|r| r.error_message.as_deref())
|
||||
.filter(|e| e.contains("unknown variant"))
|
||||
.map(|e| {
|
||||
// Extract the variant name: "unknown variant `foo`, expected ..."
|
||||
e.split('`')
|
||||
.nth(1)
|
||||
.unwrap_or(e)
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if recent_errors.len() >= 2 {
|
||||
// Find the most frequent bad variant
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for v in &recent_errors {
|
||||
*counts.entry(v.as_str()).or_default() += 1;
|
||||
}
|
||||
if let Some((bad_variant, count)) = counts.into_iter().max_by_key(|(_, c)| *c) {
|
||||
if count >= 2 {
|
||||
notes.push(format!(
|
||||
"⚠ DSL ERROR (repeated {count}×): the swym API rejected \
|
||||
`{bad_variant}` as an unknown variant. \
|
||||
Check the 'Critical: expression kinds' section — \
|
||||
`{bad_variant}` may be a FuncName (use inside \
|
||||
{{\"kind\":\"func\",\"name\":\"{bad_variant}\",...}}) \
|
||||
or it may not be supported at all. \
|
||||
Use ONLY the documented kinds and func names."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zero-trade check ---
|
||||
let zero_trade_iters = history
|
||||
.iter()
|
||||
|
||||
@@ -213,6 +213,14 @@ fn lmstudio_context_length(json: &Value, model_id: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the content of the first `<think>` block, if any.
|
||||
/// Used for debug logging of R1 reasoning chains.
|
||||
pub fn extract_think_content(text: &str) -> Option<String> {
|
||||
let start = text.find("<think>")? + "<think>".len();
|
||||
let end = text[start..].find("</think>").map(|i| start + i)?;
|
||||
Some(text[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract a JSON object from a model response text.
|
||||
/// Handles markdown code fences and R1-style `<think>...</think>` blocks.
|
||||
pub fn extract_json(text: &str) -> Result<Value> {
|
||||
|
||||
@@ -66,11 +66,48 @@
|
||||
"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."
|
||||
"description": "Per-order size in base asset units. Fixed decimal string (e.g. \"0.001\"), a declarative SizingMethod object, or a dynamic Expr object. When a method or Expr returns None the order is skipped; negative values are clamped to zero.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/DecimalString" },
|
||||
{ "$ref": "#/definitions/SizingFixedSum" },
|
||||
{ "$ref": "#/definitions/SizingPercentOfBalance" },
|
||||
{ "$ref": "#/definitions/SizingFixedUnits" },
|
||||
{ "$ref": "#/definitions/Expr" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"SizingFixedSum": {
|
||||
"description": "Buy `amount` worth of quote currency at the current price. qty = amount / current_price.",
|
||||
"type": "object",
|
||||
"required": ["method", "amount"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": { "const": "fixed_sum" },
|
||||
"amount": { "$ref": "#/definitions/DecimalString", "description": "Quote-currency amount, e.g. \"500\" means buy $500 worth." }
|
||||
}
|
||||
},
|
||||
"SizingPercentOfBalance": {
|
||||
"description": "Buy percent% of the named asset's free balance worth of base asset. qty = balance(asset) * percent/100 / current_price.",
|
||||
"type": "object",
|
||||
"required": ["method", "percent", "asset"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": { "const": "percent_of_balance" },
|
||||
"percent": { "$ref": "#/definitions/DecimalString", "description": "Percentage, e.g. \"2\" means 2% of the free balance." },
|
||||
"asset": { "type": "string", "description": "Asset name to look up, e.g. \"usdc\". Matched case-insensitively." }
|
||||
}
|
||||
},
|
||||
"SizingFixedUnits": {
|
||||
"description": "Buy exactly `units` of base asset. Semantic alias for a fixed decimal quantity.",
|
||||
"type": "object",
|
||||
"required": ["method", "units"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": { "const": "fixed_units" },
|
||||
"units": { "$ref": "#/definitions/DecimalString", "description": "Base asset quantity, e.g. \"0.01\" means 0.01 BTC." }
|
||||
}
|
||||
},
|
||||
"Rule": {
|
||||
"type": "object",
|
||||
"required": ["when", "then"],
|
||||
@@ -280,7 +317,12 @@
|
||||
{ "$ref": "#/definitions/ExprBinOp" },
|
||||
{ "$ref": "#/definitions/ExprApplyFunc" },
|
||||
{ "$ref": "#/definitions/ExprUnaryOp" },
|
||||
{ "$ref": "#/definitions/ExprBarsSince" }
|
||||
{ "$ref": "#/definitions/ExprBarsSince" },
|
||||
{ "$ref": "#/definitions/ExprEntryPrice" },
|
||||
{ "$ref": "#/definitions/ExprPositionQuantity" },
|
||||
{ "$ref": "#/definitions/ExprUnrealisedPnl" },
|
||||
{ "$ref": "#/definitions/ExprBarsSinceEntry" },
|
||||
{ "$ref": "#/definitions/ExprBalance" }
|
||||
]
|
||||
},
|
||||
"ExprLiteral": {
|
||||
@@ -417,6 +459,55 @@
|
||||
"description": "Maximum bars to look back."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExprEntryPrice": {
|
||||
"description": "Volume-weighted average entry price of the current open position. Returns None when flat.",
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "entry_price" }
|
||||
}
|
||||
},
|
||||
"ExprPositionQuantity": {
|
||||
"description": "Absolute quantity of the current open position in base asset units. Returns None when flat.",
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "position_quantity" }
|
||||
}
|
||||
},
|
||||
"ExprUnrealisedPnl": {
|
||||
"description": "Estimated unrealised PnL of the current open position in quote asset. Returns None when flat.",
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "unrealised_pnl" }
|
||||
}
|
||||
},
|
||||
"ExprBarsSinceEntry": {
|
||||
"description": "Number of complete primary-interval bars elapsed since the current position was opened. Computed as floor((now - time_enter) / primary_interval_secs). Returns None when flat.",
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "bars_since_entry" }
|
||||
}
|
||||
},
|
||||
"ExprBalance": {
|
||||
"description": "Free balance of the named asset (matched case-insensitively). Returns None when the asset is not found or balance data is unavailable.",
|
||||
"type": "object",
|
||||
"required": ["kind", "asset"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "balance" },
|
||||
"asset": {
|
||||
"type": "string",
|
||||
"description": "Internal asset name, e.g. \"usdt\", \"btc\". Case-insensitive."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
177
src/prompts.rs
177
src/prompts.rs
@@ -52,6 +52,10 @@ 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.
|
||||
|
||||
These are FuncNames used INSIDE `{{"kind":"func","name":"...","period":N}}` expressions.
|
||||
`atr`, `adx`, and `supertrend` use OHLC internally and ignore the `field` parameter.
|
||||
To use ADX as a trend-strength filter: `{{"kind":"compare","left":{{"kind":"func","name":"adx","period":14}},"op":">","right":{{"kind":"literal","value":"25"}}}}`
|
||||
|
||||
### 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.
|
||||
@@ -70,11 +74,49 @@ bars_since_entry — complete bars elapsed since position was opened
|
||||
balance — free balance of a named asset (e.g. "usdt", "usdc")
|
||||
|
||||
### Quantity
|
||||
Action quantity MUST be a fixed decimal string that parses as a floating-point number,
|
||||
e.g. `"quantity": "0.001"`.
|
||||
NEVER use an expression object for quantity — only plain decimal strings are accepted.
|
||||
NEVER use placeholder strings like `"ATR_SIZED"`, `"FULL_BALANCE"`, `"percent_of_balance"`,
|
||||
`"dynamic"`, or any non-numeric string — these will be rejected immediately.
|
||||
Action quantity accepts four forms — pick the simplest one for your intent:
|
||||
|
||||
**1. Declarative sizing methods (preferred — instrument-agnostic, readable):**
|
||||
|
||||
Spend a fixed quote amount (e.g. $500 worth of base at current price):
|
||||
```json
|
||||
{{"method":"fixed_sum","amount":"500"}}
|
||||
```
|
||||
|
||||
Spend a percentage of free quote balance (e.g. 5% of USDC):
|
||||
```json
|
||||
{{"method":"percent_of_balance","percent":"5","asset":"usdc"}}
|
||||
```
|
||||
|
||||
Buy a fixed number of base units (semantic alias for a decimal string):
|
||||
```json
|
||||
{{"method":"fixed_units","units":"0.01"}}
|
||||
```
|
||||
|
||||
**2. Plain decimal string** — use only when you have a specific reason:
|
||||
`"0.01"` (0.01 BTC, 3.0 ETH, 50.0 SOL — instrument-specific, not portable)
|
||||
|
||||
**3. Expr** — for dynamic sizing not covered by the methods above, e.g. ATR-based:
|
||||
```json
|
||||
{{"kind":"bin_op","op":"div",
|
||||
"left":{{"kind":"literal","value":"200"}},
|
||||
"right":{{"kind":"func","name":"atr","period":14}}}}
|
||||
```
|
||||
|
||||
**4. Exit rules** — use `position_quantity` to close the exact open size:
|
||||
```json
|
||||
{{"kind":"position_quantity"}}
|
||||
```
|
||||
Alternatively, `"9999"` works for exits: sell quantities are automatically capped to the open
|
||||
position size, so a large fixed number is equivalent to `position_quantity`.
|
||||
|
||||
CRITICAL mistakes to never make:
|
||||
- `{{"method":"position_quantity"}}` is WRONG — `position_quantity` is an Expr, not a SizingMethod.
|
||||
CORRECT: `{{"kind":"position_quantity"}}`. The `"method"` field belongs ONLY to the three
|
||||
declarative sizing objects (`fixed_sum`, `percent_of_balance`, `fixed_units`).
|
||||
- `{{"method":"fixed_sum","amount":"100","multiplier":"2.0"}}` is WRONG — `fixed_sum` has no
|
||||
`multiplier` field. Only `amount` is accepted alongside `method`.
|
||||
- NEVER add extra fields to SizingMethod objects — they use `additionalProperties: false`.
|
||||
|
||||
### Multi-timeframe
|
||||
Any expression can reference a different timeframe via "timeframe" field.
|
||||
@@ -146,11 +188,28 @@ Common mistakes to NEVER make:
|
||||
- `"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"}}`.
|
||||
- Every Expr object MUST have a `"kind"` field. `{{"field":"close"}}` is WRONG — missing `"kind"`.
|
||||
CORRECT: `{{"kind":"field","field":"close"}}`. The `"kind"` is never optional.
|
||||
- `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`.
|
||||
- `volume` is a candle FIELD, not a func name. Access it as `{{"kind":"field","field":"volume"}}`.
|
||||
To compute EMA of volume: `{{"kind":"apply_func","name":"ema","period":20,"expr":{{"kind":"field","field":"volume"}}}}`.
|
||||
To compute EMA of volume: `{{"kind":"apply_func","name":"ema","period":20,"input":{{"kind":"field","field":"volume"}}}}`.
|
||||
- `bollinger_upper` and `bollinger_lower` are FUNC NAMES, not Expr kinds. To compare close to the upper band:
|
||||
`{{"kind":"compare","left":{{"kind":"field","field":"close"}},"op":">","right":{{"kind":"func","name":"bollinger_upper","period":20}}}}`
|
||||
NEVER write `{{"kind":"bollinger_upper",...}}` — `bollinger_upper` is not an Expr kind.
|
||||
NEVER set `"field":"bollinger_upper"` on a func Expr — `bollinger_upper`/`bollinger_lower` have no `field`
|
||||
parameter; they compute from close internally. Just `{{"kind":"func","name":"bollinger_upper","period":20}}`.
|
||||
- The `{{"kind":"bollinger",...}}` Condition (shorthand) only accepts `"band": "above_upper"` or
|
||||
`"band": "below_lower"`. There is NO `above_lower` or `below_upper` — those are invalid and will be
|
||||
rejected. Use `above_upper` (price above the upper band) or `below_lower` (price below the lower band).
|
||||
- `adx` is a FUNC NAME, not a Condition kind. To filter for strong trends (ADX > 25):
|
||||
`{{"kind":"compare","left":{{"kind":"func","name":"adx","period":14}},"op":">","right":{{"kind":"literal","value":"25"}}}}`
|
||||
NEVER write `{{"kind":"adx",...}}` — `adx` is not a Condition kind, it is a FuncName used inside `{{"kind":"func",...}}`.
|
||||
- `roc` (rate of change), `hma` (Hull MA), `ma` (generic), `vwap`, `macd`, `cci`, `stoch` are NOT supported.
|
||||
Use `sma`, `ema`, `wma`, `rsi`, `atr`, `adx`, `supertrend`, `std_dev`, `sum`, `highest`, `lowest`,
|
||||
`bollinger_upper`, `bollinger_lower` only. There is no generic `ma` — use `sma` or `ema` explicitly.
|
||||
Hull MA can be approximated as: WMA(2*WMA(n/2) - WMA(n)) using `apply_func`.
|
||||
|
||||
## Working examples
|
||||
|
||||
@@ -171,7 +230,7 @@ Common mistakes to NEVER make:
|
||||
{{"kind": "ema_trend", "period": 50, "direction": "above"}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
"then": {{"side": "buy", "quantity": "0.01"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: EMA9 crosses below EMA21, OR 2% stop-loss, OR 72-bar time exit",
|
||||
@@ -199,7 +258,7 @@ Common mistakes to NEVER make:
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
"then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
@@ -222,7 +281,7 @@ Common mistakes to NEVER make:
|
||||
{{"kind": "bollinger", "period": 20, "band": "below_lower"}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
"then": {{"side": "buy", "quantity": "0.01"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: RSI recovers above 55, OR 3% stop-loss, OR 48-bar time exit",
|
||||
@@ -250,7 +309,7 @@ Common mistakes to NEVER make:
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
"then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
@@ -277,7 +336,7 @@ Common mistakes to NEVER make:
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.001"}}
|
||||
"then": {{"side": "buy", "quantity": "0.01"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: 2-ATR stop-loss below entry price, OR 48-bar time exit",
|
||||
@@ -312,12 +371,102 @@ Common mistakes to NEVER make:
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "sell", "quantity": "0.001"}}
|
||||
"then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
### Example 4 — MACD crossover (composed from primitives)
|
||||
|
||||
MACD has no native support, but can be composed from `func` and `apply_func`.
|
||||
The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD line.
|
||||
|
||||
```json
|
||||
{{
|
||||
"type": "rule_based",
|
||||
"candle_interval": "4h",
|
||||
"rules": [
|
||||
{{
|
||||
"comment": "Buy: MACD line crosses above signal line",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "flat"}},
|
||||
{{
|
||||
"kind": "cross_over",
|
||||
"left": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||
}},
|
||||
"right": {{
|
||||
"kind": "apply_func", "name": "ema", "period": 9,
|
||||
"input": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"then": {{"side": "buy", "quantity": "0.01"}}
|
||||
}},
|
||||
{{
|
||||
"comment": "Sell: MACD crosses below signal, OR 2% stop-loss, OR 72-bar time exit",
|
||||
"when": {{
|
||||
"kind": "all_of",
|
||||
"conditions": [
|
||||
{{"kind": "position", "state": "long"}},
|
||||
{{
|
||||
"kind": "any_of",
|
||||
"conditions": [
|
||||
{{
|
||||
"kind": "cross_under",
|
||||
"left": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||
}},
|
||||
"right": {{
|
||||
"kind": "apply_func", "name": "ema", "period": 9,
|
||||
"input": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"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": {{"kind": "position_quantity"}}}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
Key pattern: `apply_func` wraps any `Expr` tree using the `"input"` field (NOT `"expr"`).
|
||||
This enables EMA-of-expression (signal line), WMA-of-expression (Hull MA), or std_dev-of-returns.
|
||||
There is NO native `macd` func name — always compose it as `bin_op(sub, func(ema,12), func(ema,26))` as shown above.
|
||||
CRITICAL: `apply_func` uses `"input"`, not `"expr"`. Writing `"expr":` will be rejected by the API.
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
- Don't use the same indicator for both entry and exit (circular logic)
|
||||
@@ -328,8 +477,8 @@ Common mistakes to NEVER make:
|
||||
- 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"`
|
||||
- Never use a placeholder string for `quantity` — `"ATR_SIZED"`, `"FULL_BALANCE"`, `"dynamic"`, etc. are all invalid and will be rejected. Use `"0.001"` or similar.
|
||||
- Never use an expression object for `quantity` — it must always be a plain decimal string like `"0.01"`
|
||||
- Never use a placeholder string for `quantity` — `"ATR_SIZED"`, `"FULL_BALANCE"`, `"dynamic"`, etc. are all invalid and will be rejected. Use `"0.01"` or similar.
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
112
src/swym.rs
112
src/swym.rs
@@ -4,6 +4,21 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Response from `POST /api/v1/strategies/validate`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidationResponse {
|
||||
pub valid: bool,
|
||||
#[serde(default)]
|
||||
pub errors: Vec<ValidationError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ValidationError {
|
||||
/// Dotted JSON path to the offending field. Absent for top-level structural errors.
|
||||
pub path: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Client for the swym backtesting API.
|
||||
pub struct SwymClient {
|
||||
client: Client,
|
||||
@@ -30,6 +45,8 @@ pub struct CandleCoverage {
|
||||
pub first_open: String,
|
||||
pub last_close: String,
|
||||
pub count: u64,
|
||||
pub expected_count: Option<u64>,
|
||||
pub coverage_pct: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -52,16 +69,10 @@ pub struct BacktestResult {
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
/// 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`).
|
||||
/// Parse a backtest response using the flat summary fields added in swym patch 8fb410311.
|
||||
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 {
|
||||
@@ -70,28 +81,29 @@ impl BacktestResult {
|
||||
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));
|
||||
let total_positions = summary.and_then(|s| s["total_positions"].as_u64().map(|v| v as u32));
|
||||
let winning_positions = summary.and_then(|s| s["winning_positions"].as_u64().map(|v| v as u32));
|
||||
let losing_positions = summary.and_then(|s| s["losing_positions"].as_u64().map(|v| v as u32));
|
||||
let win_rate = summary.and_then(|s| parse_number(&s["win_rate"]));
|
||||
let profit_factor = summary.and_then(|s| parse_number(&s["profit_factor"]));
|
||||
let net_pnl = summary.and_then(|s| parse_number(&s["net_pnl"]));
|
||||
let total_pnl = summary.and_then(|s| parse_number(&s["total_pnl"]));
|
||||
let sharpe_ratio = summary.and_then(|s| parse_number(&s["sharpe_ratio"]));
|
||||
let total_fees = summary.and_then(|s| parse_number(&s["total_fees"]));
|
||||
|
||||
Self {
|
||||
run_id: resp.id,
|
||||
instrument: instrument.to_string(),
|
||||
status: resp.status.clone(),
|
||||
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,
|
||||
winning_positions,
|
||||
losing_positions,
|
||||
win_rate,
|
||||
profit_factor,
|
||||
total_pnl,
|
||||
net_pnl,
|
||||
sharpe_ratio,
|
||||
total_fees,
|
||||
avg_bars_in_trade: None,
|
||||
error_message: resp.error_message.clone(),
|
||||
condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()),
|
||||
@@ -129,26 +141,22 @@ impl BacktestResult {
|
||||
}
|
||||
|
||||
/// Is this result promising enough to warrant out-of-sample validation?
|
||||
/// Uses sharpe if available, otherwise falls back to net_pnl > 0.
|
||||
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
|
||||
if self.status != "complete" { return false; }
|
||||
if self.total_positions.unwrap_or(0) < min_trades { return false; }
|
||||
if self.net_pnl.unwrap_or(0.0) <= 0.0 { return false; }
|
||||
match self.sharpe_ratio {
|
||||
Some(sr) => sr > min_sharpe,
|
||||
None => true, // sharpe absent (e.g. 0 trades); net_pnl + trades is sufficient signal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
/// Parse a numeric JSON value — accepts either a plain JSON number or a decimal string.
|
||||
/// Returns `None` for null, missing, or sentinel values (>1e20 in magnitude).
|
||||
fn parse_number(v: &Value) -> Option<f64> {
|
||||
let f = v.as_f64().or_else(|| v.as_str()?.parse().ok())?;
|
||||
if f.abs() > 1e20 { None } else { Some(f) }
|
||||
}
|
||||
|
||||
@@ -254,6 +262,32 @@ impl SwymClient {
|
||||
resp.json().await.context("parse candle coverage")
|
||||
}
|
||||
|
||||
/// Validate a strategy against the swym DSL schema.
|
||||
///
|
||||
/// Calls `POST /api/v1/strategies/validate` and returns a structured list
|
||||
/// of all validation errors. Returns `Ok(vec![])` when the strategy is valid.
|
||||
/// Returns `Err` only on network or parse failures, not on DSL errors.
|
||||
pub async fn validate_strategy(&self, strategy: &Value) -> Result<Vec<ValidationError>> {
|
||||
let url = format!("{}/strategies/validate", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(strategy)
|
||||
.send()
|
||||
.await
|
||||
.context("validate strategy request")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("validate strategy {status}: {body}");
|
||||
}
|
||||
|
||||
let parsed: ValidationResponse =
|
||||
resp.json().await.context("parse validation response")?;
|
||||
Ok(parsed.errors)
|
||||
}
|
||||
|
||||
/// Submit a backtest run.
|
||||
pub async fn submit_backtest(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user