# Backtests

Prepare historical data, run a compiled strategy against it once, poll the result, and plot the
equity curve. For running the *same* strategy across a parameter grid instead, see
[`docs/backtest_sweep.md`](backtest_sweep.md).

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/backtest/{exchangeId}/{type}/prepare` | Prepare a dataset |
| `GET` | `/backtest/{exchangeId}/{type}/prepare/{jobId}` | Poll prepare status |
| `POST` | `/backtest/{exchangeId}/{type}/execute` | Run a strategy against a prepared dataset |
| `GET` | `/backtest/{exchangeId}/{type}/execute/{jobId}` | Poll the execution result |
| `DELETE` | `/backtest/{exchangeId}/{type}/execute/{jobId}` | Cancel a running execution |

`{type}` is the [`DataSourceType`](../openapi.yaml): `ticker`, `kline` or `funding`.

## Where a job's status lives

The three job-polling endpoints answer "is it done, did it fail" at different places, because each
returns its own result type. The status itself is the same [`JobState`](#response--preparejobstate)
vocabulary (`New`, `Started`, `Completed`, `Aborted`, `Failed`) in all three:

| Poll | Read the status at | Notes |
|---|---|---|
| `GET .../prepare/{jobId}` | `status` | flat: the response is a `PrepareJobState`, a `JobState` with the coverage summary beside it |
| `GET .../execute/{jobId}` | `state.status` | nested: the response is a `BacktestJobResult`, `{state, results}`; a `202` with an empty body means it is not readable yet |
| `GET .../executeSweep/{requestId}/{sweepId}` | `state.status` | the sweep also carries its own top-level `status`, but in a different vocabulary (`RUNNING`, `COMPLETED`, `PARTIAL`, `CANCELLED`); read `state` for the terms of the other two |

A poller that serves all three can read `(resp.get("state") or resp)["status"]` (in Python): `state`
when the response has one, the top level when it does not.

## Data sources

| `{type}` | Prepare | Execute | Sweep |
|---|---|---|---|
| `ticker` | yes | yes | yes |
| `kline` | yes | yes | yes |
| `funding` | yes | not yet | not yet |

A `funding` request to `execute` or `executeSweep` is rejected with `400` before anything is
queued, and the message names what can be run: `funding data can be prepared but not executed yet.
Sources that can be executed: ticker, kline`.

### Kline: you choose the bar width

A kline run reads bars of exactly the `cadence` you prepared at — `1s` (the default) up to `1q`, the
same labels as for tickers (`5s`, `3m`, `8h`, `1w` among them) — whatever the strategy itself might
suggest. The same strategy can therefore be run at several cadences by preparing the range once per
cadence. A label that is not a whole number of seconds is `400` at prepare.

```bash
curl -X POST https://api.qtsurfer.net/v1/backtest/binance/kline/prepare \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"instrument":"BTC/USDT","from":"2026-03-14T10:00:00Z","to":"2026-03-14T16:00:00Z","cadence":"1m"}'
# → 202 {"jobId": "5ikYAMIO..."}

curl -X POST https://api.qtsurfer.net/v1/backtest/binance/kline/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prepareJobId":"5ikYAMIO...","strategyId":"2ul144qe9tlwzu5anhwvc6"}'
```

## Preparing data

`POST .../prepare`

Enqueues a prepare task over a date range and returns a `jobId` immediately; poll the `GET`
below for completion. Same params → same `jobId` (idempotent) — repeated calls reuse the
existing job instead of enqueueing duplicate work.

### Request body — `PrepareRequest`

Two shapes, chosen by the `exchangeId` path segment:

| Field | Type | Notes |
|---|---|---|
| `from`, `to` | string | required. ISO-8601, ISO date, or basic ISO date (`2024-12-14T23:59:59Z`, `2024-12-14`, `20241214`) |
| `instrument` | string | required **unless** `exchangeId` is the reserved value `user` |
| `datasetId` | string | **only** for `exchangeId: user` — a dataset from `POST /datasets`, in place of `instrument` |
| `datasetVersionId` | string | **only** for `exchangeId: user`, optional — pins a past version instead of the dataset's current one |
| `cadence` | string | optional. Managed exchange, `ticker` or `funding`: one of `1s`, `5s`, `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `1w`, `1q` — default `1s`. Managed exchange, `kline`: the same labels — default `1s`; any other label is `400`. `exchangeId: user`: default is the dataset version's own discovered cadence, served as-is; any cadence equal to or coarser than it and an exact multiple of it is accepted, even outside that list (e.g. `15s`), and an `rt` dataset resamples to any fixed cadence. Finer than the source, or not an exact multiple of it, is `400` |

