# Swym API Reference

This document covers the full HTTP API for the strategy iteration workflow: data preparation,
strategy authoring, backtest execution, and result analysis. No code changes to the backend are
required to define, test, and refine trading strategies.

**Base URL:** `http://<host>:3000`
**Content-Type:** `application/json` (all requests and responses)
**Error shape:** `{ "error": "<message>" }` with an appropriate HTTP status code.

---

## Table of Contents

1. [Workflow Overview](#workflow-overview)
2. [Data Preparation](#data-preparation)
   - [Ingest Configs](#ingest-configs)
   - [Candle Backfill](#candle-backfill)
   - [Market Subscriptions](#market-subscriptions)
   - [Exchanges & Instruments](#exchanges--instruments)
   - [Ingestion Stats](#ingestion-stats)
3. [Strategies](#strategies)
4. [Paper Runs (Backtests & Live)](#paper-runs)
   - [Run Config Schema](#run-config-schema)
   - [Create a Run](#post-apiv1paper-runs)
   - [List & Get Runs](#get-apiv1paper-runs)
   - [Compare Runs](#get-apiv1paper-runscompare)
   - [Cancel a Run](#post-apiv1paper-runsidcancel)
   - [Positions (Equity Curve)](#get-apiv1paper-runsidpositions)
   - [Candles](#get-apiv1paper-runsidcandles)
   - [Condition Audit Summary](#get-apiv1paper-runsidcondition-audit)
   - [Condition Audit Detail](#get-apiv1paper-runsidcondition-auditdetail)
   - [Instrument Data Range](#get-apiv1instrumentsexchangenamedata-range)
5. [Strategy DSL Reference](#strategy-dsl-reference)
   - [Rule-Based Strategy](#rule-based-strategy)
   - [Conditions](#conditions)
   - [Expressions (Expr)](#expressions-expr)
   - [Functions (FuncName)](#functions-funcname)
   - [Multi-Timeframe](#multi-timeframe)
   - [Quantity Sizing](#quantity-sizing)
6. [Complete Examples](#complete-examples)

---

## Workflow Overview

The standard iteration loop for developing a profitable strategy:

```
1. Create an ingest config          → historical trade data flows in via ingest-binance
2. Backfill candles                 → POST /api/v1/market-candles/backfill per interval
3. Check data coverage              → GET /api/v1/market-candles/coverage/{exchange}/{symbol}
                                       Verify coverage_pct ≥ 95% for your target date range
4. Author a strategy                → POST /api/v1/strategies  (optional, but enables grouping)
5. Submit a backtest                → POST /api/v1/paper-runs (mode: "backtest")
                                       400 with coverage details if data is incomplete
6. Poll until complete              → GET /api/v1/paper-runs/{id}
7. Analyse result_summary           → trade stats, Sharpe ratio, win rate, etc.
8. Download positions               → GET /api/v1/paper-runs/{id}/positions  (equity curve)
9. Download condition audit         → GET /api/v1/paper-runs/{id}/condition-audit/detail
10. Revise the strategy, repeat
```

### Handling incomplete data

The backtest submission endpoint enforces a **95% candle coverage** requirement. If fewer than 95%
of the expected candles are present for the requested date range, the request is rejected with a
`400 Bad Request` response explaining the shortfall and what to do:

```json
{
  "error": "insufficient 1h candle data for BTCUSDT on binance_spot: 4380 of 8760 expected candles available (50.0% coverage, minimum 95%). Candle ingestion last reached 2025-07-01 — it may still be catching up. Retry later or trigger a backfill via POST /api/v1/market-candles/backfill."
}
```

The error includes an ingestion status hint derived from the per-interval cursor:

| Hint | Meaning | Action |
|---|---|---|
| "Candle ingestion appears up to date" | Cursor is current; data is genuinely sparse | Run `POST /api/v1/market-candles/backfill` for the gap period |
| "Candle ingestion last reached {date}" | Cursor lags behind; worker is catching up | Wait and retry, or run a targeted backfill |
| "No candle ingestion cursor found" | Interval has never been ingested by the worker | Run `POST /api/v1/market-candles/backfill` to populate via Binance REST API |

Pre-check coverage before submitting a backtest using `GET /api/v1/market-candles/coverage/{exchange}/{symbol}`.
The response now includes `expected_count` and `coverage_pct` fields so you can verify readiness
without incurring a failed backtest submission.

---

## Data Preparation

### Ingest Configs

Ingest configs tell `ingest-binance` which instruments to download historical trade data for. The
service polls active configs, fetches zipped CSVs from `s3://data.binance.vision`, and inserts
rows into `market_trades`.

Binance provides data for three market types, selected with `source_type`:
- `"spot"` — spot markets under `data/spot/` (exchange epoch: 2017-07-01)
- `"futures_um"` — USD-M perpetuals under `data/futures/um/` (epoch: 2019-09-08)
- `"futures_cm"` — COIN-M perpetuals under `data/futures/cm/` (epoch: 2020-08-10)

Futures backtests use the same execution model as spot — the paper engine trades a configured
base quantity with no margin or leverage simulation. This is sufficient for directional strategy
validation.

#### `POST /api/v1/ingest-configs`

Create a new ingest config. The instrument is registered in the database the first time it is seen.

**Request body:**

```json
{
  "exchange": "binance_spot",
  "pair": "BTC-USDT",
  "source": "binance_s3",
  "source_type": "spot",
  "retention_days": 365,
  "candle_retention_days": null,
  "candle_intervals": ["1m", "5m", "1h", "4h", "1d"]
}
```

To ingest USD-M futures data:

```json
{
  "exchange": "BinanceFuturesUsd",
  "pair": "BTC-USDT",
  "source": "binance_s3",
  "source_type": "futures_um",
  "retention_days": 365,
  "candle_retention_days": null,
  "candle_intervals": ["5m", "1h", "4h", "1d"]
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `exchange` | string | required | Exchange identifier (e.g. `"binance_spot"`, `"BinanceFuturesUsd"`) |
| `pair` | string | required | Trading pair in `"BASE-QUOTE"` format (e.g. `"BTC-USDT"`) |
| `source` | string | `"binance_s3"` | Data source — only `"binance_s3"` is supported |
| `source_type` | string | `"spot"` | S3 data path variant: `"spot"`, `"futures_um"` (USD-M perpetuals), or `"futures_cm"` (COIN-M perpetuals) |
| `retention_days` | int | `365` | How many days of raw trades to keep. Older rows are purged after ingestion. |
| `candle_retention_days` | int\|null | `null` | Candle retention in days; `null` = unlimited |
| `candle_intervals` | string[] | `[]` | Candle intervals to pre-aggregate (e.g. `["5m","1h"]`). Valid: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"` |

**Response (201):** [`IngestConfigResponse`](#ingestconfigresponse)

---

#### `GET /api/v1/ingest-configs`

List all ingest configs.

**Query params:** none
**Response (200):** `IngestConfigResponse[]`

---

#### `PATCH /api/v1/ingest-configs/{id}`

Update an existing ingest config.

**Request body (all fields optional):**

```json
{
  "active": true,
  "retention_days": 730,
  "candle_intervals": ["1m", "5m", "15m", "1h", "4h", "1d"],
  "candle_retention_days": 180,
  "candle_retention_unlimited": false
}
```

| Field | Type | Description |
|---|---|---|
| `active` | bool | Pause (`false`) or resume (`true`) ingestion |
| `retention_days` | int | Update raw trade retention |
| `candle_intervals` | string[] | Replace the set of pre-aggregated intervals |
| `candle_retention_days` | int | Set candle retention in days |
| `candle_retention_unlimited` | bool | Set to `true` to clear the candle retention limit |

**Response (200):** `IngestConfigResponse`

---

#### `IngestConfigResponse`

```json
{
  "id": 1,
  "instrument_id": 42,
  "source": "binance_s3",
  "source_type": "spot",
  "retention_days": 365,
  "candle_retention_days": null,
  "active": true,
  "last_ingested_date": "2026-03-08",
  "candle_intervals": ["1m", "5m", "1h"],
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-03-09T12:00:00Z",
  "exchange": "binance_spot",
  "base_asset": "btc",
  "quote_asset": "usdt"
}
```

---

### Candle Backfill

Pre-aggregated OHLCV candles can also be fetched directly from the Binance REST API and stored in
the `market_candles` table. This is faster than waiting for `ingest-binance` to aggregate tick data
and is the recommended approach for candle-mode backtests.

#### `POST /api/v1/market-candles/backfill`

Fetch candles from Binance for a date range and store them. Idempotent (`ON CONFLICT DO NOTHING`).

**Request body:**

```json
{
  "exchange": "binance_spot",
  "symbol": "BTCUSDT",
  "interval": "1h",
  "from": "2025-01-01T00:00:00Z",
  "to": "2026-01-01T00:00:00Z"
}
```

| Field | Type | Description |
|---|---|---|
| `exchange` | string | Exchange identifier — must match an existing instrument |
| `symbol` | string | Exchange symbol (e.g. `"BTCUSDT"`, not `"BTC-USDT"`) |
| `interval` | string | Candle interval: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"` |
| `from` | ISO-8601 | Start of range (inclusive) |
| `to` | ISO-8601 | End of range (exclusive) |

The instrument must already exist in the database (created by an ingest config or market
subscription for the same exchange+symbol).

**Response (201):**

```json
{
  "inserted": 8760,
  "interval": "1h",
  "from": "2025-01-01T00:00:00Z",
  "to": "2026-01-01T00:00:00Z"
}
```

---

#### `GET /api/v1/market-candles/coverage/{exchange}/{symbol}`

Check which candle intervals are available and their date ranges.

**Path params:** `exchange` (e.g. `binance_spot`), `symbol` (e.g. `BTCUSDT`)
**Response (200):**

```json
[
  {
    "interval": "1h",
    "first_open": "2025-01-01T00:00:00Z",
    "last_close": "2026-01-01T00:00:00Z",
    "count": 8755,
    "expected_count": 8760,
    "coverage_pct": 99.94
  },
  {
    "interval": "4h",
    "first_open": "2025-01-01T00:00:00Z",
    "last_close": "2026-01-01T00:00:00Z",
    "count": 1800,
    "expected_count": 2190,
    "coverage_pct": 82.19
  }
]
```

| Field | Description |
|---|---|
| `count` | Actual candle rows stored in the database |
| `expected_count` | Expected rows based on interval duration across the available range |
| `coverage_pct` | `count / expected_count × 100`, capped at 100. Values below 95 indicate gaps. |

Use this before submitting a backtest to confirm data is complete for your chosen interval and
date range. The backtest endpoint requires `coverage_pct ≥ 95` for the specific `[starts_at,
finishes_at]` window; `coverage_pct` here is computed over the full available range, so a
sub-range may be complete even if the overall coverage is lower.

---

#### Backfill strategy

**The nginx timeout problem.** The backfill endpoint fetches candles from Binance and inserts them
in chunks of 500, committing each chunk before moving to the next. An nginx reverse proxy in front
of the API applies a proxy read timeout (typically 60–120 s). For fine-grained intervals over a
long date range, a single request can exceed this timeout:

| Interval | Candles per year | Risk |
|---|---|---|
| `1d` | ~365 | No issue |
| `4h` | ~2 190 | No issue |
| `1h` | ~8 760 | Marginal |
| `15m` | ~35 040 | High |
| `5m` | ~105 120 | Very high |
| `1m` | ~525 600 | Certain timeout for multi-month ranges |

When nginx times out, it closes the connection and returns an HTML error page. The API handler is
cancelled mid-run: chunks already committed remain in the database, but the remainder of the range
is not inserted. The `inserted` field in the response is never returned — the client receives HTML
instead of JSON.

**Detecting a truncated backfill.** A successful response is always JSON with an `inserted` field:

```json
{ "inserted": 4032, "interval": "5m", "from": "...", "to": "..." }
```

If the response body cannot be parsed as JSON, or the HTTP response is not `200`/`201`, the
backfill was cut short. Check the raw response body — a timeout returns an HTML `504 Gateway
Timeout` or similar.

After any backfill, always verify completeness:

```bash
curl -s https://<host>/api/v1/market-candles/coverage/binance_spot/BTCUSDT | \
  jq '.[] | select(.coverage_pct < 95)'
```

An empty result means all intervals are ≥ 95% covered. Any rows returned indicate gaps that need
a targeted re-backfill for that interval and sub-range.

**Recommended approach: quarterly chunks.** Break large date ranges into ≤ 3-month windows, one
request per chunk. Each chunk completes well within the proxy timeout even for `1m` data. Because
backfill is idempotent (`ON CONFLICT DO NOTHING`), re-running a chunk that already has data is
safe and inserts 0 rows.

Example shell loop (no external date utilities required):

```bash
#!/usr/bin/env bash
# Backfill in quarterly chunks; idempotent — safe to re-run.
set -euo pipefail

instrument="ETHUSDC"
interval="1m"
range_from="2025-01-01"
range_to="2026-03-01"

quarter_chunks() {
  local from="$1" to="$2" cursor="$from"
  while [[ "$cursor" < "$to" ]]; do
    local year month next_month next_year chunk_to
    year="${cursor%%-*}"; month="${cursor#*-}"; month="${month%%-*}"
    next_month=$(( 10#$month + 3 )); next_year=$year
    if (( next_month > 12 )); then next_month=$(( next_month - 12 )); next_year=$(( next_year + 1 )); fi
    chunk_to=$(printf "%04d-%02d-01" "$next_year" "$next_month")
    [[ "$chunk_to" > "$to" ]] && chunk_to="$to"
    echo "${cursor}T00:00:00Z"; echo "${chunk_to}T00:00:00Z"
    cursor="$chunk_to"
  done
}

mapfile -t chunks < <(quarter_chunks "$range_from" "$range_to")
i=0
while (( i < ${#chunks[@]} )); do
  chunk_from="${chunks[$i]}"; chunk_to="${chunks[$((i+1))]}"; i=$(( i + 2 ))
  response=$(curl -s --max-time 300 -X POST https://<host>/api/v1/market-candles/backfill \
    -H 'Content-Type: application/json' \
    -d "{\"exchange\":\"binance_spot\",\"symbol\":\"${instrument}\",\"interval\":\"${interval}\",\"from\":\"${chunk_from}\",\"to\":\"${chunk_to}\"}")
  inserted=$(echo "${response}" | jq -r '.inserted // empty' 2>/dev/null)
  if [[ -n "$inserted" ]]; then
    echo "ok  ${chunk_from}→${chunk_to}: ${inserted} inserted"
  else
    echo "ERR ${chunk_from}→${chunk_to}: ${response}"  # likely HTML — backfill incomplete
  fi
done
```

**Verifying the result.** After all chunks complete, check coverage:

```bash
curl -s https://<host>/api/v1/market-candles/coverage/binance_spot/ETHUSDC | jq .
```

Interpret the response:

| `coverage_pct` | Meaning | Action |
|---|---|---|
| ≥ 95 | Sufficient for backtesting | Proceed |
| 80–95 | Borderline — gaps exist | Re-run backfill for affected sub-range; check Binance availability for that period |
| < 80 | Significant gaps | Binance may not have data for that period, or a request failed silently; re-run and inspect per-chunk output |
| 0 or missing interval | No data at all | No backfill was run for this interval; run from scratch |

If `coverage_pct` is stuck below 95% after repeated backfills, the data may genuinely not exist
on Binance for that period (instrument was not listed, or trading was suspended). Narrow the
backtest `starts_at`/`finishes_at` to a range with full coverage.

---

### Market Subscriptions

Market subscriptions tell `market-worker` to open a live WebSocket feed and stream market events
into the database. Not required for backtesting (which replays historical data), but needed for
live paper-trading runs.

#### `POST /api/v1/market-subscriptions`

```json
{
  "exchange": "binance_spot",
  "pair": "BTC-USDT",
  "sub_kinds": ["PublicTrades"]
}
```

Valid `sub_kinds`: `"PublicTrades"`, `"OrderBooksL1"`, `"OrderBooksL2"`, `"OrderBooksL3"`,
`"Liquidations"`, `"Candles"`

**Response (201):** `MarketSubscriptionResponse[]` (one per sub_kind)

---

#### `GET /api/v1/market-subscriptions`

**Query params:**

| Param | Type | Default | Description |
|---|---|---|---|
| `active_only` | bool | `true` | Filter to active subscriptions only |

**Response (200):**

```json
[
  {
    "id": 1,
    "instrument_id": 42,
    "sub_kind": "PublicTrades",
    "active": true,
    "created_at": "2026-01-01T00:00:00Z",
    "updated_at": "2026-01-01T00:00:00Z",
    "exchange": "binance_spot",
    "base_asset": "btc",
    "quote_asset": "usdt"
  }
]
```

---

#### `DELETE /api/v1/market-subscriptions/{id}`

Deactivate a market subscription. `market-worker` will stop the associated stream on its next poll
cycle. **Response (204): no body.**

---

### Exchanges & Instruments

#### `GET /api/v1/exchanges`

List all supported exchanges with their supported subscription kinds.

```json
[
  {
    "name": "binance_spot",
    "supported_sub_kinds": ["PublicTrades", "OrderBooksL1"],
    "supports_instrument_lookup": true
  }
]
```

#### `GET /api/v1/exchanges/{name}/instruments`

Fetch tradeable instruments from the exchange REST API. Useful for discovering valid symbol names
before creating ingest configs.

#### `GET /api/v1/exchanges/{name}/sub-kinds`

List subscription kinds supported by an exchange.

---

### Ingestion Stats

#### `GET /api/v1/ingestion-stats`

Real-time counts of ingested data per instrument — useful for confirming data is flowing before
kicking off a backtest.

```json
[
  {
    "instrument_id": 42,
    "exchange_name": "binance_spot",
    "base_asset": "btc",
    "quote_asset": "usdt",
    "kind": "spot",
    "trade_count_total": 12500000,
    "trade_count_1h": 48000,
    "l1_count_total": 8200000,
    "l1_count_1h": 31000,
    "latest_trade_time": "2026-03-09T11:59:58Z",
    "latest_l1_time": "2026-03-09T11:59:59Z",
    "estimated_storage_bytes": 1250000000
  }
]
```

---

## Strategies

Strategies can be created independently and then referenced when submitting runs. This is optional
— strategy records are also created automatically when a paper run is submitted. The main benefit
of pre-creating a strategy is to get a stable UUID and group runs by `strategy_id`.

### `POST /api/v1/strategies/validate`

Validates a strategy config JSON without persisting anything. Runs the full deserialization
pipeline and semantic checks, returning every error as a structured list. Use this before
submitting a run to confirm the config is correct.

Always returns **HTTP 200**. `valid: false` is a validation result, not an HTTP error.

**Request body:** the strategy config object directly (same shape as the `config` field in
`POST /api/v1/strategies`):

```json
{
  "type": "rule_based",
  "candle_interval": "5m",
  "rules": [
    {
      "when": { "kind": "ema_crossover", "fast_period": 21, "slow_period": 9, "direction": "above" },
      "then": { "side": "buy", "quantity": { "method": "fixed_sum", "amount": "500" } }
    }
  ]
}
```

**Response — valid strategy (200):**

```json
{
  "valid": true,
  "errors": []
}
```

**Response — invalid strategy (200):**

```json
{
  "valid": false,
  "errors": [
    {
      "path": "rules[0].when",
      "message": "fast_period (21) must be less than slow_period (9)"
    },
    {
      "path": "candle_interval",
      "message": "\"2m\" is not a valid interval; must be one of: 1m, 5m, 15m, 1h, 4h, 1d"
    }
  ]
}
```

Each error object has:

| Field | Type | Description |
|---|---|---|
| `message` | string | Human-readable description of the problem |
| `path` | string\|null | Dotted JSON path to the offending field (e.g. `rules[0].when.fast_period`), absent for top-level structural errors |

Serde deserialization errors short-circuit on the first structural problem (one error returned);
semantic errors are all collected and returned together.

---

### `POST /api/v1/strategies`

**Request body:**

```json
{
  "name": "EMA crossover 9/21",
  "description": "Golden/death cross on 1h candles with RSI confirmation.",
  "config": {
    "type": "rule_based",
    "candle_interval": "1h",
    "rules": [...]
  }
}
```

| Field | Type | Description |
|---|---|---|
| `config` | object | Strategy config JSON — see [Strategy DSL Reference](#strategy-dsl-reference) |
| `name` | string\|null | Human-readable name. If supplied, the strategy is keyed by `(name, version=1)` and re-submissions update it in place. |
| `description` | string\|null | Markdown description. Not part of the content hash. |

**Response (201):**

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "hash": "a3f2b1...",
  "config": { ... },
  "hash_input": { ... },
  "created_at": "2026-03-09T00:00:00Z",
  "name": "EMA crossover 9/21",
  "version": 1,
  "description": "Golden/death cross on 1h candles with RSI confirmation."
}
```

The `hash` is a SHA-256 of the normalized config (key-sorted, whitespace-stripped). Structurally
identical strategies — regardless of key order or whitespace — produce the same hash. `hash_input`
shows the normalised form used as input.

---

### `GET /api/v1/strategies`

**Query params:**

| Param | Default | Description |
|---|---|---|
| `limit` | `50` | Max results |
| `offset` | `0` | Pagination offset |

**Response (200):** `StrategyResponse[]`

---

### `GET /api/v1/strategies/{id}`

**Response (200):** `StrategyResponse`

---

## Paper Runs

A paper run is a single execution of a strategy against either live market data or a historical
date range. The run is queued and picked up by `paper-executor`, which runs the barter paper
trading engine and persists the results.

### Run Config Schema

The `config` field in a paper run request must be a valid `RunConfig` JSON object:

```json
{
  "instrument": {
    "exchange": "binance_spot",
    "name_exchange": "BTCUSDT",
    "underlying": { "base": "btc", "quote": "usdt" },
    "quote": "underlying_quote",
    "kind": "spot"
  },
  "execution": {
    "mocked_exchange": "binance_spot",
    "latency_ms": 100,
    "fees_percent": "0.001",
    "initial_state": {
      "exchange": "binance_spot",
      "balances": [
        {
          "asset": "usdt",
          "balance": { "total": "10000", "free": "10000" },
          "time_exchange": "2025-01-01T00:00:00Z"
        }
      ],
      "instrument": {
        "instrument_name": "BTCUSDT",
        "orders": []
      }
    }
  },
  "strategy": {
    "type": "rule_based",
    "candle_interval": "1h",
    "rules": [...]
  },
  "audit_conditions": true
}
```

#### `instrument`

| Field | Type | Description |
|---|---|---|
| `exchange` | string | Exchange identifier (e.g. `"binance_spot"`) |
| `name_exchange` | string | Symbol as used on the exchange (e.g. `"BTCUSDT"`) |
| `underlying.base` | string | Base asset, lowercase (e.g. `"btc"`) |
| `underlying.quote` | string | Quote asset, lowercase (e.g. `"usdt"`) |
| `quote` | string | Always `"underlying_quote"` for spot |
| `kind` | string | Always `"spot"` for spot markets |

#### `execution`

| Field | Type | Description |
|---|---|---|
| `mocked_exchange` | string | Must match `instrument.exchange` |
| `latency_ms` | int | Simulated order submission latency in milliseconds |
| `fees_percent` | decimal | Trading fee as a raw decimal — `"0.001"` = 0.1% (one basis point = `"0.0001"`) |
| `initial_state.exchange` | string | Must match `instrument.exchange` |
| `initial_state.balances` | array | Starting balances. Typically one USDT balance for a BTC/USDT strategy. |
| `initial_state.instrument.instrument_name` | string | Must match `instrument.name_exchange` |
| `initial_state.instrument.orders` | array | Open orders — always `[]` for a fresh run |

#### `audit_conditions`

When `true` (the default), every bar's condition evaluation tree is stored in
`paper_run_condition_audit` and the pass-rate summary is added to `result_summary`. Set to `false`
to skip per-bar storage and reduce database writes for very long backtests.

---

### `POST /api/v1/paper-runs`

Submit a new paper run (backtest or live).

**Request body:**

```json
{
  "mode": "backtest",
  "starts_at": "2025-01-01T00:00:00Z",
  "finishes_at": "2026-01-01T00:00:00Z",
  "risk_free_return": "0.05",
  "candle_interval": "1h",
  "config": { ... }
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | string | `"live"` | `"backtest"` or `"live"` |
| `starts_at` | ISO-8601 | required | Start of the trading window. Must be in the past for backtests. |
| `finishes_at` | ISO-8601 | required | End of the trading window. Must be in the future for live runs. |
| `risk_free_return` | decimal | `"0.05"` | Annual risk-free rate for Sharpe ratio calculation (0.05 = 5%) |
| `candle_interval` | string | null | Candle replay interval for backtest mode. Not needed for `RuleBased` strategies — the interval is embedded in the strategy config. Valid: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"`. Omit to replay raw ticks. |
| `config` | object | required | `RunConfig` — see [Run Config Schema](#run-config-schema) |

**Validation rules:**
- `finishes_at` must be after `starts_at`
- For `"backtest"`: `starts_at` must be in the past; data must exist for the instrument and interval; the range must fall within available data
- For `"backtest"` with candles: the requested range must have **≥ 95% candle coverage** (actual count vs expected count derived from interval duration). Returns 400 with a diagnostic message and ingestion status hint if below threshold.
- For `"live"`: `finishes_at` must be in the future; `candle_interval` must not be set
- For `RuleBased` strategies: all timeframes referenced by expressions must have available candle data with ≥ 95% coverage
- Raw-tick backtests are rejected if the date range contains more than 500,000,000 trades

**Insufficient coverage response (400):**

```json
{
  "error": "insufficient 1h candle data for BTCUSDT on binance_spot: 4380 of 8760 expected candles available (50.0% coverage, minimum 95%). Candle ingestion last reached 2025-07-01 — it may still be catching up. Retry later or trigger a backfill via POST /api/v1/market-candles/backfill."
}
```

See [Handling incomplete data](#handling-incomplete-data) for the interpretation guide.

**Response (201):** `PaperRunResponse`

---

### `GET /api/v1/paper-runs`

List paper runs, newest first.

**Query params:**

| Param | Type | Default | Description |
|---|---|---|---|
| `status` | string | — | Filter by status: `queued`, `running`, `complete`, `failed`, `cancelled` |
| `strategy_id` | UUID | — | Filter by strategy |
| `limit` | int | `50` | Max results |
| `offset` | int | `0` | Pagination offset |

**Response (200):** `PaperRunResponse[]`

---

### `GET /api/v1/paper-runs/{id}`

Get a single paper run by UUID.

**Response (200):** `PaperRunResponse`

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "complete",
  "mode": "backtest",
  "config": { ... },
  "starts_at": "2025-01-01T00:00:00Z",
  "finishes_at": "2026-01-01T00:00:00Z",
  "risk_free_return": "0.05",
  "result_summary": { ... },
  "error_message": null,
  "worker_id": "paper-executor-1",
  "created_at": "2026-03-09T10:00:00Z",
  "started_at": "2026-03-09T10:00:05Z",
  "completed_at": "2026-03-09T10:02:30Z",
  "candle_interval": "1h",
  "strategy_id": "..."
}
```

#### `result_summary` structure

The `result_summary` field is populated when the run status is `complete`. It contains flat
summary fields for easy consumption, the full barter tear sheet output for detailed analysis,
and (for backtests) metadata and the condition audit summary.

```json
{
  "total_positions": 47,
  "winning_positions": 28,
  "losing_positions": 19,
  "win_rate": "0.5957",
  "profit_factor": "1.83",
  "total_pnl": "1338.70",
  "total_fees": "98.20",
  "net_pnl": "1240.50",
  "sharpe_ratio": "1.42",
  "time_engine_start": "2025-01-01T00:00:00Z",
  "time_engine_end": "2026-01-01T00:00:00Z",
  "instruments": {
    "binance_btcusdt_spot": {
      "pnl": "1240.50",
      "pnl_return": { "value": "0.1241", "interval": "Daily" },
      "sharpe_ratio": { "value": "1.42", "interval": "Daily" },
      "sortino_ratio": { "value": "2.10", "interval": "Daily" },
      "calmar_ratio": { "value": "0.85", "interval": "Daily" },
      "pnl_drawdown": null,
      "pnl_drawdown_mean": null,
      "pnl_drawdown_max": null,
      "win_rate": { "value": "0.5957" },
      "profit_factor": { "value": "1.83" }
    }
  },
  "assets": [
    {
      "exchange": "BinanceSpot",
      "asset": "usdt",
      "tear_sheet": {
        "balance_end": { "total": "11240.50", "free": "11240.50" },
        "drawdown": null,
        "drawdown_mean": null,
        "drawdown_max": null
      }
    }
  ],
  "backtest_metadata": {
    "candle_count": 8760,
    "candle_interval": "1h",
    "data_range_start": "2025-01-01T00:00:00Z",
    "data_range_end": "2026-01-01T00:00:00Z",
    "position_count": 47
  },
  "condition_audit_summary": {
    "total_bars": 8760,
    "rules": [
      {
        "rule_index": 0,
        "rule_comment": "Long entry",
        "times_fired": 47,
        "fire_rate": 0.00536,
        "conditions": [
          {
            "path": "when.conditions[0]",
            "kind": "ema_crossover",
            "true_count": 52,
            "true_rate": 0.00594,
            "insufficient_data_count": 0
          },
          {
            "path": "when.conditions[1]",
            "kind": "position",
            "true_count": 8712,
            "true_rate": 0.9945,
            "insufficient_data_count": 0
          }
        ]
      }
    ]
  }
}
```

| Field | Description |
|---|---|
| `total_positions` | Total closed positions |
| `winning_positions` | Positions with `pnl > 0` |
| `losing_positions` | Positions with `pnl ≤ 0` |
| `win_rate` | Decimal string, e.g. `"0.5957"`. `null` if no trades. |
| `profit_factor` | Gross profit / gross loss. `null` if no losing trades. |
| `total_pnl` | Gross PnL before fees (net_pnl + total_fees) |
| `total_fees` | Sum of entry and exit fees across all positions |
| `net_pnl` | Net PnL after fees |
| `sharpe_ratio` | Daily Sharpe ratio. Decimal string or `null`. |
| `sortino_ratio` | Sortino ratio (penalises downside volatility only). Decimal string or `null`. |
| `calmar_ratio` | Calmar ratio (return / max drawdown). Decimal string or `null`. |
| `max_drawdown` | Peak-to-trough drawdown as a fraction, e.g. `"-0.12"`. Decimal string or `null`. |
| `pnl_return` | Total return as a fraction, e.g. `"0.08"`. Decimal string or `null`. |
| `avg_win` | Average PnL of winning positions. Decimal string or `null` if no winners. |
| `avg_loss` | Average PnL of losing positions (negative). Decimal string or `null` if no losers. |
| `max_win` | Largest single winning PnL. Decimal string or `null` if no positions. |
| `max_loss` | Most negative single PnL. Decimal string or `null` if no positions. |
| `avg_hold_duration_secs` | Average position hold time in seconds. Float or `null` if no positions. |
| `instruments` | Full barter per-instrument tear sheet (drawdown, Sortino, Calmar, etc.) |
| `assets` | Final balance and drawdown per asset |
| `backtest_metadata` | Candle/trade counts and date range (backtests only) |
| `condition_audit_summary` | Condition pass rates (RuleBased backtests only) |

The `condition_audit_summary` is always present for completed `RuleBased` backtests, regardless of
the `audit_conditions` setting. Tick backtests use `"trade_count"` instead of `"candle_count"` in
`backtest_metadata`. Live runs omit `backtest_metadata`, `condition_audit_summary`, and the position
aggregate fields (`total_positions`, `winning_positions`, `losing_positions`, `total_pnl`,
`total_fees`, `avg_win`, `avg_loss`, `max_win`, `max_loss`, `avg_hold_duration_secs`).

---

### `GET /api/v1/paper-runs/compare`

Returns flat metrics for multiple runs in a single call. Designed for side-by-side strategy
comparison without fetching full `result_summary` blobs.

**Query parameters:**

| Param | Required | Description |
|---|---|---|
| `ids` | yes | Comma-separated list of run UUIDs (max 50) |

**Response (200):** Array of `RunMetricsSummary` objects in the same order as the requested ids.
Runs not found are silently omitted.

```json
[
  {
    "id": "00000000-0000-0000-0000-000000000001",
    "status": "complete",
    "strategy_id": "...",
    "starts_at": "2024-01-01T00:00:00Z",
    "finishes_at": "2024-03-01T00:00:00Z",
    "candle_interval": "1h",
    "total_positions": 142,
    "win_rate": "0.5845",
    "profit_factor": "1.38",
    "net_pnl": "842.15",
    "total_pnl": "910.40",
    "total_fees": "68.25",
    "sharpe_ratio": "1.21",
    "sortino_ratio": "1.67",
    "calmar_ratio": "0.94",
    "max_drawdown": "-0.089",
    "pnl_return": "0.0842",
    "avg_win": "18.32",
    "avg_loss": "-11.04",
    "max_win": "94.10",
    "max_loss": "-52.30",
    "avg_hold_duration_secs": 7420.5
  }
]
```

---

### `POST /api/v1/paper-runs/{id}/cancel`

Cancel a queued run. Only works when `status` is `"queued"`. Running runs cannot be interrupted.

**Response (200):** `PaperRunResponse` with `status: "cancelled"`

---

### `GET /api/v1/paper-runs/{id}/positions`

Download closed positions for an equity curve or trade-by-trade analysis.

**Query params:**

| Param | Type | Description |
|---|---|---|
| `samples` | int | If provided and less than the total count, downsample to this many points using uniform NTILE sampling. Useful for plotting. |

**Response (200):**

```json
{
  "total": 47,
  "sampled": 47,
  "positions": [
    {
      "id": 1,
      "run_id": "...",
      "seq": 1,
      "side": "buy",
      "price_entry": "42000.00",
      "price_exit": "44500.00",
      "quantity": "0.001",
      "pnl": "2.50",
      "fees_enter": "0.042",
      "fees_exit": "0.0445",
      "time_enter": "2025-01-15T08:00:00Z",
      "time_exit": "2025-01-17T12:00:00Z"
    }
  ]
}
```

All monetary values are in the quote asset (e.g. USDT). `seq` is 1-based.

---

### `GET /api/v1/paper-runs/{id}/candles`

Retrieve the OHLCV candles that were replayed during a candle-mode backtest. Only available for
runs with a `candle_interval` set.

**Response (200):**

```json
{
  "interval": "1h",
  "total": 8760,
  "candles": [
    {
      "open_time": "2025-01-01T00:00:00Z",
      "close_time": "2025-01-01T01:00:00Z",
      "open": 42000.0,
      "high": 42800.0,
      "low": 41900.0,
      "close": 42500.0,
      "volume": 1240.5
    }
  ]
}
```

---

### `GET /api/v1/paper-runs/{id}/condition-audit`

Returns the condition pass-rate summary from `result_summary.condition_audit_summary`.

Available for all completed `RuleBased` backtests. Returns 404 for non-rule-based runs or
incomplete runs.

**Response (200):** Same shape as `result_summary.condition_audit_summary` (see above).

---

### `GET /api/v1/paper-runs/{id}/condition-audit/detail`

Returns paginated per-bar condition evaluation detail. Only available when the run was submitted
with `audit_conditions: true` in the config. Returns 404 if no detail data exists.

**Query params:**

| Param | Type | Default | Description |
|---|---|---|---|
| `limit` | int | `1000` | Rows per page (clamped to 1–10000) |
| `offset` | int | `0` | Row offset for pagination |

**Response (200):**

```json
{
  "total": 8760,
  "offset": 0,
  "limit": 1000,
  "rows": [
    {
      "id": 1,
      "run_id": "...",
      "bar_index": 0,
      "bar_time": "2025-01-01T01:00:00Z",
      "rule_index": 0,
      "rule_fired": false,
      "conditions": [
        {
          "path": "when.conditions[0]",
          "kind": "ema_crossover",
          "result": false,
          "insufficient_data": false,
          "left_value": "41800.50",
          "right_value": "42100.25"
        },
        {
          "path": "when.conditions[1]",
          "kind": "position",
          "result": true,
          "insufficient_data": false
        }
      ]
    }
  ]
}
```

#### `ConditionAudit` fields

| Field | Description |
|---|---|
| `path` | JSON path of the condition within the rule, e.g. `"when.conditions[2]"` |
| `kind` | Condition type discriminator (e.g. `"compare"`, `"cross_over"`, `"rsi"`) |
| `comment` | Optional annotation if the condition had a `comment` field |
| `result` | Whether the condition evaluated to `true` on this bar |
| `insufficient_data` | `true` when the condition returned `false` due to missing candle history (warmup period), not a genuine logical false |
| `left_value` | Numeric LHS value for binary conditions (Compare, CrossOver, CrossUnder, Rsi, Bollinger, EventCount) |
| `right_value` | Numeric RHS value for binary conditions |

---

### `GET /api/v1/instruments/{exchange}/{name}/data-range`

Check the available trade data range for an instrument. Useful for determining the maximum
backtest window.

**Path params:** `exchange` (e.g. `binance_spot`), `name` (e.g. `BTCUSDT`)

**Response (200):**

```json
{ "start": "2023-01-01T00:00:00Z", "end": "2026-03-09T00:00:00Z" }
```

Returns `null` (200) if no data exists for the instrument.

---

## Strategy DSL Reference

All strategy types are expressed as JSON. The `config.strategy` field in a run config (or the
`config` field in `POST /api/v1/strategies`) is tagged with `"type"` to select the strategy.

### Rule-Based Strategy

The only API-defined strategy type. Evaluated on every candle close. All rules whose `when`
condition is true simultaneously fire their `then` action.

```json
{
  "type": "rule_based",
  "candle_interval": "1h",
  "rules": [
    {
      "comment": "Long entry: EMA cross + flat",
      "when": { ... },
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Long exit: close cross back below",
      "when": { ... },
      "then": { "side": "sell", "quantity": "9999" }
    }
  ]
}
```

| Field | Description |
|---|---|
| `candle_interval` | Drives strategy evaluation: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"` |
| `rules` | Ordered list of rules. All true rules fire on each bar — they are not exclusive. |
| `rule.comment` | Optional annotation; stored in condition audit output |
| `rule.when` | A `Condition` tree (see below) |
| `rule.then.side` | `"buy"` or `"sell"` |
| `rule.then.quantity` | Fixed decimal string (e.g. `"0.001"`) or dynamic `Expr` object |
| `rule.then.reverse` | `true` to reverse through flat into a new position (default `false`) |

**Closing orders:** When an order's side is opposite to the current position (sell while long,
or buy while short), the engine automatically uses the full position quantity, ignoring the
configured sizing. This ensures clean position closes. When flat or adding to an existing
position, the configured quantity is used as-is.

**Reversals:** Set `"reverse": true` on a rule action to close the existing position *and*
immediately open a new one in the opposite direction. The submitted order quantity becomes
`existing_position_qty + configured_quantity`, and barter splits the fill proportionally —
the first portion closes the old position, the remainder opens the new one. When there is no
opposing position `reverse` has no effect and the configured quantity is used normally.

Example — EMA crossover strategy that always holds a position (long or short):
```json
{
  "rules": [
    {
      "when": { "kind": "ema_crossover", "fast": 9, "slow": 21, "direction": "up" },
      "then": { "side": "buy", "quantity": "0.001", "reverse": true }
    },
    {
      "when": { "kind": "ema_crossover", "fast": 9, "slow": 21, "direction": "down" },
      "then": { "side": "sell", "quantity": "0.001", "reverse": true }
    }
  ]
}
```

**Warm-up:** Indicators require historical candles to compute. The executor automatically
pre-loads candles before `starts_at` based on the maximum indicator period in the strategy. You do
not need to extend `starts_at` manually.

---

### Conditions

All conditions are JSON objects tagged with `"kind"`. Combinators compose leaf conditions.

#### Leaf Conditions

##### `ema_crossover`

True when the fast EMA crosses above (or below) the slow EMA on the current bar. This is an
**event** — it fires for one bar only.

```json
{
  "kind": "ema_crossover",
  "fast_period": 9,
  "slow_period": 21,
  "direction": "above",
  "timeframe": "1h"
}
```

| Field | Values | Description |
|---|---|---|
| `fast_period` | int | Fast EMA period |
| `slow_period` | int | Slow EMA period |
| `direction` | `"above"` \| `"below"` | `"above"` = golden cross; `"below"` = death cross |
| `timeframe` | string | Optional — defaults to primary `candle_interval` |

---

##### `ema_trend`

True when the close price is above (or below) an EMA. This is a **state** — true on every bar
while the condition holds.

```json
{
  "kind": "ema_trend",
  "period": 200,
  "direction": "above",
  "timeframe": "1d"
}
```

---

##### `rsi`

True when RSI is above or below a threshold.

```json
{
  "kind": "rsi",
  "period": 14,
  "threshold": "30",
  "comparison": "below",
  "timeframe": "1h"
}
```

| Field | Default | Description |
|---|---|---|
| `period` | `14` | RSI lookback period |
| `threshold` | required | Threshold value (0–100) |
| `comparison` | required | `"above"` or `"below"` |

---

##### `bollinger`

True when price breaks above the upper or below the lower Bollinger Band.

```json
{
  "kind": "bollinger",
  "period": 20,
  "num_std_dev": "2.0",
  "band": "below_lower"
}
```

| Field | Default | Values |
|---|---|---|
| `period` | `20` | SMA period |
| `num_std_dev` | `"2.0"` | Standard deviation multiplier |
| `band` | required | `"above_upper"` or `"below_lower"` |

---

##### `price_level`

True when price is above or below a fixed price level.

```json
{
  "kind": "price_level",
  "price": "50000",
  "direction": "above"
}
```

---

##### `position`

True when the current position matches the required state.

```json
{ "kind": "position", "state": "flat" }
```

Values: `"flat"` (no open position), `"long"` (long position held), `"short"` (short position held).

> All three states are fully supported. A `"sell"` action while flat opens a short position;
> `{ "kind": "position", "state": "short" }` then matches until the short is closed.

---

#### Combinator Conditions

##### `all_of`

True when **all** sub-conditions are true (logical AND).

```json
{
  "kind": "all_of",
  "conditions": [
    { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above" },
    { "kind": "position", "state": "flat" }
  ]
}
```

---

##### `any_of`

True when **any** sub-condition is true (logical OR).

```json
{
  "kind": "any_of",
  "conditions": [
    { "kind": "rsi", "threshold": "30", "comparison": "below" },
    { "kind": "bollinger", "band": "below_lower" }
  ]
}
```

---

##### `not`

Inverts a sub-condition.

```json
{
  "kind": "not",
  "condition": { "kind": "position", "state": "flat" }
}
```

---

#### Expression-Based Conditions

These conditions use numeric `Expr` trees for flexible comparisons.

##### `compare`

True when a numeric comparison holds: `left op right`.

```json
{
  "kind": "compare",
  "left": { "kind": "func", "name": "rsi", "period": 14 },
  "op": "<",
  "right": { "kind": "literal", "value": "30" }
}
```

Valid `op` values: `">"`, `"<"`, `">="`, `"<="`, `"=="`

---

##### `cross_over`

True when `left` crossed **above** `right` on the current bar (left was ≤ right on the previous
bar and > right on the current bar). Fires for one bar only.

```json
{
  "kind": "cross_over",
  "left": { "kind": "func", "name": "ema", "period": 9 },
  "right": { "kind": "func", "name": "ema", "period": 21 }
}
```

---

##### `cross_under`

True when `left` crossed **below** `right` on the current bar. Fires for one bar only.

```json
{
  "kind": "cross_under",
  "left": { "kind": "field", "field": "close" },
  "right": { "kind": "func", "name": "sma", "period": 20 }
}
```

---

##### `event_count`

True when an inner condition fired a certain number of times within the last `period` bars
(offsets 1..=period, **excluding** the current bar). Useful for confirming a recent event without
requiring the event to be ongoing.

```json
{
  "kind": "event_count",
  "condition": { "kind": "cross_under", "left": { ... }, "right": { ... } },
  "period": 10,
  "op": ">=",
  "count": 1
}
```

Returns `false` during warm-up (fewer than `period` candles available).

> **Offset-awareness:** Only `compare`, `cross_over`, `cross_under`, `all_of`, `any_of`, `not`,
> and nested `event_count` conditions are offset-aware. `position`, `ema_crossover`, `ema_trend`,
> `rsi`, and `bollinger` always evaluate against the current bar and return `false` at any
> non-zero offset inside `event_count`.

---

### Expressions (Expr)

Expressions evaluate to a numeric value (`Option<Decimal>`) against the current candle history.
Used in `compare`, `cross_over`, `cross_under`, and as dynamic `quantity` specs. All `Expr`
objects are tagged with `"kind"`.

#### `literal`

A constant numeric value.

```json
{ "kind": "literal", "value": "100.5" }
```

---

#### `field`

An OHLCV candle field, optionally from `offset` bars ago.

```json
{ "kind": "field", "field": "close" }
{ "kind": "field", "field": "low", "offset": 1 }
```

| Field | Values | Description |
|---|---|---|
| `field` | `"open"`, `"high"`, `"low"`, `"close"`, `"volume"` | Which OHLCV field |
| `offset` | int ≥ 0 | Bars ago (0 = current bar, 1 = previous bar) |
| `timeframe` | string | Optional timeframe override |

---

#### `func`

A rolling-window function applied to a candle field.

```json
{ "kind": "func", "name": "ema", "field": "close", "period": 21 }
{ "kind": "func", "name": "atr", "period": 14 }
{ "kind": "func", "name": "supertrend", "period": 10, "multiplier": "3.0" }
{ "kind": "func", "name": "sma", "field": "close", "period": 200, "offset": 5, "timeframe": "1d" }
```

| Field | Default | Description |
|---|---|---|
| `name` | required | See [Functions](#functions-funcname) |
| `field` | `"close"` | Input candle field (ignored by ATR, ADX — they use H/L/C internally) |
| `period` | required | Lookback period |
| `offset` | `0` | Shift the window back N bars (0 = window ending on current bar) |
| `multiplier` | `null` | Used by `supertrend` (ATR multiplier; default 3.0) and `bollinger_upper`/`bollinger_lower` (std dev multiplier; default 2.0) |
| `timeframe` | null | Optional timeframe override |

---

#### `bin_op`

Arithmetic combination of two expressions.

```json
{
  "kind": "bin_op",
  "op": "div",
  "left": { "kind": "func", "name": "highest", "field": "high", "period": 20 },
  "right": { "kind": "func", "name": "lowest", "field": "low", "period": 20 }
}
```

`op` values: `"add"`, `"sub"`, `"mul"`, `"div"`

Returns `None` on division by zero.

---

#### `apply_func`

Like `func`, but takes an arbitrary sub-expression as input instead of a raw candle field. Enables
function composition.

```json
{
  "kind": "apply_func",
  "name": "ema",
  "input": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
  "period": 20
}
```

This computes EMA-of-EMA (double EMA, used in DEMA). Other examples:

- **Hull MA:** `WMA(2 × WMA(n/2) − WMA(n), sqrt(n))`
- **VWAP:** `SUM(close × volume, n) / SUM(volume, n)` using `apply_func` + `bin_op`

Not all functions are valid inside `apply_func`. `atr`, `supertrend`, `adx`, and `rsi` require
multi-field per-bar logic and will return `None` here.

---

#### `unary_op`

Unary math on a sub-expression.

```json
{
  "kind": "unary_op",
  "op": "abs",
  "operand": { "kind": "bin_op", "op": "sub", "left": { ... }, "right": { ... } }
}
```

`op` values:
- `"abs"` — absolute value
- `"sqrt"` — square root (returns `None` for negative input)
- `"neg"` — arithmetic negation
- `"log"` — natural logarithm (returns `None` for non-positive input)

---

#### `bars_since`

Returns the number of bars ago that a condition was last true (within a `period`-bar lookback
window). Returns `None` if the condition never fired within the window.

```json
{
  "kind": "bars_since",
  "condition": { "kind": "cross_under", "left": { ... }, "right": { ... } },
  "period": 20
}
```

Use inside `compare` to express recency constraints:

```json
{
  "kind": "compare",
  "left": { "kind": "bars_since", "condition": { ... }, "period": 20 },
  "op": "<=",
  "right": { "kind": "literal", "value": "5" }
}
```

("The event occurred within the last 5 bars.")

---

#### `entry_price`

Volume-weighted average entry price of the current open position. Returns `None` when flat.

```json
{ "kind": "entry_price" }
```

---

#### `position_quantity`

Absolute quantity of the current open position in base asset units. Returns `None` when flat.

```json
{ "kind": "position_quantity" }
```

---

#### `unrealised_pnl`

Estimated unrealised PnL of the current open position in quote asset units. Returns `None` when flat.

```json
{ "kind": "unrealised_pnl" }
```

---

#### `bars_since_entry`

Number of complete candle bars elapsed since the position was opened. Computed as integer division
of elapsed seconds by the primary candle interval seconds. Returns `None` when flat.

```json
{ "kind": "bars_since_entry" }
```

Use to implement time-based exits:

```json
{
  "kind": "compare",
  "left": { "kind": "bars_since_entry" },
  "op": ">=",
  "right": { "kind": "literal", "value": "24" }
}
```

("Exit after 24 bars in the trade.")

---

#### `balance`

Free balance of a named asset. Case-insensitive. Returns `None` if the asset is not found.

```json
{ "kind": "balance", "asset": "usdt" }
```

---

### Functions (FuncName)

| Name | Description | Notes |
|---|---|---|
| `highest` | Rolling maximum of a field over `period` bars | |
| `lowest` | Rolling minimum of a field over `period` bars | |
| `sma` | Simple moving average | |
| `ema` | Exponential moving average (Wilder's initialisation) | |
| `wma` | Weighted moving average (linear weights, oldest = 1, newest = period) | |
| `rsi` | Relative Strength Index | Period default 14 in legacy `rsi` condition |
| `std_dev` | Population standard deviation | |
| `sum` | Rolling sum | |
| `atr` | Average True Range (Wilder's smoothing) | Ignores `field`; uses H/L/C. Requires `period + 1` candles. |
| `adx` | Average Directional Index (Wilder's smoothing) | Ignores `field`; uses H/L/C. Requires ~`2 × period + 1` candles. |
| `supertrend` | ATR-based Supertrend line | `period` = ATR period; `multiplier` = ATR multiplier (default 3.0). Use `cross_over`/`cross_under` against close for signals. |
| `bollinger_upper` | Upper Bollinger Band: `SMA + multiplier × StdDev` | `multiplier` defaults to 2.0 |
| `bollinger_lower` | Lower Bollinger Band: `SMA − multiplier × StdDev` | `multiplier` defaults to 2.0 |

---

### Multi-Timeframe

Any expression or condition that accepts a `timeframe` field can reference a different candle
interval than the strategy's primary `candle_interval`. The `timeframe` string must be a valid
candle interval (`"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"`).

For backtest runs, **all referenced timeframes must have candle data backfilled** before the run
is submitted. The API validates this and returns a 400 error describing which timeframe and
backfill endpoint to use.

```json
{
  "kind": "all_of",
  "conditions": [
    {
      "kind": "ema_trend",
      "period": 200,
      "direction": "above",
      "timeframe": "1d"
    },
    {
      "kind": "ema_crossover",
      "fast_period": 9,
      "slow_period": 21,
      "direction": "above"
    }
  ]
}
```

This evaluates "daily close above SMA200 AND hourly EMA9 crosses EMA21" on a `"1h"` strategy —
a higher-timeframe trend filter.

---

### Quantity Sizing

The `then.quantity` field accepts a fixed decimal string, a declarative sizing method, or a
dynamic expression.

#### Fixed quantity

```json
"quantity": "0.001"
```

A constant base asset quantity per order.

#### Sizing methods

Declarative sizing — the executor resolves the base-asset quantity at execution time from the
method parameters and the current price/balance. Easier to express intent than building an Expr
tree.

##### `fixed_sum`

Buy a fixed amount of quote currency. `qty = amount / current_price`.

```json
"quantity": { "method": "fixed_sum", "amount": "500" }
```

If `amount` is `"500"` and BTC is at $50,000, the order is for `0.01 BTC`.

| Field | Type | Description |
|---|---|---|
| `amount` | decimal string | Quote currency amount to spend |

---

##### `percent_of_balance`

Buy a percentage of a named asset's free balance. `qty = (balance × percent / 100) / current_price`.

```json
"quantity": { "method": "percent_of_balance", "percent": "5", "asset": "usdt" }
```

| Field | Type | Description |
|---|---|---|
| `percent` | decimal string | Percentage of the free balance to spend (0–100) |
| `asset` | string | Asset name, case-insensitive (e.g. `"usdt"`) |

Returns `None` (order skipped) if the asset is not found in the balance map.

---

##### `fixed_units`

Buy an exact number of base asset units. Semantic alias for a fixed decimal string, but explicit
in intent.

```json
"quantity": { "method": "fixed_units", "units": "0.01" }
```

| Field | Type | Description |
|---|---|---|
| `units` | decimal string | Base asset quantity |

---

**Closing orders:** When an order's side is opposite to the current position (sell while long,
or buy while short), the engine automatically uses the full position quantity. The configured
sizing is used when flat (opening a new position) or when adding to an existing same-side position.

#### Dynamic quantity

Any `Expr` object. The expression is evaluated at candle close when the rule fires. If it returns
`None` (e.g. insufficient data, `None`-producing sub-expression), the order is skipped for that
bar. Negative results are clamped to zero.

**ATR-based position sizing** (risk 1% of balance per trade, size based on 2× ATR14):

```json
"quantity": {
  "kind": "bin_op",
  "op": "div",
  "left": {
    "kind": "bin_op",
    "op": "div",
    "left": { "kind": "balance", "asset": "usdt" },
    "right": { "kind": "literal", "value": "100" }
  },
  "right": {
    "kind": "bin_op",
    "op": "mul",
    "left": { "kind": "literal", "value": "2" },
    "right": { "kind": "func", "name": "atr", "period": 14 }
  }
}
```

**Percentage of balance** (buy 5% of available USDT at market):

```json
"quantity": {
  "kind": "bin_op",
  "op": "div",
  "left": {
    "kind": "bin_op",
    "op": "mul",
    "left": { "kind": "balance", "asset": "usdt" },
    "right": { "kind": "literal", "value": "0.05" }
  },
  "right": { "kind": "field", "field": "close" }
}
```

**Close entire position:**

```json
"quantity": { "kind": "position_quantity" }
```

---

## Complete Examples

### Example 1: EMA Crossover (Simple)

```json
{
  "type": "rule_based",
  "candle_interval": "1h",
  "rules": [
    {
      "comment": "Buy: EMA9 crosses above EMA21 while flat",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "ema_crossover",
            "fast_period": 9,
            "slow_period": 21,
            "direction": "above"
          },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Sell: EMA9 crosses below EMA21 while long",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "ema_crossover",
            "fast_period": 9,
            "slow_period": 21,
            "direction": "below"
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": "9999" }
    }
  ]
}
```

---

### Example 2: RSI Mean Reversion with ATR Sizing

```json
{
  "type": "rule_based",
  "candle_interval": "4h",
  "rules": [
    {
      "comment": "Buy: RSI oversold + trending up",
      "when": {
        "kind": "all_of",
        "conditions": [
          { "kind": "rsi", "period": 14, "threshold": "35", "comparison": "below" },
          { "kind": "ema_trend", "period": 50, "direction": "above" },
          { "kind": "position", "state": "flat" }
        ]
      },
      "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": "bin_op",
            "op": "mul",
            "left": { "kind": "literal", "value": "2" },
            "right": { "kind": "func", "name": "atr", "period": 14 }
          }
        }
      }
    },
    {
      "comment": "Sell: RSI overbought",
      "when": {
        "kind": "all_of",
        "conditions": [
          { "kind": "rsi", "period": 14, "threshold": "65", "comparison": "above" },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    },
    {
      "comment": "Time-based exit: close after 10 bars maximum",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "compare",
            "left": { "kind": "bars_since_entry" },
            "op": ">=",
            "right": { "kind": "literal", "value": "10" }
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    }
  ]
}
```

---

### Example 3: Multi-Timeframe Trend Filter

```json
{
  "type": "rule_based",
  "candle_interval": "1h",
  "rules": [
    {
      "comment": "Long only when daily trend is bullish",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "ema_trend",
            "period": 200,
            "direction": "above",
            "timeframe": "1d"
          },
          {
            "kind": "cross_over",
            "left": { "kind": "func", "name": "ema", "period": 9 },
            "right": { "kind": "func", "name": "ema", "period": 21 }
          },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Exit: close crosses below EMA21",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_under",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "ema", "period": 21 }
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    }
  ]
}
```

> **Prerequisite:** Both `"1h"` and `"1d"` candle data must be backfilled for the target
> instrument and date range before submitting this backtest.

---

### Example 4: Full Backtest Request

```json
POST /api/v1/paper-runs
{
  "mode": "backtest",
  "starts_at": "2025-01-01T00:00:00Z",
  "finishes_at": "2026-01-01T00:00:00Z",
  "risk_free_return": "0.05",
  "config": {
    "instrument": {
      "exchange": "binance_spot",
      "name_exchange": "BTCUSDT",
      "underlying": { "base": "btc", "quote": "usdt" },
      "quote": "underlying_quote",
      "kind": "spot"
    },
    "execution": {
      "mocked_exchange": "binance_spot",
      "latency_ms": 100,
      "fees_percent": "0.001",
      "initial_state": {
        "exchange": "binance_spot",
        "balances": [
          {
            "asset": "usdt",
            "balance": { "total": "10000", "free": "10000" },
            "time_exchange": "2025-01-01T00:00:00Z"
          }
        ],
        "instrument": {
          "instrument_name": "BTCUSDT",
          "orders": []
        }
      }
    },
    "strategy": {
      "type": "rule_based",
      "candle_interval": "1h",
      "rules": [
        {
          "comment": "Entry",
          "when": {
            "kind": "all_of",
            "conditions": [
              { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above" },
              { "kind": "position", "state": "flat" }
            ]
          },
          "then": { "side": "buy", "quantity": "0.001" }
        },
        {
          "comment": "Exit",
          "when": {
            "kind": "all_of",
            "conditions": [
              { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "below" },
              { "kind": "position", "state": "long" }
            ]
          },
          "then": { "side": "sell", "quantity": "9999" }
        }
      ]
    },
    "audit_conditions": true
  }
}
```

---

### Example 5: Supertrend Strategy

```json
{
  "type": "rule_based",
  "candle_interval": "4h",
  "rules": [
    {
      "comment": "Buy when close crosses above Supertrend",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_over",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "supertrend", "period": 10, "multiplier": "3.0" }
          },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Sell when close crosses below Supertrend",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_under",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "supertrend", "period": 10, "multiplier": "3.0" }
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    }
  ]
}
```

---

### Example 6: Bollinger Band Mean Reversion

```json
{
  "type": "rule_based",
  "candle_interval": "1h",
  "rules": [
    {
      "comment": "Buy the lower band touch",
      "when": {
        "kind": "all_of",
        "conditions": [
          { "kind": "bollinger", "period": 20, "num_std_dev": "2.0", "band": "below_lower" },
          { "kind": "rsi", "period": 14, "threshold": "40", "comparison": "below" },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "buy", "quantity": "0.001" }
    },
    {
      "comment": "Exit at the middle band (SMA20)",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_over",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    }
  ]
}
```

---

### Example 7: Long/Short Bollinger Mean Reversion

A bidirectional strategy: go long on a lower-band touch, short on an upper-band touch, exit
each when price crosses back through the 20-period SMA (middle band).

```json
{
  "type": "rule_based",
  "candle_interval": "1h",
  "rules": [
    {
      "comment": "Open long: price below lower Bollinger Band while flat",
      "when": {
        "kind": "all_of",
        "conditions": [
          { "kind": "bollinger", "period": 20, "num_std_dev": "2.0", "band": "below_lower" },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "buy", "quantity": { "method": "percent_of_balance", "percent": "10", "asset": "usdt" } }
    },
    {
      "comment": "Close long: price crosses above SMA20 (middle band)",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_over",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
          },
          { "kind": "position", "state": "long" }
        ]
      },
      "then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
    },
    {
      "comment": "Open short: price above upper Bollinger Band while flat",
      "when": {
        "kind": "all_of",
        "conditions": [
          { "kind": "bollinger", "period": 20, "num_std_dev": "2.0", "band": "above_upper" },
          { "kind": "position", "state": "flat" }
        ]
      },
      "then": { "side": "sell", "quantity": { "method": "percent_of_balance", "percent": "10", "asset": "usdt" } }
    },
    {
      "comment": "Close short: price crosses below SMA20 (middle band)",
      "when": {
        "kind": "all_of",
        "conditions": [
          {
            "kind": "cross_under",
            "left": { "kind": "field", "field": "close" },
            "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
          },
          { "kind": "position", "state": "short" }
        ]
      },
      "then": { "side": "buy", "quantity": { "kind": "position_quantity" } }
    }
  ]
}
```

Short positions are opened with `"side": "sell"` while flat. The engine computes the quantity
from the sizing method (`percent_of_balance`) and uses it as the short size. The close-short
rule uses `"side": "buy"` — since the order side is opposite to the open short position, the
engine automatically closes the full position.
