diff --git a/src/agent.rs b/src/agent.rs index 34910bb..71c69de 100644 --- a/src/agent.rs +++ b/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 = 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 = Vec::new(); diff --git a/src/dsl-schema.json b/src/dsl-schema.json index 150d379..1c63e15 100644 --- a/src/dsl-schema.json +++ b/src/dsl-schema.json @@ -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." + } + } } } } diff --git a/src/prompts.rs b/src/prompts.rs index 279d429..530e1fc 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -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 diff --git a/src/swym.rs b/src/swym.rs index 684f0a6..6b0cdc6 100644 --- a/src/swym.rs +++ b/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, +} + +#[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> { + 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,