QTSurfer beta

Parameter sweeps

Run parameter grids, rank trials, and validate results with walk-forward folds.

Run a strategy across a parameter grid instead of one fixed set of values, poll a ranked leaderboard, optionally validate the winner out of sample with walk-forward folds, and inspect which parameters actually moved the objective.

All five endpoints share {exchangeId}/{type}/executeSweep/{requestId} (requestId is the jobId from POST /backtest/{exchangeId}/{type}/prepare — a sweep reuses the same prepared dataset, never a fresh one):

MethodPathPurpose
POST.../executeSweep/{requestId}Submit a sweep
GET.../executeSweep/{requestId}/{sweepId}Poll progress and the leaderboard
DELETE.../executeSweep/{requestId}/{sweepId}Cancel a running sweep
GET.../executeSweep/{requestId}/{sweepId}/sensitivityMarginals and heatmaps
GET.../executeSweep/{requestId}/{sweepId}/runs/{runIx}/equityCurveA selected trial’s curve — see the shared equity-curve guide

Submitting a sweep

POST .../executeSweep/{requestId}

Request body — ExecuteSweepRequest

FieldTypeDefaultNotes
strategyIdstringrequired
sweepSweepSpecRequestrequired — the grid itself
baseConfigSweepBaseConfigbacktest config shared by every trial
walkForwardWalkForwardRequestopt in to out-of-sample validation instead of a flat sweep
equityCurveEquityCurveRequest{mode: "auto"}select retained trial curves and their default response transform
storeSignalsbooleanfalsestore signals for every trial; keep false for normal sweeps
shardsinteger ≥ 00requested horizontal shard count; 0 selects automatically
minTradeFloorinteger ≥ 030trials below this trade count are flagged (belowTradeFloor) but stay in the results

sweepSweepSpecRequest

FieldTypeDefaultNotes
paramsmap of string → SweepAxisrequired, ≥ 1 entry — one axis per swept parameter
samplergrid | random | lhsgrid
objectivesharpe | sortino | pnl | maxddsharpe
samplesinteger ≥ 1sample count for random/lhs; ignored by grid
seedint64reproducibility seed. Omitted → the server generates one (Java’s L64X128MixRandom) and returns the effective value in ExecuteSweepAccepted.seed
params — map of SweepAxis

Each strategy property being swept gets one axis, expressed as either a numeric range or an explicit list:

"rsiPeriod":       {"from": 7, "to": 28, "step": 1},
"useTrendFilter":  {"values": [true, false]}
  • rangefrom, to, step (all required, step > 0)
  • enumeratedvalues (≥ 1 item, each number or boolean)

baseConfigSweepBaseConfig

Applied identically to every trial in the sweep.

FieldTypeDefaultNotes
initialFundingnumber > 010000
feeRatenumber ≥ 00.001
buyFeeRate / sellFeeRatenumber ≥ 0override feeRate per side
feeLegRECEIVED | QUOTE | BASERECEIVED
percentAmountToLocknumber, 0 < n ≤ 100

equityCurveEquityCurveRequest

Selection, retention, transform defaults, pointer responses, and read-time overrides are documented in the shared equity-curve guide.

Example

curl -X POST "https://api.qtsurfer.net/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID" 
  -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: application/json" 
  -d '{
    "strategyId": "2ul144qe9tlwzu5anhwvc6",
    "sweep": {
      "sampler": "grid",
      "objective": "sharpe",
      "params": {
        "rsiPeriod": {"from": 7, "to": 28, "step": 1},
        "useTrendFilter": {"values": [true, false]}
      }
    }
  }'

ExecuteSweepAccepted (202):

{
  "sweepId": "swp_95e47a7f0966ce11",
  "requestId": "5ikYAMIO...",
  "totalRuns": 44,
  "shards": 1,
  "seed": 487221,
  "queued": true
}

