feat: validate endpoint integration, Expr quantity sizing, apply_func input field fix
- Add /api/v1/strategies/validate client to SwymClient; wire into agent loop before submission so all DSL errors are surfaced in one round-trip - Update dsl-schema.json to upstream: quantity is now oneOf[DecimalString, Expr], ExprApplyFunc uses "input" field (renamed from "expr") - Update prompts: document expression-based quantity sizing (fixed-fraction and ATR-based examples), fix apply_func to use "input" not "expr" throughout - Remove unused ValidationError import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
36
src/agent.rs
36
src/agent.rs
@@ -325,7 +325,7 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
|||||||
let strat_path = cli.output_dir.join(format!("strategy_{iteration:03}.json"));
|
let strat_path = cli.output_dir.join(format!("strategy_{iteration:03}.json"));
|
||||||
std::fs::write(&strat_path, serde_json::to_string_pretty(&strategy)?)?;
|
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() {
|
if !hard_errors.is_empty() {
|
||||||
let record = IterationRecord {
|
let record = IterationRecord {
|
||||||
iteration,
|
iteration,
|
||||||
@@ -338,6 +338,40 @@ pub async fn run(cli: &Cli) -> Result<()> {
|
|||||||
continue;
|
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, e.message);
|
||||||
|
}
|
||||||
|
let error_notes: Vec<String> = api_errors
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("DSL error at {}: {}", e.path, 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)
|
// Run backtests against all instruments (in-sample)
|
||||||
let mut results: Vec<BacktestResult> = Vec::new();
|
let mut results: Vec<BacktestResult> = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,11 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"side": { "type": "string", "enum": ["buy", "sell"] },
|
"side": { "type": "string", "enum": ["buy", "sell"] },
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"$ref": "#/definitions/DecimalString",
|
"description": "Per-order size in base asset units. Either a fixed decimal string (e.g. \"0.001\") or a dynamic Expr evaluated at candle close. When an Expr returns None the order is skipped; negative values are clamped to zero.",
|
||||||
"description": "Per-order size in base asset units, e.g. \"0.001\" for BTC."
|
"oneOf": [
|
||||||
|
{ "$ref": "#/definitions/DecimalString" },
|
||||||
|
{ "$ref": "#/definitions/Expr" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -280,7 +283,12 @@
|
|||||||
{ "$ref": "#/definitions/ExprBinOp" },
|
{ "$ref": "#/definitions/ExprBinOp" },
|
||||||
{ "$ref": "#/definitions/ExprApplyFunc" },
|
{ "$ref": "#/definitions/ExprApplyFunc" },
|
||||||
{ "$ref": "#/definitions/ExprUnaryOp" },
|
{ "$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": {
|
"ExprLiteral": {
|
||||||
@@ -417,6 +425,55 @@
|
|||||||
"description": "Maximum bars to look back."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,21 +74,36 @@ bars_since_entry — complete bars elapsed since position was opened
|
|||||||
balance — free balance of a named asset (e.g. "usdt", "usdc")
|
balance — free balance of a named asset (e.g. "usdt", "usdc")
|
||||||
|
|
||||||
### Quantity
|
### Quantity
|
||||||
Action quantity MUST be a fixed decimal string that parses as a floating-point number.
|
Action quantity is either a fixed decimal string **or** an Expr that evaluates to a number
|
||||||
NEVER use an expression object for quantity — only plain decimal strings are accepted.
|
at candle close. If the Expr returns None the order is skipped; negative values are clamped
|
||||||
NEVER use placeholder strings like `"ATR_SIZED"`, `"FULL_BALANCE"`, `"percent_of_balance"`,
|
to zero.
|
||||||
`"dynamic"`, `"all"`, or any non-numeric string — these will be rejected immediately.
|
|
||||||
To exit a position, use the SAME decimal string as the entry rule — the backtester matches
|
|
||||||
open quantity automatically. There is no "close all" concept; just repeat the entry quantity.
|
|
||||||
|
|
||||||
Size your quantity to represent roughly 5–10% of the initial $10,000 USDC balance at
|
**Preferred: expression-based sizing** — instrument-agnostic, scales automatically:
|
||||||
approximate current prices. Reference values (adjust if market prices differ significantly):
|
|
||||||
- BTC: `"0.01"` ≈ $800 (8% of $10k)
|
Fixed fraction of quote balance (5% of USDC balance, converted to base units at current price):
|
||||||
- ETH: `"3.0"` ≈ $600 (6% of $10k)
|
```json
|
||||||
- SOL: `"50.0"` ≈ $700 (7% of $10k)
|
{{"kind":"bin_op","op":"div",
|
||||||
Since strategies run across all instruments with the same quantity string, choose a value
|
"left":{{"kind":"bin_op","op":"mul",
|
||||||
appropriate for the primary instrument you are designing for. Avoid `"0.001"` — it produces
|
"left":{{"kind":"balance","asset":"usdc"}},
|
||||||
negligible exposure and makes results statistically meaningless.
|
"right":{{"kind":"literal","value":"0.05"}}}},
|
||||||
|
"right":{{"kind":"field","field":"close"}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
ATR-based risk sizing (risk $200 per trade, sized by volatility):
|
||||||
|
```json
|
||||||
|
{{"kind":"bin_op","op":"div",
|
||||||
|
"left":{{"kind":"literal","value":"200"}},
|
||||||
|
"right":{{"kind":"func","name":"atr","period":14}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
For exit rules, use `position_quantity` to close the exact open position:
|
||||||
|
```json
|
||||||
|
{{"kind":"position_quantity"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed strings are also valid** when you want a specific size, e.g. `"0.01"`.
|
||||||
|
NEVER use placeholder strings like `"ATR_SIZED"`, `"FULL_BALANCE"`, `"all"`, `"dynamic"` —
|
||||||
|
these are rejected immediately. Use an Expr or a plain decimal string.
|
||||||
|
|
||||||
### Multi-timeframe
|
### Multi-timeframe
|
||||||
Any expression can reference a different timeframe via "timeframe" field.
|
Any expression can reference a different timeframe via "timeframe" field.
|
||||||
@@ -166,7 +181,7 @@ Common mistakes to NEVER make:
|
|||||||
with `ApplyFuncName` values: `highest`, `lowest`, `sma`, `ema`, `wma`, `std_dev`, `sum`,
|
with `ApplyFuncName` values: `highest`, `lowest`, `sma`, `ema`, `wma`, `std_dev`, `sum`,
|
||||||
`bollinger_upper`, `bollinger_lower`.
|
`bollinger_upper`, `bollinger_lower`.
|
||||||
- `volume` is a candle FIELD, not a func name. Access it as `{{"kind":"field","field":"volume"}}`.
|
- `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:
|
- `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}}}}`
|
`{{"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 write `{{"kind":"bollinger_upper",...}}` — `bollinger_upper` is not an Expr kind.
|
||||||
@@ -374,7 +389,7 @@ The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD li
|
|||||||
}},
|
}},
|
||||||
"right": {{
|
"right": {{
|
||||||
"kind": "apply_func", "name": "ema", "period": 9,
|
"kind": "apply_func", "name": "ema", "period": 9,
|
||||||
"expr": {{
|
"input": {{
|
||||||
"kind": "bin_op", "op": "sub",
|
"kind": "bin_op", "op": "sub",
|
||||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||||
@@ -403,7 +418,7 @@ The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD li
|
|||||||
}},
|
}},
|
||||||
"right": {{
|
"right": {{
|
||||||
"kind": "apply_func", "name": "ema", "period": 9,
|
"kind": "apply_func", "name": "ema", "period": 9,
|
||||||
"expr": {{
|
"input": {{
|
||||||
"kind": "bin_op", "op": "sub",
|
"kind": "bin_op", "op": "sub",
|
||||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||||
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
"right": {{"kind": "func", "name": "ema", "period": 26}}
|
||||||
@@ -434,9 +449,10 @@ The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD li
|
|||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Key pattern: `apply_func` wraps any `Expr` tree, enabling EMA-of-expression (signal line),
|
Key pattern: `apply_func` wraps any `Expr` tree using the `"input"` field (NOT `"expr"`).
|
||||||
WMA-of-expression (Hull MA), or std_dev-of-returns. There is NO native `macd` func name —
|
This enables EMA-of-expression (signal line), WMA-of-expression (Hull MA), or std_dev-of-returns.
|
||||||
always compose it as `bin_op(sub, func(ema,12), func(ema,26))` as shown above.
|
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
|
## Anti-patterns to avoid
|
||||||
|
|
||||||
|
|||||||
40
src/swym.rs
40
src/swym.rs
@@ -4,6 +4,20 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
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 {
|
||||||
|
pub path: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Client for the swym backtesting API.
|
/// Client for the swym backtesting API.
|
||||||
pub struct SwymClient {
|
pub struct SwymClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -254,6 +268,32 @@ impl SwymClient {
|
|||||||
resp.json().await.context("parse candle coverage")
|
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.
|
/// Submit a backtest run.
|
||||||
pub async fn submit_backtest(
|
pub async fn submit_backtest(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
Reference in New Issue
Block a user