QTSurfer

Parameter Sweeps

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). {type} is ticker or kline; a kline sweep, walk-forward included, runs over bars of the cadence the request was prepared at (see docs/backtest_execute.md). funding can be prepared but not swept yet:

Method Path Purpose
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}/sensitivity Marginals and heatmaps
GET .../executeSweep/{requestId}/{sweepId}/runs/{runIx}/equityCurve A selected trial’s curve — see the shared equity-curve guide

Submitting a sweep

POST .../executeSweep/{requestId}

Request body — ExecuteSweepRequest

Field Type Default Notes
strategyId string — required
sweep SweepSpecRequest — required — the grid itself
baseConfig SweepBaseConfig — backtest config shared by every trial
walkForward WalkForwardRequest — opt in to out-of-sample validation instead of a flat sweep
equityCurve EquityCurveRequest {mode: "auto"} select retained trial curves and their default response transform
storeSignals boolean false store signals for every trial; keep false for normal sweeps
shards integer ≥ 0 0 requested horizontal shard count; 0 selects automatically
minTradeFloor integer ≥ 0 30 trials below this trade count are flagged (belowTradeFloor) but stay in the results

sweep — SweepSpecRequest

Field Type Default Notes
params map of string → SweepAxis — required, ≥ 1 entry — one axis per swept parameter
sampler grid | random | lhs grid grid runs every combination and is held to your plan’s grid limit, see How large a grid may be
objective sharpe | sortino | pnl | maxdd sharpe  
samples integer ≥ 1 — sample count for random/lhs; ignored by grid
seed int64 — reproducibility 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]}
How large a grid may be

A sweep with the grid sampler runs every combination of its axes, and the number of combinations is the product of the axes’ sizes. Your plan sets the largest full grid it may run: maxSweepCartesian in GET /account, 100 on the free plan and larger on the paid ones. A grid over that number is refused with 400, and the message asks for the random or lhs sampler. Those two run only samples combinations, whatever the size of the space they draw from, and are not held to the plan’s grid limit. A platform-wide ceiling on the number of combinations of one sweep applies to every plan and every sampler.

"sweep": {"sampler": "random", "samples": 200, "params": {"rsiPeriod": {"from": 2, "to": 40, "step": 1},
                                                       "atrMultiplier": {"from": 0.5, "to": 5, "step": 0.1}}}

baseConfig — SweepBaseConfig

Applied identically to every trial in the sweep.

Field Type Default Notes
initialFunding number > 0 100  
feeRate number ≥ 0 0.001  
buyFeeRate / sellFeeRate number ≥ 0 — override feeRate per side
feeLeg RECEIVED | QUOTE | BASE RECEIVED  
percentAmountToLock number, 0 < n ≤ 100 —  

equityCurve — EquityCurveRequest

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, a type that can’t be swept yet (funding), 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 param Type Default Notes
objective sharpe | sortino | pnl | maxdd sweep’s own objective re-score the leaderboard by a different objective than the one it was submitted with
order ranked | natural ranked natural returns every row, untruncated, in stable runIx order — use it to materialise durable trial rows
ranking plateau | raw plateau how 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

Field Notes
status RUNNING | COMPLETED | PARTIAL | CANCELLED
ranking which 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, pboSplits probability 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
failReason why 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
progress SweepProgress
leaderboardSize total rows currently available
truncated true only when the ranked view exceeds its display limit
leaderboard array of SweepRunRow
walkForward present only for a walk-forward sweep — see below
state the 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 an upfront estimate — the requested range against the prepare’s target cadence, set before any data is loaded rather than measured from it — on a single execute and a plain sweep alike, so completed / size is a usable progress ratio from the moment the job starts. A plain sweep’s value is the sum of every shard’s own estimate (each shard’s per-run size times its own vector slice), the same additive shape state.completed already uses. 0 means the prepare context behind the job predates this field, never a guessed value standing in for a real one. On a walkForward sweep state.size is still always 0, the same scope exclusion state.completed already has there — don’t build a fold progress bar on it.

progress — SweepProgress

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

Field Notes
done, total rows/units completed vs. total
aborted individual runs that executed and aborted (row-level)
shardCount, pendingShards total and still-pending shards
failedShards whole shards/folds that failed and will not be retried — distinct from aborted, which counts bad runs, not missing units
retrying units whose last attempt hit a transient error and are queued to retry — not a failure yet
notStarted units that haven’t reported anything; persistent alongside a rising stalledSeconds is worth investigating
stalledSeconds seconds since anything last advanced; omitted on a finished sweep
etaSeconds rough 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

Field Notes
runIx deterministic zero-based expansion index, stable across shards and ranking
rank present only in the ranked view
plateauScore, neighbourCount plateau 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
deflatedSharpe probability 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. Absent, not zero, where the statistic isn’t meaningfully computable: an aborted run, a sweep with too few trials to establish any dispersion, fewer than 3 period returns, or a degenerate (near-constant) return series
params, sharpe, sortino, pnl, pnlPct, cagr, maxDdPct, trades, winRate the trial’s own results
belowTradeFloor, aborted, runtimeMs  
equityCurve present when this completed trial has a retained curve. It may contain a URL alone or a URL plus inline points; inspect points/equities, not object or URL presence, to tell. 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": 0.576,
      "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

Field Type Default Notes
folds integer ≥ 2 — required. 2 is a structural minimum, not a tuning choice: paramDrift compares consecutive fold winners, and a single fold has no pair to compare
inSamplePct integer, 10–90 66 share 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:

Field Notes
folds, inSamplePct requested folds and resolved in-sample share
completedFolds folds finished so far; 0 while the first is still running
paramDrift mean 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
results one WalkForwardFold per completed fold, oldest first

WalkForwardFold

Field Notes
foldIx position in the sequence, oldest first
inSampleFrom, inSampleTo, outOfSampleTo window indices into the prepared session
params the vector that won this fold’s optimization window
inSampleSharpe how 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
outOfSample a full SweepRunRow — the honest number
vectorsRun vectors 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 param Type Notes
objective sharpe | sortino | pnl | maxdd defaults 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:

SweepSensitivity

Field Notes
rowsAnalysed rows available when computed; grows while the sweep is still running
marginals array of {param, points: [{value, count, best, mean, worst}]}
heatmaps array of {paramA, paramB, cells: [{valueA, valueB, count, best, mean}]}
heatmapsTruncated true 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.