queued: false means an identical sweep already existed and this call did not enqueue a duplicate — prepare and execute requests are idempotent, keyed on their body.

Errors: 400 invalid spec or the expanded grid exceeds the server limit · 404 requestId not found or expired · 429 sweep queue or per-user concurrency limit reached.

Polling progress and the leaderboard

GET .../executeSweep/{requestId}/{sweepId}

Query paramTypeDefaultNotes
objectivesharpe | sortino | pnl | maxddsweep’s own objectivere-score the leaderboard by a different objective than the one it was submitted with
orderranked | naturalrankednatural returns every row, untruncated, in stable runIx order — use it to materialise durable trial rows
rankingplateau | rawplateauhow the ranked view is ordered; ignored when order=natural

ranking=plateau sorts by plateau score — the objective of the worst run in a parameter point’s immediate neighbourhood — rather than the raw objective, so a spike that doesn’t survive the parameters moving slightly no longer wins by default. Pass ranking=raw for the old, unadjusted ordering.

Response — ExecuteSweepResult

FieldNotes
statusRUNNING | COMPLETED | PARTIAL | CANCELLED
rankingwhich ordering was actually applied — not always the one requested (a sweep with no stored grid can’t be plateau-ranked and falls back to raw)
pbo, pboSplitsprobability of backtest overfitting, combinatorially symmetric cross-validation over the whole sweep. > ~0.5 → the sweep is selecting noise. Present only once the last shard finishes, and only for a non-walk-forward sweep
failReasonwhy the sweep produced less than it should — the cause reported by the first shard to fail, not an exhaustive list. Turns an empty leaderboard with done: 0 into an answer (e.g. "Failed to load/configure strategy") instead of a mystery
progressSweepProgress
leaderboardSizetotal rows currently available
truncatedtrue only when the ranked view exceeds its display limit
leaderboardarray of SweepRunRow
walkForwardpresent only for a walk-forward sweep — see below
statethe same JobState shape a single execute’s result carries — not a lookalike, the same type. See below for how it relates to status above

state.status is a different vocabulary from the sweep’s own status field above (New / Started / Completed / Aborted / Failed, mapped from it — PARTIAL and CANCELLED both become Aborted, since a sweep’s PARTIAL is already terminal, unlike the non-terminal Partial a single job can be in). state.completed is real ticks processed on a plain sweep; on a walk-forward sweep it is currently always 0. state.size is always 0 on every execute and sweep path today — nothing populates it yet.

progressSweepProgress

Partitions the shards (or, for a walk-forward sweep, the folds): every unit is finished, failed, retrying, or not yet started.

FieldNotes
done, totalrows/units completed vs. total
abortedindividual runs that executed and aborted (row-level)
shardCount, pendingShardstotal and still-pending shards
failedShardswhole shards/folds that failed and will not be retried — distinct from aborted, which counts bad runs, not missing units
retryingunits whose last attempt hit a transient error and are queued to retry — not a failure yet
notStartedunits that haven’t reported anything; persistent alongside a rising stalledSeconds is worth investigating
stalledSecondsseconds since anything last advanced; omitted on a finished sweep
etaSecondsrough seconds remaining; runs conservative (2–5× long) when part of the sweep spent time waiting to retry. Omitted, never 0, when it can’t be computed

Leaderboard rows — SweepRunRow

FieldNotes
runIxdeterministic zero-based expansion index, stable across shards and ranking
rankpresent only in the ranked view
plateauScore, neighbourCountplateau score is the objective of the worst neighbour; neighbourCount: 0 means the point had no neighbours to compare against — the score is unevidenced, not confirmed. Always read together
deflatedSharpeprobability this run’s Sharpe reflects real edge rather than the best draw among however many vectors were tried. > ~0.95 survives the multiple-testing correction; ≤ 0.5 is indistinguishable from the best of a pile of coin flips
params, sharpe, sortino, pnl, pnlPct, cagr, maxDdPct, trades, winRatethe trial’s own results
belowTradeFloor, aborted, runtimeMs
equityCurvepresent only when this trial’s curve was selected — see Equity curves