`exchangeId: user` is reserved for your own uploaded data — see
[`docs/datasets.md`](datasets.md).

### Example

```bash
curl -X POST https://api.qtsurfer.net/v1/backtest/binance/ticker/prepare \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"instrument":"BTC/USDT","from":"2026-03-14","to":"2026-03-15"}'
# → 202 {"jobId": "5ikYAMIO..."}
```

Errors: `400` invalid request, `from` older than the lookback window, `to` in the future, a `kline`
`cadence` that isn't a kline cadence, or (for
`exchangeId: user`) the dataset's upload hasn't finished ingesting / `cadence` finer than the
dataset's discovered cadence / range exceeds your tier's limit · `404` exchange/type not found, or
(for `exchangeId: user`) `datasetId`/`datasetVersionId` doesn't exist or isn't yours · `429` global
queue at capacity or too many active backtests — doesn't apply to `exchangeId: user`, which reads
an already-ingested file rather than claiming worker capacity.

## Polling prepare status

`GET .../prepare/{jobId}`

A single-instrument prepare is always terminal (`status: Completed`) — decide from
`coverageRatio` (e.g. execute once it clears a chosen threshold; against an `rt` dataset, which
has no ratio, from `dataFrom`/`dataTo`) rather than polling for missing
hours that may never arrive. A missing hour usually means low activity, not missing data.

### Response — `PrepareJobState`

The `JobState` shape (`contextId`, `status`, `statusDetail`, `size`, `completed`, `startTime`,
`endTime`) plus a coverage summary. **Two coverage shapes**, by exchange vs. dataset:

| Field | Notes |
|---|---|
| `dataFrom`, `dataTo` | available data range. Present either way |
| `coverageRatio` | `0`–`1`. Managed exchange: `hoursWithData / totalHours`. Dataset (`exchangeId: user`): `rows / expectedStepsAtCadence` over the dataset version's own range, echoing what ingest computed once. **Absent for an `rt` dataset** — no fixed step, so no expected row count |
| `totalHours`, `hoursWithData` | managed exchange only — absent for a dataset-backed prepare |
| `hoursWithoutData` | managed exchange only — one entry per empty hour: `{hour, expected, rationale}`. `rationale` is `pending_conversion` (re-poll may fill it), `low_activity`, or `unknown` |
| `cadence`, `gaps`, `largestGapSteps` | dataset-backed only — the dataset version's own discovered cadence (a fixed grid or `rt`, see [`docs/datasets.md`](datasets.md)), and its gap count/size at that cadence. `gaps`/`largestGapSteps` are `0` for `rt` |

### Example

```bash
curl https://api.qtsurfer.net/v1/backtest/binance/ticker/prepare/$PREPARE_JOB_ID \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "contextId": "ctx_0bjmoxd4vahkgc0hnvdldh",
  "status": "Completed",
  "size": 0,
  "completed": 24,
  "startTime": "2026-04-14T15:00:00Z",
  "endTime": "2026-04-14T15:00:01Z",
  "dataFrom": "2026-04-14T13:00:00Z",
  "dataTo": "2026-04-14T15:30:05Z",
  "coverageRatio": 0.994,
  "totalHours": 168,
  "hoursWithData": 167,
  "hoursWithoutData": [
    {"hour": "2026-04-14T02:00:00Z", "expected": 0, "rationale": "low_activity"}
  ]
}
```

