QTSurfer beta

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.

MethodPathPurpose
POST/backtest/{exchangeId}/{type}/preparePrepare a dataset
GET/backtest/{exchangeId}/{type}/prepare/{jobId}Poll prepare status
POST/backtest/{exchangeId}/{type}/executeRun 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 DataSourceTypeticker today.

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:

FieldTypeNotes
from, tostringrequired. ISO-8601, ISO date, or basic ISO date (2024-12-14T23:59:59Z, 2024-12-14, 20241214)
instrumentstringrequired unless exchangeId is the reserved value user
datasetIdstringonly for exchangeId: user — a dataset from POST /datasets, in place of instrument
datasetVersionIdstringonly for exchangeId: user, optional — pins a past version instead of the dataset’s current one
cadenceenum1s, 5s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 1w, 1q — default 1s. Coarser-than-source values must be exact multiples of the source cadence

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, 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) 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:

FieldNotes
dataFrom, dataToavailable data range. Present either way
coverageRatio01. Managed exchange: hoursWithData / totalHours. Dataset (exchangeId: user): rows / expectedStepsAtCadence over the dataset version’s own range, echoing what ingest computed once
totalHours, hoursWithDatamanaged exchange only — absent for a dataset-backed prepare
hoursWithoutDatamanaged 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, largestGapStepsdataset-backed only — the dataset version’s own discovered cadence, and its gap count/size at that cadence

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) → same jobId (idempotent) — a request that omits equityCurve dedupes exactly as it did before that field existed.

Request body

FieldTypeNotes
prepareJobIdstringrequired — must be a Completed prepare job
strategyIdstringrequired
storeSignalsbooleandefault false. When true, the worker uploads emitted signals to object storage and the result gains signalsUrl/signalsId
equityCurveEquityCurveOptionsoptional — reshape the curve baked into results.equityCurve

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

Errors: 400 invalid request · 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):

FieldNotes
hostName, iops, strategyId, instrumentalways 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, maxDrawdownPercentyield metrics — present once the strategy emitted at least one trade
equityCurveEquityCurveResult — present under the same condition as the yield metrics
noticesdiagnostics 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
noticesTruncatedhow many notices were dropped past the cap of 50; absent when none were
signalCount, signalsId, signalsUrl, signalsUpload, signalsUploadedAt, signalsUploadReasononly 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": 58.33,
    "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.