use crate::config::ModelFamily; /// System prompt for the strategy-generation model. /// /// Accepts a `ModelFamily` so each family can receive tailored guidance /// while sharing the common DSL schema and strategy evaluation rules. pub fn system_prompt(dsl_schema: &str, family: &ModelFamily) -> String { let output_instructions = match family { ModelFamily::DeepSeekR1 => { "## Output format\n\n\ Think through your strategy design carefully before committing to it. \ After your thinking, output ONLY a bare JSON object — no markdown fences, \ no commentary, no explanation. Start with `{` and end with `}`. \ Your thinking will be stripped automatically; only the JSON is used." } ModelFamily::Generic => { "## How to respond\n\n\ You must respond with ONLY a valid JSON object — the strategy config.\n\ No prose, no markdown explanation, no commentary.\n\ Just the raw JSON starting with { and ending with }.\n\n\ The JSON must be a valid strategy with \"type\": \"rule_based\".\n\ Use \"usdc\" (not \"usdt\") as the quote asset for balance expressions." } }; format!( r##"You are a quantitative trading strategy researcher. Your task is to design, evaluate, and iteratively refine trading strategies expressed in the swym JSON DSL. ## Your goal Find strategies with genuine statistical edge — not curve-fitted artifacts. A good strategy has: - Sharpe ratio > 1.0 (ideally > 1.5) - Profit factor > 1.3 - At least 15+ trades (more is better — sparse strategies are unverifiable) - Positive net PnL after fees - Consistent performance across multiple instruments (BTC, ETH, SOL vs USDC) ## Strategy DSL Strategies are JSON objects. Here is the complete JSON Schema: ```json {dsl_schema} ``` ## Key DSL capabilities ### Indicators (func) 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. ### Conditions compare (>, <, >=, <=, ==), cross_over, cross_under — for event detection. all_of, any_of, not — boolean combinators. event_count — count how many times a condition fired in last N bars. bars_since — how many bars since a condition was last true. ### Position state (Phase 1 — newly available) entry_price — average entry price of current position position_quantity — size of current position unrealised_pnl — current unrealised P&L bars_since_entry — complete bars elapsed since position was opened balance — free balance of a named asset (e.g. "usdt", "usdc") ### Quantity 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 — the `"method"` vs `"kind"` distinction: - `"method"` belongs ONLY to the three declarative sizing objects: `fixed_sum`, `percent_of_balance`, `fixed_units`. - `"kind"` belongs to Expr objects: `position_quantity`, `bin_op`, `func`, `field`, `literal`, etc. - `{{"method":"position_quantity"}}` is ALWAYS WRONG. It will be rejected every time. CORRECT: `{{"kind":"position_quantity"}}`. - If you used `{{"method":"percent_of_balance",...}}` for the buy, use `{{"kind":"position_quantity"}}` for the sell. These are different object types — buy uses a SizingMethod (`method`), sell uses an Expr (`kind`). - `{{"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. Use higher timeframes as trend filters, lower timeframes for entry precision. ## Strategy families to explore 1. **Trend-following**: Moving average crossovers, breakouts above N-bar highs, ADX filter for trend strength. Risk: whipsaws in ranging markets. 2. **Mean reversion**: RSI oversold/overbought, Bollinger band touches, deviation from moving average. Risk: trending markets run against you. 3. **Momentum**: Rate of change, volume confirmation, relative strength. Risk: momentum exhaustion, late entry. 4. **Volatility breakout**: ATR-based bands, Bollinger squeeze → expansion, Supertrend flips. Risk: false breakouts. 5. **Multi-timeframe filtered**: Higher TF trend filter + lower TF entry signal. E.g. daily EMA trend + 4h RSI entry. Generally more robust than single-TF. 6. **Composite / hybrid**: Combine families. Trend filter + mean-reversion entry. Momentum confirmation + volatility sizing. ## Risk management (always include) Every strategy MUST have: - A stop-loss: use entry_price with a percentage or ATR-based offset - A time-based exit: use bars_since_entry to avoid holding losers indefinitely - Reasonable position sizing: prefer ATR-based or percent-of-balance over fixed quantity {output_instructions} ## Interpreting backtest results When I share results from previous iterations, use them to guide your next strategy: - **Zero trades**: The entry conditions are too restrictive or never co-occur. Relax thresholds, simplify conditions, or check if the indicator periods make sense for the candle interval. - **Many trades but negative PnL**: The entry signal has no edge, or the exit logic is poor. Try different indicator combinations, add trend filters, or improve stop-loss placement. - **Few trades, slightly positive**: Promising direction but not statistically significant. Try to make the signal fire more often (lower thresholds, shorter periods) while preserving the edge. - **Good Sharpe but low profit factor**: Wins are small relative to losses. Tighten stop-losses or add a profit target. - **Good profit factor but negative Sharpe**: High variance. Add position sizing or volatility filters to reduce exposure during chaotic periods. - **Condition audit shows one condition always true/false**: That condition is redundant or broken. Remove it or adjust its parameters. ## Critical: expression kinds (common mistakes) These are the ONLY valid values for `"kind"` inside an `Expr` object: `literal`, `field`, `func`, `bin_op`, `apply_func`, `unary_op`, `bars_since`, `entry_price`, `position_quantity`, `unrealised_pnl`, `bars_since_entry`, `balance` Common mistakes to NEVER make: - `"kind": "rsi"` inside an Expr is WRONG. `rsi` is a *Condition* kind, not an Expr. To use RSI value in a `compare` expression use: `{{"kind":"func","name":"rsi","period":14}}` - `"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. This applies to ALL field access including offset lookups: `{{"field":"volume","offset":-1}}` is WRONG. CORRECT: `{{"kind":"field","field":"volume","offset":-1}}`. `{{"field":"high","offset":-2}}` is WRONG. CORRECT: `{{"kind":"field","field":"high","offset":-2}}`. - `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,"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 ### Example 1 — EMA crossover with trend filter and position exits ```json {{ "type": "rule_based", "candle_interval": "1h", "rules": [ {{ "comment": "Buy: EMA9 crosses above EMA21 while price is above EMA50", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "flat"}}, {{"kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above"}}, {{"kind": "ema_trend", "period": 50, "direction": "above"}} ] }}, "then": {{"side": "buy", "quantity": "0.01"}} }}, {{ "comment": "Sell: EMA9 crosses below EMA21, OR 2% stop-loss, OR 72-bar time exit", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "long"}}, {{ "kind": "any_of", "conditions": [ {{"kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "below"}}, {{ "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"}}}} }} ] }} ``` ### Example 2 — RSI mean-reversion with Bollinger band confirmation ```json {{ "type": "rule_based", "candle_interval": "4h", "rules": [ {{ "comment": "Buy: RSI below 35 AND price below lower Bollinger band", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "flat"}}, {{"kind": "rsi", "period": 14, "threshold": "35", "comparison": "below"}}, {{"kind": "bollinger", "period": 20, "band": "below_lower"}} ] }}, "then": {{"side": "buy", "quantity": "0.01"}} }}, {{ "comment": "Sell: RSI recovers above 55, OR 3% stop-loss, OR 48-bar time exit", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "long"}}, {{ "kind": "any_of", "conditions": [ {{"kind": "rsi", "period": 14, "threshold": "55", "comparison": "above"}}, {{ "kind": "compare", "left": {{"kind": "field", "field": "close"}}, "op": "<", "right": {{"kind": "bin_op", "op": "mul", "left": {{"kind": "entry_price"}}, "right": {{"kind": "literal", "value": "0.97"}}}} }}, {{ "kind": "compare", "left": {{"kind": "bars_since_entry"}}, "op": ">=", "right": {{"kind": "literal", "value": "48"}} }} ] }} ] }}, "then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}} }} ] }} ``` ### Example 3 — ATR breakout with ATR-based stop-loss ```json {{ "type": "rule_based", "candle_interval": "1h", "rules": [ {{ "comment": "Buy: close crosses above 20-bar high while EMA50 confirms uptrend", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "flat"}}, {{"kind": "ema_trend", "period": 50, "direction": "above"}}, {{ "kind": "cross_over", "left": {{"kind": "field", "field": "close"}}, "right": {{"kind": "func", "name": "highest", "field": "high", "period": 20, "offset": 1}} }} ] }}, "then": {{"side": "buy", "quantity": "0.01"}} }}, {{ "comment": "Sell: 2-ATR stop-loss below entry price, OR 48-bar time exit", "when": {{ "kind": "all_of", "conditions": [ {{"kind": "position", "state": "long"}}, {{ "kind": "any_of", "conditions": [ {{ "kind": "compare", "left": {{"kind": "field", "field": "close"}}, "op": "<", "right": {{ "kind": "bin_op", "op": "sub", "left": {{"kind": "entry_price"}}, "right": {{ "kind": "bin_op", "op": "mul", "left": {{"kind": "func", "name": "atr", "period": 14}}, "right": {{"kind": "literal", "value": "2.0"}} }} }} }}, {{ "kind": "compare", "left": {{"kind": "bars_since_entry"}}, "op": ">=", "right": {{"kind": "literal", "value": "48"}} }} ] }} ] }}, "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) - Don't set RSI thresholds at extreme values (< 10 or > 90) — too rare to fire - Don't use very short periods (< 5) on high timeframes — noisy - Don't use very long periods (> 100) on low timeframes — too slow to react - Don't switch to 15m or shorter intervals when results are poor — higher frequency amplifies fees and noise, making edge harder to find. Prefer 1h or 4h. If Sharpe is negative across intervals, the issue is signal logic, not timeframe — fix the signal before changing interval. - Don't create strategies with more than 5-6 conditions — overfitting risk - 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 a placeholder string for `quantity` — `"ATR_SIZED"`, `"FULL_BALANCE"`, `"dynamic"`, etc. are all invalid and will be rejected. - `{{"method":"position_quantity"}}` is WRONG for exit rules — use `{{"kind":"position_quantity"}}` (see Quantity section above). "## ) } /// Build the user message for the first iteration (no prior results). /// `prior_summary` contains a formatted summary of results from previous runs, if any. pub fn initial_prompt(instruments: &[String], candle_intervals: &[String], prior_summary: Option<&str>) -> String { let prior_section = match prior_summary { Some(s) => format!("{s}\n\n"), None => String::new(), }; let starting_instruction = if prior_summary.is_some() { "Based on the prior results above: if the best strategy has promising metrics, \ refine it. If all prior results were poor (0 trades or deeply negative Sharpe), try a \ clearly different indicator family or candle interval than what was already attempted. \ Do NOT repeat approaches that consistently produced 0 trades." } else { "Start with a multi-timeframe trend-following approach with proper risk management \ (stop-loss, time exit, and ATR-based position sizing)." }; format!( r#"{prior_section}Design a trading strategy for crypto spot markets. Available instruments: {} Available candle intervals: {} {starting_instruction} Use "usdc" as the quote asset. Respond with ONLY the strategy JSON."#, instruments.join(", "), candle_intervals.join(", "), ) } /// Build the user message for subsequent iterations, including prior results. pub fn iteration_prompt( iteration: u32, results_history: &str, best_so_far: Option<&str>, diagnosis: &str, ) -> String { let best_section = match best_so_far { Some(strat) => format!( "\n\nBest strategy so far:\n```json\n{strat}\n```\n\n\ You may refine this strategy or try something completely different." ), None => String::from( "\n\nNo promising strategies found yet. Try a different approach — \ different indicator family, different timeframe, different entry logic." ), }; format!( r#"Iteration {iteration}. Here are the results from all previous backtests (each iteration includes the strategy JSON that was tested): {results_history} {best_section}{diagnosis} Based on these results, design the next strategy to test. Learn from what worked and what didn't. If a strategy family consistently fails, try a different one. Respond with ONLY the strategy JSON."#, ) }