Example

curl "https://api.qtsurfer.net/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID/$SWEEP_ID" 
  -H "Authorization: Bearer $TOKEN"
{
  "status": "RUNNING",
  "ranking": "plateau",
  "progress": {
    "done": 31, "total": 44, "aborted": 0,
    "shardCount": 1, "pendingShards": 0,
    "failedShards": 0, "retrying": 0, "notStarted": 1,
    "etaSeconds": 12
  },
  "leaderboardSize": 31,
  "truncated": false,
  "leaderboard": [
    {
      "runIx": 12, "rank": 1,
      "params": {"rsiPeriod": 16, "useTrendFilter": true},
      "sharpe": 1.84, "plateauScore": 1.61, "neighbourCount": 6,
      "sortino": 2.10, "pnl": 812.40, "pnlPct": 8.12, "cagr": 0.31,
      "maxDdPct": 6.4, "trades": 118, "winRate": 57.6,
      "belowTradeFloor": false, "aborted": false, "runtimeMs": 842
    }
  ],
  "state": {
    "contextId": "swp_95e47a7f0966ce11",
    "status": "Started", "size": 0, "completed": 31,
    "startTime": "2026-03-18T13:21:28.958Z", "endTime": null
  }
}

Errors: 404 sweep not found or expired.

Walk-forward validation

Add walkForward to executeSweep’s body to test whether the winning parameters keep working, not just which ones won. The data splits into sequential folds; each optimizes the full grid on its own window and is scored only on the window immediately after — data its winner was not chosen on. Omit the block and nothing about the sweep changes, including the response shape.

The cost is why it’s opt-in: folds × totalRuns backtests, so 4 folds over a 500-point grid is 2004 runs where the plain sweep is 500. A request exceeding the server’s sweep budget is a 400.

WalkForwardRequest

FieldTypeDefaultNotes
foldsinteger ≥ 2required. 2 is a structural minimum, not a tuning choice: paramDrift compares consecutive fold winners, and a single fold has no pair to compare
inSamplePctinteger, 10–9066share of the session each fold optimizes on; the remainder is where its winner is scored

Example

curl -X POST "https://api.qtsurfer.net/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID" 
  -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: application/json" 
  -d '{
    "strategyId": "2ul144qe9tlwzu5anhwvc6",
    "sweep": {"sampler":"grid","objective":"sharpe",
              "params":{"rsiPeriod":{"from":7,"to":28,"step":1}}},
    "walkForward": {"folds": 4}
  }'
# → 202 {"sweepId":"swp_...","walkForward":{"folds":4,"inSamplePct":66,"totalRuns":92}}

ExecuteSweepAccepted.walkForward (WalkForwardAccepted: folds, inSamplePct, totalRuns) confirms the fold plan the moment the sweep is accepted — before any fold has finished — so it’s safe to branch on while polling whether a sweep is walk-forward or a flat grid.

Result shape — WalkForwardResult

On getSweepResult, ExecuteSweepResult.walkForward is present only for a walk-forward sweep, and its presence, not its contents, is what identifies one:

FieldNotes
folds, inSamplePctrequested folds and resolved in-sample share
completedFoldsfolds finished so far; 0 while the first is still running
paramDriftmean normalized lattice distance between consecutive fold winners. Low = the parameter means something; winners jumping across the grid every fold = the sweep is fitting noise. Absent is not zero — omitted when it can’t be computed (fewer than two folds finished), because 0 is itself a meaningful reading here
resultsone WalkForwardFold per completed fold, oldest first

WalkForwardFold

FieldNotes
foldIxposition in the sequence, oldest first
inSampleFrom, inSampleTo, outOfSampleTowindow indices into the prepared session
paramsthe vector that won this fold’s optimization window
inSampleSharpehow that winner scored on the window it was chosen on — only there to compare against outOfSample, since any grid produces a flattering in-sample winner
outOfSamplea full SweepRunRow — the honest number
vectorsRunvectors evaluated in-sample before picking the winner

