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"));
|
||||
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,
|
||||
@@ -338,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, 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)
|
||||
let mut results: Vec<BacktestResult> = Vec::new();
|
||||
|
||||
|
||||
@@ -66,8 +66,11 @@
|
||||
"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. 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.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/DecimalString" },
|
||||
{ "$ref": "#/definitions/Expr" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -280,7 +283,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 +425,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
### Quantity
|
||||
Action quantity MUST be a fixed decimal string that parses as a floating-point number.
|
||||
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"`, `"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.
|
||||
Action quantity is either a fixed decimal string **or** an Expr that evaluates to a number
|
||||
at candle close. If the Expr returns None the order is skipped; negative values are clamped
|
||||
to zero.
|
||||
|
||||
Size your quantity to represent roughly 5–10% of the initial $10,000 USDC balance at
|
||||
approximate current prices. Reference values (adjust if market prices differ significantly):
|
||||
- BTC: `"0.01"` ≈ $800 (8% of $10k)
|
||||
- ETH: `"3.0"` ≈ $600 (6% of $10k)
|
||||
- SOL: `"50.0"` ≈ $700 (7% of $10k)
|
||||
Since strategies run across all instruments with the same quantity string, choose a value
|
||||
appropriate for the primary instrument you are designing for. Avoid `"0.001"` — it produces
|
||||
negligible exposure and makes results statistically meaningless.
|
||||
**Preferred: expression-based sizing** — instrument-agnostic, scales automatically:
|
||||
|
||||
Fixed fraction of quote balance (5% of USDC balance, converted to base units at current price):
|
||||
```json
|
||||
{{"kind":"bin_op","op":"div",
|
||||
"left":{{"kind":"bin_op","op":"mul",
|
||||
"left":{{"kind":"balance","asset":"usdc"}},
|
||||
"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
|
||||
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`,
|
||||
`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.
|
||||
@@ -374,7 +389,7 @@ The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD li
|
||||
}},
|
||||
"right": {{
|
||||
"kind": "apply_func", "name": "ema", "period": 9,
|
||||
"expr": {{
|
||||
"input": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"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": {{
|
||||
"kind": "apply_func", "name": "ema", "period": 9,
|
||||
"expr": {{
|
||||
"input": {{
|
||||
"kind": "bin_op", "op": "sub",
|
||||
"left": {{"kind": "func", "name": "ema", "period": 12}},
|
||||
"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),
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
40
src/swym.rs
40
src/swym.rs
@@ -4,6 +4,20 @@ 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 {
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Client for the swym backtesting API.
|
||||
pub struct SwymClient {
|
||||
client: Client,
|
||||
@@ -254,6 +268,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