QTSurfer

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.

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..."}

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..."}

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..."}

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.