When walkForward is present, the top-level leaderboard becomes one row per completed fold — that fold’s out-of-sample winner, runIx carrying the fold index rather than a grid position — instead of one row per parameter point. ranking is always raw, and no plateau score, DSR, or pbo is reported: the out-of-sample scores are already the honest number.

Parameter sensitivity

GET .../executeSweep/{requestId}/{sweepId}/sensitivity

Query paramTypeNotes
objectivesharpe | sortino | pnl | maxdddefaults to the sweep’s own objective

Aggregated directly from the sweep’s stored rows — no re-run, no engine call — so it works on a sweep still in flight (the aggregates then describe whatever finished so far). Aborted runs are excluded throughout: a run that threw measured nothing, and counting it as a bad outcome would invent evidence against a value that was never really tested. 404 if the sweep is unknown or expired.

A leaderboard says which point won; it can’t say whether an axis mattered at all. Sensitivity answers that with two views:

  • Marginal — one axis, every other axis collapsed away: for each value, aggregate every run that used it, whatever the rest of the parameters were. A flat marginal means the axis was irrelevant over the range swept. best, mean, and worst disagreeing is itself a signal — a high best with a poor mean only works in specific company, an interaction invisible behind a single number.
  • Heatmap — the same, over a pair of axes, so that interaction becomes visible directly. Quadratic in the axis count (N axes → N(N-1)/2 surfaces), which is why this is a separate endpoint rather than fields on the poll response — heatmapsTruncated: true means at least one surface was left out to stay inside the response budget.

SweepSensitivity

FieldNotes
rowsAnalysedrows available when computed; grows while the sweep is still running
marginalsarray of {param, points: [{value, count, best, mean, worst}]}
heatmapsarray of {paramA, paramB, cells: [{valueA, valueB, count, best, mean}]}
heatmapsTruncatedtrue when a two-parameter surface was dropped to stay inside the budget

Example

curl "https://api.qtsurfer.net/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID/$SWEEP_ID/sensitivity" 
  -H "Authorization: Bearer $TOKEN"
{
  "sweepId": "swp_95e47a7f0966ce11",
  "status": "COMPLETED",
  "objective": "sharpe",
  "rowsAnalysed": 44,
  "marginals": [
    {
      "param": "rsiPeriod",
      "points": [
        {"value": 7,  "count": 4, "best": 0.94, "mean": 0.62, "worst": 0.31},
        {"value": 16, "count": 4, "best": 1.84, "mean": 1.53, "worst": 1.22},
        {"value": 28, "count": 4, "best": 0.77, "mean": 0.55, "worst": 0.28}
      ]
    }
  ],
  "heatmaps": [
    {
      "paramA": "rsiPeriod",
      "paramB": "useTrendFilter",
      "cells": [
        {"valueA": 16, "valueB": true, "count": 1, "best": 1.84, "mean": 1.84}
      ]
    }
  ],
  "heatmapsTruncated": false
}

Rendered, heatmaps[0] above looks like this (illustrative — the response only carries the numbers; plotting them is a client concern):

Mean Sharpe by rsiPeriod × useTrendFilter, an 8×2 heatmap peaking at 1.84 for rsiPeriod 16 with useTrendFilter true

Cancelling a sweep

DELETE .../executeSweep/{requestId}/{sweepId}

Requests cancellation between parameter vectors — already-completed rows remain readable.

{"status": "cancelling", "sweepId": "swp_95e47a7f0966ce11"}

Errors: 404 sweep not found.

Visualizing a winner: equity curve

The shared equity-curve guide covers sweep retention modes, leaderboard pointers, read-time query parameters, response transforms, metadata, plotting, and the manual reproduction path when a trial curve was not retained.