Backtests
Prepare historical data, execute a strategy, poll its result, and inspect its equity curve.
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.
| 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: 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 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), 1m, 5m, 15m, 30m, 1h, 4h or 1d — whatever the strategy itself might suggest. The same strategy
can therefore be run at several cadences by preparing the range once per cadence. Any other label
(5s, 3m, 8h, …) is 400 at prepare, and the message lists the accepted ones.
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: one of 1s, 1m, 5m, 15m, 30m, 1h, 4h, 1d — 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.
Example
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), and its gap count/size at that cadence. gaps/largestGapSteps are 0 for rt |
Example
curl https://api.qtsurfer.net/v1/backtest/binance/ticker/prepare/$PREPARE_JOB_ID
-H "Authorization: Bearer $TOKEN" {
"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). 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 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 | optional — reshape the curve baked into results.equityCurve |
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
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..."} With a baseConfig override (capital and position size, instead of the platform defaults):
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..."} Re-running a sweep leaderboard row for its curve, with params:
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..."} 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) 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 — 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
curl https://api.qtsurfer.net/v1/backtest/binance/ticker/execute/$EXECUTE_JOB_ID
-H "Authorization: Bearer $TOKEN" {
"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 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.