## Executing a backtest

`POST .../execute`

Runs the strategy identified by `strategyId` over the data from `prepareJobId`; instrument and
date range are recovered from the prepare job, not sent again. Works unchanged for a
dataset-backed prepare. Same `(prepareJobId, strategyId, storeSignals, equityCurve, baseConfig,
params)` → same `jobId` (idempotent) — a request that omits `equityCurve`, `baseConfig` or `params`
dedupes exactly as it did before those fields existed. Two different `params` vectors over one
prepare are two different jobs, and `9` and `9.0` are the same one.

Optionally takes `params`: strategy properties for this one run, applied without recompiling.
Use this to re-run a sweep row as an ordinary backtest result with a chosen parameter vector — for
example, when you need the plain-backtest result alongside a sweep curve (see
[`docs/backtest_sweep.md`](backtest_sweep.md)). Compile the strategy once, call this endpoint N
times with different `params`, and each response includes the curve under the same conditions as
any other plain backtest. This is an independent execution rather than a replay of the sweep trial,
but the two paths are pinned to agree on every leaderboard metric for the same vector. Treat a
difference as a bug worth reporting, not as expected behaviour.

Optionally takes `baseConfig`: capital/fee/position-size overrides, the same
[`SweepBaseConfig`](backtest_sweep.md#baseconfig--sweepbaseconfig) shape `executeSweep` accepts —
send the same object to either endpoint. This endpoint has one effective fee rate rather than a
sweep's independent buy/sell legs: a `baseConfig` that resolves to different buy/sell rates, or
sets a non-default `feeLeg`, is rejected with `400` instead of silently collapsed to one side. Omit
it to run at the platform defaults (`initialFunding: 100`, `feeRate: 0.001`).

### Request body

| Field | Type | Notes |
|---|---|---|
| `prepareJobId` | string | required — must be a `Completed` prepare job |
| `strategyId` | string | required |
| `storeSignals` | boolean | default `false`. When `true`, the worker uploads emitted signals to object storage and the result gains `signalsUrl`/`signalsId` |
| `equityCurve` | [`EquityCurveOptions`](equity_curves.md#plain-backtests-choose-the-transform-on-submit) | optional — reshape the curve baked into `results.equityCurve` |
| `baseConfig` | [`SweepBaseConfig`](backtest_sweep.md#baseconfig--sweepbaseconfig) | optional — capital/fee/position-size overrides, same shape `executeSweep` accepts. One effective fee rate: a value implying asymmetric buy/sell fees, or a non-default `feeLeg`, is `400` |
| `params` | object | optional, at most 64 entries. Flat map of strategy property name → scalar (number, string or boolean). Keys are the `name` declared on `@StrategyProperty` (not necessarily the Java field it annotates) — `GET`/`POST /strategy` returns `declaredProperties` for the valid names. An unknown key fails the job rather than silently running at defaults. Omit a key to leave it at its default; `null` is not a value. Arrays are rejected — a list is a sweep axis, this endpoint runs exactly one vector. `strategyId`, `storeSignals`, `equityCurve`, `backtestEnabled`, `backtestFakeExecution` are reserved (they configure the job, not the strategy) |

### Example

```bash
curl -X POST https://api.qtsurfer.net/v1/backtest/binance/ticker/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prepareJobId":"5ikYAMIO...","strategyId":"2ul144qe9tlwzu5anhwvc6"}'
# → 202 {"jobId": "4GmNN0i9..."}
```

Re-running a sweep leaderboard row for its curve, with `params`:

```bash
curl -X POST https://api.qtsurfer.net/v1/backtest/binance/ticker/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prepareJobId":"5ikYAMIO...","strategyId":"2ul144qe9tlwzu5anhwvc6","params":{"ema.fast.period":9,"ema.slow.period":21}}'
# → 202 {"jobId": "9k2LpQi7..."}
```

With a `baseConfig` override (capital and position size, instead of the platform defaults):

```bash
curl -X POST https://api.qtsurfer.net/v1/backtest/binance/ticker/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prepareJobId":"5ikYAMIO...","strategyId":"2ul144qe9tlwzu5anhwvc6","baseConfig":{"initialFunding":1000,"percentAmountToLock":10}}'
# → 202 {"jobId": "7pQx91Ab..."}
```

Errors: `400` invalid request, or a `type` that can't be executed yet (`funding`) · `404` prepare job
not found or expired · `429` rate limited.

## Polling the result

`GET .../execute/{jobId}`

A `202` (empty body, `{}`) means the result isn't readable yet — keep polling under your existing
timeout, and never treat it as terminal. It's returned both while the job is still running *and*
when a terminal job's stored result couldn't be read back, so a poll loop should key off `200`
plus `state.status`, not off "not 202 anymore".

### Response — `BacktestJobResult`

`state` ([`JobState`](#response--preparejobstate)) plus `results` (`ResultMap`):

| Field | Notes |
|---|---|
| `hostName`, `iops`, `strategyId`, `instrument` | always present. `strategyId` here is the **execution context id** (`strategy:<user>:<strategyId>`) — take the segment after the last `:` to get back the id you compiled with |
| `pnlTotal`, `pnlTotalPercent`, `totalTrades`, `winRate`, `sharpeRatio`, `sortinoRatio`, `cagr`, `maxDrawdown`, `maxDrawdownPercent` | yield metrics — present once the strategy emitted at least one trade |
| `equityCurve` | [`EquityCurveResult`](equity_curves.md) — present under the same condition as the yield metrics |
| `params` | the strategy properties this run was given, echoed back as sent. Absent when the request carried none — its presence is what distinguishes a parameterised run from one at the declared defaults |
| `notices` | diagnostics the engine raised, each `{level, code, message, provenance: execute}`. **Absent means nothing was raised** — the one surface where silence is a real answer. Raised on failed/aborted runs too, and those are the most worth reading: a run with no trades often says why here |
| `noticesTruncated` | how many notices were dropped past the cap of 50; absent when none were |
| `signalCount`, `signalsId`, `signalsUrl`, `signalsUpload`, `signalsUploadedAt`, `signalsUploadReason` | only when the request set `storeSignals: true`. `signalsUpload` is `Done` \| `Failed` \| `Skipped`; `signalsUrl` is a Parquet file with **every** emitted signal (indicator values, markers) — the full detail behind the summary `equityCurve` |

### Example

```bash
curl https://api.qtsurfer.net/v1/backtest/binance/ticker/execute/$EXECUTE_JOB_ID \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "state": {"status": "Completed", "completed": 85058},
  "results": {
    "pnlTotal": 42.75, "pnlTotalPercent": 2.25, "totalTrades": 156, "winRate": 0.5833,
    "sharpeRatio": 1.245, "sortinoRatio": 1.872, "cagr": 0.1534,
    "maxDrawdown": 12.50, "maxDrawdownPercent": 8.75, "iops": 123956.53,
    "equityCurve": {
      "points": [
        {"timestamp": 1700000000000, "equity": 100.0},
        {"timestamp": 1700000060000, "equity": 110.5},
        {"timestamp": 1700000120000, "equity": 90.25}
      ],
      "meta": {
        "inputPointCount": 3, "outputPointCount": 3,
        "resampled": false, "differential": false, "outMode": "ARRAY"
      }
    }
  }
}
```

Errors: `400` invalid request · `404` execution job not found.

## Cancelling

`DELETE .../execute/{jobId}`

Requests cancellation; status transitions to `Aborted` once processed — asynchronous, so poll
`GET` to confirm. `200` `{"status": "cancelling", "jobId": "..."}` · `404` not found.

## Visualizing the equity curve

The shared [equity-curve guide](equity_curves.md) covers plotting, percentage normalization,
`ARRAY` and `SHORT` shapes, resampling, differential encoding, metadata, size guards, and the
different submit/read semantics for plain backtests and retained sweep trials.
