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:
2026-03-10 09:12:12 +02:00
parent 5146b3f764
commit 7e1ff51ae0
4 changed files with 171 additions and 24 deletions

View File

@@ -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();

View File

@@ -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."
}
}
} }
} }
} }

View File

@@ -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 510% 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

View File

@@ -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,