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). {type} is ticker or kline; a kline sweep, walk-forward included,
runs over bars of the cadence the request was prepared at (see Data sources). 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]} - range —
from,to,step(all required,step> 0) - enumerated —
values(≥ 1 item, eachnumberorboolean)
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:
- 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, andworstdisagreeing is itself a signal — a highbestwith a poormeanonly 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 (
Naxes →N(N-1)/2surfaces), which is why this is a separate endpoint rather than fields on the poll response —heatmapsTruncated: truemeans at least one surface was left out to stay inside the response budget.
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):
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.