This document provides guidance on how to translate a trading strategy into the Swym rules-based strategy DSL.

Swym requires a strategy to be expressed as a single valid JSON object. The machine-readable schema lives at [`assets/strategy/schema.json`](../assets/strategy/schema.json) (JSON Schema draft-07). Use the schema to validate a strategy before submitting it — see [Validation](#validation) below.

No prose, no markdown fences — just the JSON.

## Top-level shape

{
  "type": "rule_based",
  "candle_interval": "<interval>",
  "rules": [ <Rule>, ... ]
}

candle_interval must be one of: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"

## Rule

{
  "comment": "<optional human-readable annotation>",   // omit if empty
  "when": <Condition>,
  "then": { "side": "buy" | "sell", "quantity": <QuantitySpec> }
}

QuantitySpec is a fixed decimal string, a named SizingMethod, or a dynamic Expr:
  "quantity": "0.001"                                    // fixed: 0.001 BTC per order
  "quantity": { "method": "fixed_sum", "amount": "500" } // sizing method: buy $500 worth
  "quantity": { "kind": "bin_op", ... }                  // dynamic Expr: evaluated at close

For sizing methods, see "Position sizing methods" below.
For dynamic expression trees, see "Position state and dynamic sizing" below.
All rules that fire on the same candle close will execute. Typically you have one
buy rule (gated on position=flat) and one sell rule (gated on position=long).

## Conditions

All conditions have a "kind" discriminator.

### Combinators
{ "kind": "all_of", "conditions": [<Condition>, ...] }
{ "kind": "any_of", "conditions": [<Condition>, ...] }
{ "kind": "not",    "condition": <Condition> }

### Position
{ "kind": "position", "state": "flat" | "long" | "short" }

### Legacy EMA shortcuts (use these for simple EMA strategies; avoid for complex ones)
{ "kind": "ema_crossover", "fast_period": <int>, "slow_period": <int>, "direction": "above" | "below" }
{ "kind": "ema_trend",     "period": <int>,                            "direction": "above" | "below" }
  // ema_trend: true when close is above (or below) the EMA

### Legacy RSI shortcut
{ "kind": "rsi", "period": <int, default 14>, "threshold": "<decimal>", "comparison": "above" | "below" }

### Legacy Bollinger shortcut
{ "kind": "bollinger", "period": <int, default 20>, "num_std_dev": "<decimal, default 2>",
  "band": "above_upper" | "below_lower" }

### Price level
{ "kind": "price_level", "price": "<decimal>", "direction": "above" | "below" }

### Generic numeric comparison   ← use this for any indicator comparison
{ "kind": "compare", "left": <Expr>, "op": ">" | "<" | ">=" | "<=" | "==", "right": <Expr> }

### Crossover / crossunder        ← detects left crossing above/below right vs. previous bar
{ "kind": "cross_over",  "left": <Expr>, "right": <Expr> }
{ "kind": "cross_under", "left": <Expr>, "right": <Expr> }

### Event count  ← how many times did a condition fire in the last N bars?
{
  "kind": "event_count",
  "condition": <Condition>,   // evaluated at each of the last `period` bars (offsets 1..=period)
  "period": <int>,            // how many bars back to scan (excluding the current bar)
  "op": ">" | "<" | ">=" | "<=" | "==",
  "count": <int>              // plain integer (not a string)
}
// Examples:
// "at least one cross_under in last 20 bars"  → op: ">=", count: 1
// "price touched the MA exactly twice"        → op: "==", count: 2
// "first-touch guard: never fired"            → op: "==", count: 0
//
// Offset-aware sub-conditions: Compare, CrossOver, CrossUnder, AllOf, AnyOf, Not, EventCount.
// NOT offset-aware (always return false inside event_count): Position, EmaCrossover,
// EmaTrend, Rsi, Bollinger.

## Expressions (Expr)

All expressions have a "kind" discriminator.

### Literal constant
{ "kind": "literal", "value": "<decimal string>" }

### OHLCV candle field
{ "kind": "field", "field": "open"|"high"|"low"|"close"|"volume" }
{ "kind": "field", "field": "close", "offset": 1 }   // 1 = previous bar, 2 = 2 bars ago

### Rolling-window function over a candle field
{
  "kind": "func",
  "name": <FuncName>,
  "field": "open"|"high"|"low"|"close"|"volume",   // which candle field (ignored for atr/adx/supertrend)
  "period": <int>,
  "offset": <int>   // omit or 0 = current bar; 1 = window shifted one bar back
  // "multiplier": "<decimal>"  — for supertrend: ATR multiplier (typically "3.0")
  //                            — for bollinger_upper/bollinger_lower: num_std_dev (typically "2.0")
}

### Bars since a condition last fired
{
  "kind": "bars_since",
  "condition": <Condition>,  // scanned at offsets 1..=period
  "period": <int>            // maximum bars to look back
}
// Returns None (→ false in any enclosing condition) if condition never fired within the window.
// Use inside compare to express recency constraints:
//   { "kind": "compare",
//     "left":  { "kind": "bars_since", "condition": { "kind": "cross_under", ... }, "period": 20 },
//     "op": ">", "right": { "kind": "literal", "value": "2" } }
// ("The last touch was more than 2 bars ago.")
// Obeys the same offset-awareness constraints as event_count sub-conditions.

### Position state expressions

The following unit expressions expose live position data at candle close.
They return `None` (propagate to `false` in conditions) when the position is flat.
These are only valid at `extra == 0` (current bar); they return `None` at any non-zero offset.

{ "kind": "entry_price" }         // VWAP average entry price of the open position
{ "kind": "position_quantity" }   // absolute quantity held (base asset units)
{ "kind": "unrealised_pnl" }      // estimated unrealised PnL (quote asset)
{ "kind": "bars_since_entry" }    // integer division: (now - time_enter).seconds / interval_secs → exact integer 0,1,2,...

// Free balance of a named asset:
{ "kind": "balance", "asset": "usdt" }   // matched case-insensitively against internal asset name

### Rolling function applied to an arbitrary sub-expression (function composition)
{
  "kind": "apply_func",
  "name": <FuncName>,     // see valid names below — NOT atr/adx/supertrend/rsi
  "input": <Expr>,        // inner expression evaluated at each bar in the window
  "period": <int>
  // "offset": <int>  — omit or 0 = ending at current bar
}
// Use this for: EMA of EMA, WMA of expression (Hull MA), VWAP = Sum(close*vol)/Sum(vol), etc.

### Arithmetic
{ "kind": "bin_op", "op": "add"|"sub"|"mul"|"div", "left": <Expr>, "right": <Expr> }

### Unary math
{ "kind": "unary_op", "op": "abs"|"sqrt"|"neg"|"log", "operand": <Expr> }

## Multi-timeframe expressions

`field`, `func`, and `apply_func` accept an optional `"timeframe"` key that reads from a different
candle interval than the primary `candle_interval`. The strategy is evaluated once per primary candle
close; additional timeframes are read-only context updated in parallel.

Timeframe values follow the same enum as `candle_interval`: `"1m"` | `"5m"` | `"15m"` | `"1h"` | `"4h"` | `"1d"`.
When `"timeframe"` is absent or null the expression uses the primary interval (backward-compatible).

{ "kind": "field", "field": "close", "timeframe": "1d" }                           // daily close
{ "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }  // daily 200 SMA
{ "kind": "func", "name": "ema", "field": "close", "period": 20, "timeframe": "1h" }   // hourly 20 EMA

The legacy shorthand conditions also accept `"timeframe"`:

{ "kind": "ema_trend",     "period": 50, "direction": "above", "timeframe": "1d" }
{ "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above", "timeframe": "1h" }
{ "kind": "rsi",           "period": 14, "threshold": "30", "comparison": "below", "timeframe": "4h" }
{ "kind": "bollinger",     "period": 20, "band": "below_lower", "timeframe": "1h" }
{ "kind": "price_level",   "price": "50000", "direction": "above", "timeframe": "1d" }

### Data requirements

Each additional timeframe must have candle data backfilled for the backtest window.
Use `POST /api/v1/market-candles/backfill` (or the Backfill button in the dashboard).
The API rejects backtest creation if any referenced timeframe lacks coverage.

### Example: daily trend filter + 4h entry

{
  "type": "rule_based",
  "candle_interval": "4h",
  "rules": [
    {
      "comment": "Long: daily uptrend + 4h pullback to 20 SMA",
      "when": { "kind": "all_of", "conditions": [
        { "kind": "position", "state": "flat" },
        {
          "comment": "Daily: close > 200 SMA (bullish regime)",
          "kind": "compare",
          "left":  { "kind": "field", "field": "close", "timeframe": "1d" },
          "op": ">",
          "right": { "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }
        },
        {
          "comment": "4h: price crosses above 20 SMA (primary entry trigger)",
          "kind": "cross_over",
          "left":  { "kind": "field", "field": "close" },
          "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
        }
      ]},
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Exit: close crosses below 20 SMA",
      "when": { "kind": "all_of", "conditions": [
        { "kind": "position", "state": "long" },
        { "kind": "cross_under",
          "left":  { "kind": "field", "field": "close" },
          "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 } }
      ]},
      "then": { "side": "sell", "quantity": "0.001" }
    }
  ]
}

### Constraints

- `"timeframe"` is optional on every node; absent means primary interval.
- Each referenced timeframe needs backfilled candle data for the backtest range.
- The primary `candle_interval` gates evaluation — the strategy runs only on primary closes.
- Warm-up counts (see table below) apply to each timeframe's candle history independently.
- `apply_func` outer `"timeframe"` controls the output series; the inner `input` resolves its own
  timeframe independently.

## FuncName values

| name       | description                                            | field used? |
|------------|--------------------------------------------------------|-------------|
| highest    | rolling max                                            | yes         |
| lowest     | rolling min                                            | yes         |
| sma        | simple moving average                                  | yes         |
| ema        | exponential moving average (EMA multiplier 2/(n+1))   | yes         |
| wma        | weighted moving average (oldest weight=1, newest=n)   | yes         |
| rsi        | RSI via Wilder's smoothing, result in [0,100]         | yes         |
| std_dev    | population standard deviation                          | yes         |
| sum        | rolling sum                                            | yes         |
| atr             | Average True Range (Wilder simple avg, not smoothed)                        | NO (uses H/L/C)    |
| supertrend      | Supertrend line (band-flip, ATR-based)                                      | NO (uses H/L/C)    |
| adx             | Average Directional Index, Wilder smoothing [0,100]                         | NO (uses H/L/C)    |
| bollinger_upper | Upper Bollinger Band: SMA + multiplier×StdDev (multiplier default 2.0)      | yes                |
| bollinger_lower | Lower Bollinger Band: SMA − multiplier×StdDev (multiplier default 2.0)      | yes                |

atr, supertrend, adx, and rsi are NOT valid inside "apply_func" (return None silently).
bollinger_upper and bollinger_lower ARE valid inside "apply_func" but use a fixed num_std_dev of 2.0 in that context.

## Warm-up / minimum bar counts required before a condition can fire

| expression / condition         | minimum candle history     |
|-------------------------------|----------------------------|
| sma / ema / wma / sum(N)      | N bars                     |
| rsi(N)                        | N+1 bars                   |
| std_dev(N)                    | N bars                     |
| bollinger_upper/lower(N)      | N bars                     |
| highest/lowest(N)             | N bars                     |
| atr(N)                        | N+1 bars                   |
| adx(N)                        | 2*N+1 bars                 |
| supertrend(N)                 | N+2 bars                   |
| apply_func(outer_N, inner)    | outer_N + inner warmup     |
| ema_crossover(fast, slow)     | slow+1 bars                |
| event_count(condition, N)     | N + condition's own warmup |
| bars_since(condition, N)      | N + condition's own warmup |
| entry_price / position_quantity / unrealised_pnl / bars_since_entry / balance | 0 (no candle history needed) |

Until warm-up is satisfied the condition returns false (no order is placed).
Use a candle_interval and backtesting window long enough to cover warm-up.

## Rules

- Rules are evaluated independently on every candle close.
- All rules whose `when` is true simultaneously fire their `then` action.
- Gate buy rules with `{ "kind": "position", "state": "flat" }` to prevent pyramiding.
- Gate sell rules with `{ "kind": "position", "state": "long" }`.
- You can have multiple exit rules (e.g. profit target AND stop-loss AND indicator exit)
  by writing separate sell rules — the first one to fire closes the position.
- There is no native stop-loss or take-profit level primitive; express those as
  compare conditions against close vs. a Literal or a lagged Field.

## Common patterns

### EMA crossover entry
"when": { "kind": "all_of", "conditions": [
  { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above" },
  { "kind": "position", "state": "flat" }
]}

### RSI oversold entry
"when": { "kind": "all_of", "conditions": [
  { "kind": "rsi", "period": 14, "threshold": "30", "comparison": "below" },
  { "kind": "position", "state": "flat" }
]}

### ATR-based volatility filter (close must be > 1 ATR above SMA)
"when": { "kind": "compare",
  "left":  { "kind": "field", "field": "close" },
  "op": ">",
  "right": { "kind": "bin_op", "op": "add",
    "left":  { "kind": "func", "name": "sma",  "field": "close", "period": 20 },
    "right": { "kind": "func", "name": "atr",  "field": "close", "period": 14 }
  }
}

### Supertrend crossover entry (close crosses above supertrend line)
"when": { "kind": "all_of", "conditions": [
  { "kind": "cross_over",
    "left":  { "kind": "field", "field": "close" },
    "right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
  },
  { "kind": "position", "state": "flat" }
]}

### Bollinger Bands: close crosses above upper band (breakout entry)
"when": { "kind": "cross_over",
  "left":  { "kind": "field", "field": "close" },
  "right": { "kind": "func", "name": "bollinger_upper", "field": "close", "period": 20, "multiplier": "2.0" }
}

### Bollinger Bands: close crosses below lower band (mean-reversion entry)
"when": { "kind": "cross_under",
  "left":  { "kind": "field", "field": "close" },
  "right": { "kind": "func", "name": "bollinger_lower", "field": "close", "period": 20, "multiplier": "2.0" }
}

### Bollinger Bands: price inside the bands (range filter)
"when": { "kind": "all_of", "conditions": [
  { "kind": "compare",
    "left":  { "kind": "field", "field": "close" },
    "op": "<",
    "right": { "kind": "func", "name": "bollinger_upper", "field": "close", "period": 20, "multiplier": "2.0" }
  },
  { "kind": "compare",
    "left":  { "kind": "field", "field": "close" },
    "op": ">",
    "right": { "kind": "func", "name": "bollinger_lower", "field": "close", "period": 20, "multiplier": "2.0" }
  }
]}

### VWAP entry (close crosses above VWAP over last 20 bars)
"when": { "kind": "cross_over",
  "left": { "kind": "field", "field": "close" },
  "right": { "kind": "bin_op", "op": "div",
    "left":  { "kind": "apply_func", "name": "sum", "period": 20,
                "input": { "kind": "bin_op", "op": "mul",
                  "left":  { "kind": "field", "field": "close" },
                  "right": { "kind": "field", "field": "volume" } }},
    "right": { "kind": "func", "name": "sum", "field": "volume", "period": 20 }
  }
}

### Not-first-touch guard (has close crossed below 20 SMA at least once in last 20 bars?)
"when": { "kind": "event_count",
  "condition": { "kind": "cross_under",
    "left":  { "kind": "field", "field": "close" },
    "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
  },
  "period": 20, "op": ">=", "count": 1
}

### Recency guard (the last touch was more than 2 bars ago, avoid immediate re-entry)
"when": { "kind": "compare",
  "left": { "kind": "bars_since",
    "condition": { "kind": "cross_under",
      "left":  { "kind": "field", "field": "close" },
      "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
    },
    "period": 20
  },
  "op": ">", "right": { "kind": "literal", "value": "2" }
}

### Hull Moving Average (HMA = WMA(2*WMA(N/2) - WMA(N), sqrt(N)))
// Step 1: wma_half = WMA(close, N/2)
// Step 2: wma_full = WMA(close, N)
// Step 3: raw = 2*wma_half - wma_full
// Step 4: hma = WMA(raw, floor(sqrt(N)))
{ "kind": "apply_func", "name": "wma", "period": 4,  // sqrt(16) = 4
  "input": { "kind": "bin_op", "op": "sub",
    "left": { "kind": "bin_op", "op": "mul",
      "left": { "kind": "literal", "value": "2" },
      "right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }},  // N/2
    "right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }}}   // N

## Position state and dynamic sizing

### 5% stop-loss (exit when price falls 5% below entry)

{
  "comment": "Stop-loss: close dropped more than 5% below entry",
  "when": { "kind": "all_of", "conditions": [
    { "kind": "position", "state": "long" },
    { "kind": "compare",
      "left":  { "kind": "field", "field": "close" },
      "op": "<",
      "right": { "kind": "bin_op", "op": "mul",
        "left":  { "kind": "entry_price" },
        "right": { "kind": "literal", "value": "0.95" } } }
  ]},
  "then": { "side": "sell", "quantity": "0.001" }
}

### 10% take-profit (exit when unrealised PnL exceeds 10% of entry cost)

{
  "comment": "Take-profit: unrealised PnL > 10% of position value",
  "when": { "kind": "all_of", "conditions": [
    { "kind": "position", "state": "long" },
    { "kind": "compare",
      "left":  { "kind": "unrealised_pnl" },
      "op": ">",
      "right": { "kind": "bin_op", "op": "mul",
        "left":  { "kind": "bin_op", "op": "mul",
          "left":  { "kind": "entry_price" },
          "right": { "kind": "position_quantity" } },
        "right": { "kind": "literal", "value": "0.10" } } }
  ]},
  "then": { "side": "sell", "quantity": "0.001" }
}

### Time exit (exit after 20 bars)

{
  "comment": "Time exit: close after 20 bars regardless of price",
  "when": { "kind": "all_of", "conditions": [
    { "kind": "position", "state": "long" },
    { "kind": "compare",
      "left":  { "kind": "bars_since_entry" },
      "op": ">=",
      "right": { "kind": "literal", "value": "20" } }
  ]},
  "then": { "side": "sell", "quantity": "0.001" }
}

## Position sizing methods

Sizing methods are declarative shortcuts that resolve to a base-asset quantity at execution time
using the live price and free balances. They use a `"method"` tag instead of `"kind"`.

Sell orders always close the full open position regardless of the quantity specified — the
resolved quantity only matters for buy (entry) orders.

### fixed_sum — buy a fixed quote-currency amount

// Buy $500 worth of base asset at current price (qty = 500 / price)
"quantity": { "method": "fixed_sum", "amount": "500" }

### percent_of_balance — fraction of a named asset's free balance

// Risk 2% of free USDC balance (qty = balance("usdc") * 0.02 / price)
"quantity": { "method": "percent_of_balance", "percent": "2", "asset": "usdc" }

// asset is matched case-insensitively; use the internal lowercased name (e.g. "usdc" not "USDC")

### fixed_units — explicit base-asset quantity (alias for a fixed decimal)

// Buy exactly 0.01 BTC
"quantity": { "method": "fixed_units", "units": "0.01" }

If the price is zero or the required balance is absent, the order is skipped (same semantics as
an Expr that returns None).

### ATR-based position sizing (risk 1% of USDT balance per trade)

// Risk = balance × 1%. Stop distance = 2 × ATR(14). Size = risk / stop_distance.
"then": {
  "side": "buy",
  "quantity": { "kind": "bin_op", "op": "div",
    "left": { "kind": "bin_op", "op": "mul",
      "left":  { "kind": "balance", "asset": "usdt" },
      "right": { "kind": "literal", "value": "0.01" } },
    "right": { "kind": "bin_op", "op": "mul",
      "left":  { "kind": "literal", "value": "2" },
      "right": { "kind": "func", "name": "atr", "period": 14 } } }
}

### Fixed-percent-of-balance sizing (buy 2% of USDT balance worth of BTC)

// Quantity = (balance × 0.02) / close_price
"then": {
  "side": "buy",
  "quantity": { "kind": "bin_op", "op": "div",
    "left": { "kind": "bin_op", "op": "mul",
      "left":  { "kind": "balance", "asset": "usdt" },
      "right": { "kind": "literal", "value": "0.02" } },
    "right": { "kind": "field", "field": "close" } }
}

## Constraints to respect

1. All numeric values (threshold, multiplier, value in literals) must be JSON strings, not numbers.
   The exception is `quantity`: it may be a decimal string ("0.001"), a SizingMethod object
   (with "method" tag), or an Expr object (with "kind" tag).
2. "offset" and "multiplier" may be omitted when their value is 0 / None respectively.
3. "field" in "func" defaults to "close" when omitted — include it explicitly to be safe.
4. Decimal values in "field": use snake_case: "open", "high", "low", "close", "volume".
5. "kind" is required on every Condition and Expr node.
6. Do not invent new kind/name/op/field values — only use the ones listed above.
   "timeframe" is an optional field on field/func/apply_func and legacy conditions; valid values are the same enum as candle_interval.
7. The strategy must have at least one buy rule and at least one sell rule.
8. A condition returning false (e.g. during warm-up) never fires an order — this is safe.

## Validation

The JSON Schema at [`assets/strategy/schema.json`](./strategy.schema.json) enforces all structural constraints above (required fields, enum values, string-typed decimals, `additionalProperties: false` on every node). Validate a strategy JSON before submitting it to catch mistakes early.

### Command-line (check-jsonschema)

```bash
pip install check-jsonschema          # one-time install
check-jsonschema --schemafile assets/strategy/schema.json my-strategy.json
```

### Python (jsonschema)

```python
import json, jsonschema

schema   = json.load(open("assets/strategy/schema.json"))
strategy = json.load(open("my-strategy.json"))

jsonschema.validate(strategy, schema)   # raises ValidationError on failure
```

### VSCode

Add to `.vscode/settings.json` to get inline validation and autocomplete for any file matching the pattern:

```json
{
  "json.schemas": [
    {
      "fileMatch": ["**/strategy*.json", "**/rules*.json"],
      "url": "./assets/strategy/schema.json"
    }
  ]
}
```

### LLM workflow

When asking an LLM (Claude, GPT-4, etc.) to translate a strategy, include the schema as context:

1. Paste the full contents of `assets/strategy/schema.json` into your prompt, or reference this document.
2. Ask the LLM to produce a strategy JSON that is valid against the schema.
3. Validate the output with one of the methods above before submitting a backtest.

Common LLM mistakes caught by the schema:
- Numeric values instead of quoted strings (`0.001` instead of `"0.001"`)
- Unknown `kind` / `name` / `op` values (schema rejects via `additionalProperties: false` + `enum`)
- Using `atr`, `adx`, `supertrend`, or `rsi` inside `apply_func` (the `ApplyFuncName` definition is a restricted subset that excludes these)
- Missing required fields (`period`, `field`, `op`, etc.)
