Opinionated TypeScript SDK for QTSurfer, built on top of @qtsurfer/api-client.
Where @qtsurfer/api-client gives you one typed function per API endpoint, @qtsurfer/sdk adds workflow orchestration, normalized errors, and cancellation — run a backtest with a single await.
The hand-written guides mirror the SDK family structure. TypeDoc remains the generated API reference.
npm install @qtsurfer/sdk
# or
pnpm add @qtsurfer/sdk
One call: API key in, ready-to-use session out. JWT refresh on 401 is handled for you.
import { authenticate } from '@qtsurfer/sdk';
import { readFileSync } from 'node:fs';
// Reads QTSURFER_APIKEY from env when no argument is passed.
const qts = await authenticate();
// Or: const qts = await authenticate('ak_...');
const result = await qts.executeBacktest({
strategy: readFileSync('./MyStrategy.java', 'utf8'),
exchangeId: 'binance',
instrument: 'BTC/USDT',
from: '2024-01-01',
to: '2024-12-31',
storeSignals: true,
});
console.log('PnL:', result.pnlTotal);
console.log('Trades:', result.totalTrades);
| Variable | Purpose |
|---|---|
QTSURFER_APIKEY |
API key consumed by authenticate() when no arg is passed |
Tokens are kept in memory by default. Implement TokenStore to swap in
browser storage, a file, or a secret manager:
import { authenticate, type TokenStore, type AuthTokenResponse } from '@qtsurfer/sdk';
const browserStore: TokenStore = {
load: () => JSON.parse(localStorage.getItem('qts.jwt') ?? 'null'),
save: (t) => localStorage.setItem('qts.jwt', JSON.stringify(t)),
clear: () => localStorage.removeItem('qts.jwt'),
};
const qts = await authenticate(undefined, { store: browserStore });
authenticate() also accepts { baseUrl, fetch } for staging, custom HTTP
transports, or a Node-fetch polyfill in legacy runtimes.
If you already hold a JWT and want to manage refresh yourself, the
QTSurfer constructor still accepts a token:
import { QTSurfer } from '@qtsurfer/sdk';
const qts = new QTSurfer({
baseUrl: 'https://api.qtsurfer.com/v1',
token: process.env.QTSURFER_TOKEN,
});
executeBacktest() doesOrchestrates the full four-step workflow that the raw API exposes:
POST /strategy), which answers synchronously with the strategyId.POST /backtest/{exchange}/ticker/prepare) and poll until Completed.POST /backtest/{exchange}/ticker/execute) and poll GET /backtest/.../execute/{jobId} until Completed.ResultMap (pnlTotal, totalTrades, sharpeRatio, signalsUrl, …).Polling uses exponential backoff (intervalMs * 1.5, capped at maxIntervalMs) with per-stage timeout.
Progress is emitted on every stage transition and after each poll whose size > 0.
sweep() runs the same strategy once per parameter vector over one instrument and one window,
then scores and ranks the trials against a single objective. It is one call — compile → prepare →
executeSweep → poll the leaderboard — because the execute-sweep endpoint is addressed by the id
of an already-prepared dataset, and preparing is idempotent.
It resolves as soon as the platform accepts the sweep, handing back a handle while the leaderboard keeps being polled in the background.
const handle = await qts.sweep(
{
strategy: source,
exchangeId: 'binance',
instrument: 'BTC/USDT',
from: '2026-01-01T00:00:00Z',
to: '2026-02-01T00:00:00Z',
params: {
rsiPeriod: { from: 7, to: 28, step: 1 },
useTrendFilter: { values: [true, false] },
},
objective: 'sharpe',
},
{
onProgress: (p) => {
if (p.stage === 'executing') console.log(p.percent, p.snapshot?.etaSeconds);
},
},
);
// Available before a single trial has run.
handle.sweepId;
handle.accepted.seed; // effective seed — resubmit it to replay a sampled sweep exactly
handle.accepted.queued; // false ⇒ an identical sweep already existed; nothing was enqueued
handle.accepted.walkForward; // present ⇒ this sweep answers in the walk-forward shape
const result = await handle.result;
const sensitivity = await handle.getSensitivity();
// Same sweep, another view — a read, not a re-run.
const everyRow = await handle.getResults({ order: 'natural' });
params axes take one of two shapes: { from, to, step } for a numeric range, or
{ values: [...] } for an explicit list of numbers or booleans. sampler: 'random' | 'lhs' draws
samples vectors instead of the full cross product.
Five things the types cannot tell you:
ranking defaults to 'plateau' — the
objective of the worst run in a point's neighbourhood — because the highest raw score is often a
spike that does not survive small parameter moves. result.ranking reports which ordering was
actually applied, which is not always the one requested.neighbourCount: 0 means unevidenced, not confirmed. Read it together with plateauScore:
the point simply had no neighbours in the grid to compare against.truncated === true means rows were dropped from the ranked view. order: 'natural' is the
route to them — every available row, untruncated, in runIx order. Reach them with
handle.getResults({ order: 'natural' }), described below.deflatedSharpe is the probability that a row's Sharpe reflects real edge rather than the
best draw from however many vectors were tried; ~0.95 and up survives the multiple-testing
correction, ~0.5 and below does not. pbo says the same thing about the search as a whole:
above ~0.5 the sweep is selecting noise, whatever its top row says. Both are absent when there is
too little to compute them from — pbo also while the sweep is still running.aborted and failedShards on the progress snapshot count different things — runs that ran
badly versus whole units of work that never reported. Adding them double-counts. etaSeconds is
omitted rather than zeroed when it cannot be computed.order and ranking are query parameters on the result endpoint, so looking at the same sweep a
different way is a read, not a re-run:
const everyRow = await handle.getResults({ order: 'natural' });
handle.getResults(view?) compiles nothing, prepares nothing and submits nothing — no second sweep is
created. An absent property takes the platform default, and ranking is ignored alongside
order: 'natural' (that view is always runIx-ordered, and the response reports 'raw'). It works
on a sweep still in flight, returning the rows finished so far, exactly like getSensitivity().
The order / ranking passed in SweepOptions only decide what the background poll behind
handle.result reads; getResults() is how to change the view afterwards.
Adding walkForward: { folds, inSamplePct? } changes what the sweep does, not just how much of it
runs: the data is cut into sequential folds, each optimizing the whole grid on its own window and
then scoring only its winner on the window immediately after. It costs folds × grid, so it is
opt-in, and a request that multiplies past the platform's sweep budget is rejected.
The answer arrives in a different shape, and walkForward is the discriminator — present from
acceptance onward, so it is safe to branch on while polling. Its leaderboard is one row per
completed fold, with runIx carrying the fold index rather than a grid position, and no
plateau, deflated-Sharpe or PBO figure is reported. An absent paramDrift is not zero: the
figure could not be computed, and zero is itself a meaningful reading there.
handle.getSensitivity(objective?) answers what a leaderboard cannot: whether an axis moved the
objective at all. Marginals collapse every axis but one; heatmaps do the same over a pair.
Check heatmapsTruncated — marginals are always complete, but the pair surfaces are quadratic
in the axis count and may be capped, so a short list is not necessarily the whole interaction set.
Pass an AbortSignal, as with executeBacktest(). Unlike executeBacktest(), awaiting a cancelled sweep
resolves rather than rejecting: cancellation is requested between parameter vectors and the rows
already completed stay readable, so the SDK keeps polling until the platform reports the sweep
CANCELLED and then hands back the partial leaderboard. Read result.status to tell
COMPLETED, PARTIAL and CANCELLED apart. Aborting before the sweep is accepted rejects the
sweep() call itself — there is no sweep yet, and so no rows to keep.
Stream one hour of raw ticker or kline data for an instrument. The default wire format is Lastra (application/vnd.lastra); pass format: 'parquet' for on-the-fly Parquet conversion.
// Lastra (default)
const blob = await qts.downloadTickers({
exchangeId: 'binance',
base: 'BTC',
quote: 'USDT',
hour: '2026-01-15T10',
});
await Bun.write('BTC_USDT_2026-01-15_h10.lastra', await blob.arrayBuffer());
// Parquet
const klines = await qts.downloadKlines({
exchangeId: 'binance',
base: 'BTC',
quote: 'USDT',
hour: '2026-01-15T10',
format: 'parquet',
});
HTTP errors surface as QTSDownloadError (subclass of QTSError).
const exchanges = await qts.getExchanges();
// The exchange's default segment (spot today).
const spot = await qts.getInstruments('binance');
// A specific segment.
const futures = await qts.getInstruments('binance', 'futures');
console.log(spot[0]?.coverage?.tickers); // which dates are actually available
The API answers the instrument routes with a HAL envelope (data / meta / _links); the SDK
unwraps it and hands you the array. That also means meta.segment never reaches you, so if you need
certainty about which segment you are looking at, pass segment explicitly instead of relying on
the default.
validateStrategy() asks the platform to instantiate the compiled class and drive it through a
bounded synthetic series, so a wiring fault surfaces before your first backtest instead of during
it. It is idempotent and has two outcomes, which the SDK keeps distinct.
The strategyId is the one returned when the source was compiled and registered. This SDK does not
surface compilation on its own — executeBacktest() and sweep() each do it internally and keep the id —
so obtain it with qts.compileStrategy(source) if you need to validate a strategy before running it.
The response includes declaredProperties, a best-effort vocabulary of parameter keys that can
catch a typo before you submit a sweep.
const outcome = await qts.validateStrategy(strategyId);
if (outcome.queued) {
// A check was just started. NOT terminal — poll, under a deadline of your own.
const deadline = Date.now() + 60_000;
let state = await qts.getStrategy(strategyId);
while (state.validation === 'pending' && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1_000));
state = await qts.getStrategy(strategyId);
}
} else {
// A verdict already existed and nothing was queued — but read
// `state.validation`, because it can itself still be 'pending'.
console.log(outcome.state.validation);
}
Two things worth internalizing, because no type can express them:
'passed' is a floor, not a guarantee. The class loaded and survived the first event of a
short synthetic run — not your instrument, not your window, not the rest of the run. It says
nothing about whether the strategy is correct or safe to run at scale. dryRunIncomplete marks a
check that ran out of its budget, which makes the floor lower still and makes an empty notices
list no longer a clean bill of health.'pending' is not guaranteed to resolve. A queued check can go unreported for far longer than
one takes, which the platform eventually flags as validationStalled — nothing is disproved about
the strategy, the check simply did not run. That is why the loop above has a deadline and why the
SDK ships no polling helper: the timeout is your policy, not the SDK's.A verdict also describes the bytecode that existed when it was recorded. If compiledAt is newer
than validatedAt, the strategy was recompiled afterwards and the verdict no longer describes what
would run — ask for validation again.
// Every strategy you've registered and not deleted, most recently compiled first.
// Never 404s — an empty array means you have none.
const summaries = await qts.getStrategies();
// The exact source last submitted for an id, whitespace and comments included.
const code = await qts.getStrategyCode(strategyId);
// Release a registration.
await qts.deleteStrategy(strategyId);
getStrategies() deliberately omits validation on every entry — that is what keeps it cheap
regardless of how many strategies you have registered. Check a specific one's verdict with
getStrategy(strategyId).
getStrategyCode()'s 404 covers two cases the response cannot tell apart: the id was never
registered by you, or it resolves only through a shared/marketplace reference that carries no
source of its own.
deleteStrategy() resolves with nothing. It removes the strategy from both getStrategy() and
getStrategies(), but does not undo anything already run: backtests you ran against it beforehand are
unaffected, and re-submitting the exact same source afterwards registers a new strategy with a
new id rather than undeleting this one. Deleting your own copy of a strategy never affects
anyone else's copy of the same source (e.g. a shared/marketplace listing).
A full StrategyState — from getStrategy(), and from validateStrategy()'s already-validated 200
— carries an optional _links.code discovery link pointing at the same source getStrategyCode()
fetches by id. It is absent from validateStrategy()'s queued: true (202) outcome, which is a
deliberately partial stub. The SDK does not follow this link for you; it passes through unmodified
from api-client, so read it off StrategyState directly if you want it.
Create a dataset to obtain a short-lived, presigned upload URL. Upload the raw
CSV without an API authorization header, then finalize it to queue ingestion.
getDatasetUpload() reports the ingestion result; only a successfully ingested
version can be prepared with exchangeId: 'user'.
const upload = await qts.createDataset({
name: 'my-btc-tickers',
instrument: 'BTC/USDT',
});
await qts.uploadDatasetFile(upload, csvText);
const { jobId } = await qts.finalizeDatasetUpload(upload.datasetId, upload.uploadId);
const state = await qts.getDatasetUpload(upload.datasetId, upload.uploadId);
To upload a later version, open a session from the existing dataset and pass it
to the same uploadDatasetFile() helper. Calling openDatasetUpload() again
before finalizing returns the same open session, so retrying after a lost
response is safe.
const next = await qts.openDatasetUpload(upload.datasetId);
await qts.uploadDatasetFile(next, correctedCsvText);
await qts.finalizeDatasetUpload(upload.datasetId, next.uploadId);
getDatasets(), getDataset(id) and deleteDataset(id) manage dataset metadata.
Deletion hides the dataset from future use but does not change backtests that
already ran against it. A failed PUT leaves nothing to finalize; a 404 from
finalizeDatasetUpload() means the upload session is unknown or has no object
at its presigned URL. A 409 means that session already produced a version;
open a new session instead of reusing its URL.
Measured against API spec 0.111.2: 29 operations, all 29 reachable from this SDK.
It exists because the generated @qtsurfer/api-client tracks the spec automatically and this
hand-written layer does not, so an operation the platform serves could otherwise have no way in
without anything failing to compile.
Maintenance contract. When the spec gains an operation, it gains a row here. If this layer deliberately does not wrap it, the row says why.
There are two ways an operation is reached:
getExchanges, getInstruments, downloadTickers, downloadKlines, getDatasets,
createDataset, getDataset, deleteDataset, openDatasetUpload, uploadDatasetFile,
finalizeDatasetUpload, getDatasetUpload, compileStrategy, validateStrategy, getStrategy,
getStrategies, deleteStrategy, getStrategyCode) exist on
QTSurfer and, identically, on the authenticated
session. The remaining direct rows are reached
otherwise: authenticate() is a top-level export rather than a method on either class; the two
Sweep.* entries live on the handle sweep() hands back and, being handle-scoped, sit outside
the session's refresh-on-401 policy; and the two cancels are an option you pass in rather than a
call you make.executeBacktest(...) or sweep(...), with no
standalone method. Deliberate rather than missing: the workflow owns the dataset lifecycle.
Prepare, execute and result are addressed by ids the workflow mints and threads through the
stages, so exposing a stage on its own would hand the caller a requestId to keep alive and pass
around correctly, and buy nothing in return — preparing is idempotent, so preparing on every run
duplicates no work.| Operation | How it is reached |
|---|---|
authenticate |
Direct — authenticate() |
listExchanges |
Direct — getExchanges() |
listInstruments |
Direct — getInstruments(exchangeId) |
listSegmentInstruments |
Direct — getInstruments(exchangeId, segment) |
downloadTickers |
Direct — downloadTickers(...) |
downloadKlines |
Direct — downloadKlines(...) |
listStrategies |
Direct — getStrategies() |
compileStrategy |
Direct — compileStrategy(source); also used inside executeBacktest(...) / sweep(...) |
validateStrategy |
Direct — validateStrategy(strategyId) |
getStrategy |
Direct — getStrategy(strategyId) |
deleteStrategy |
Direct — deleteStrategy(strategyId) |
getStrategyCode |
Direct — getStrategyCode(strategyId) |
prepareBacktest |
Via workflow |
getPrepareStatus |
Via workflow |
executeBacktest |
Via workflow — executeBacktest(...) |
getBacktestResult |
Via workflow |
cancelBacktest |
Direct — the signal (AbortSignal) option on BacktestOptions |
executeSweep |
Via workflow — sweep(...) |
getSweepResult |
Via workflow (the background poll behind Sweep.result) and direct — Sweep.getResults(view?) re-reads the same sweep under another view |
cancelSweep |
Direct — the signal option on SweepOptions |
getSweepSensitivity |
Direct — Sweep.getSensitivity(objective?) |
getSweepRunEquityCurve |
Direct — Sweep.getEquityCurve(runIx, options?) |
listDatasets |
Direct — getDatasets() |
createDataset |
Direct — createDataset() then uploadDatasetFile() / finalizeDatasetUpload() |
deleteDataset |
Direct — deleteDataset() |
getDataset |
Direct — getDataset() |
openDatasetUpload |
Direct — openDatasetUpload() then uploadDatasetFile() |
finalizeDatasetUpload |
Direct — finalizeDatasetUpload() |
getDatasetUpload |
Direct — getDatasetUpload() |
All SDK errors extend QTSError so you can catch them generically or match by subclass.
import {
QTSError,
QTSStrategyCompileError,
QTSPreparationError,
QTSExecutionError,
QTSDownloadError,
QTSTimeoutError,
QTSCanceledError,
} from '@qtsurfer/sdk';
try {
await qts.executeBacktest(req);
} catch (e) {
if (e instanceof QTSStrategyCompileError) {
console.error('Compile failed:', e.message);
} else if (e instanceof QTSPreparationError) {
console.error('Data prep failed:', e.message);
} else if (e instanceof QTSExecutionError) {
console.error('Execution failed:', e.message);
} else if (e instanceof QTSDownloadError) {
console.error('Download failed:', e.message);
} else if (e instanceof QTSTimeoutError) {
console.error('Stage timed out');
} else if (e instanceof QTSCanceledError) {
console.error('Canceled by signal');
}
}
Pass an AbortSignal. The SDK stops polling immediately and, if execution has already started server-side, best-effort calls cancelBacktest on the QTSurfer API.
const controller = new AbortController();
setTimeout(() => controller.abort(), 60_000);
await qts.executeBacktest(req, { signal: controller.signal });
sweep() takes the same option but answers differently once the sweep has been accepted — see
Cancelling a sweep.
Polling, retry, backoff, timeout, and cancellation are delegated to cockatiel. Each workflow stage composes a retry policy (exponential backoff on in-progress statuses) with an optional timeout policy. If you need advanced resilience primitives (circuit breakers, bulkheads, fallbacks), import them directly from cockatiel.
What the SDK reaches today is the API coverage table; which release added what is in CHANGELOG.md. Milestone labels below track feature scope, not the npm package's semver.
Strategy class with .backtest(), .status()BacktestJob class with .wait(), .cancel(), .stream()exchanges / instrumentsjob.stream() returns AsyncIterator<BacktestProgress>signalsUrl Parquet into DuckDB / Lastra@qtsurfer/sdk-react, @qtsurfer/sdk-svelte)src/
├── index.ts # public exports
├── client.ts # QTSurfer class
├── errors.ts # QTSError hierarchy
├── auth/
│ ├── session.ts # authenticate() — session bootstrap + JWT refresh on 401
│ └── tokenStore.ts # TokenStore contract + default InMemoryTokenStore
├── internal/
│ ├── polling.ts # the one poll loop + status normalization every stage runs on
│ ├── preparation.ts # compile and prepare, shared by every workflow that needs a dataset
│ └── requestError.ts # QTSError construction for single-request calls
└── workflows/
├── backtest.ts # compile → prepare → execute (cockatiel policies)
├── catalog.ts # exchanges + instruments (HAL envelope unwrapped)
├── downloads.ts # hourly tickers/klines as Lastra/Parquet blobs
├── strategies.ts # validation request + recorded strategy state
└── sweep.ts # compile → prepare → executeSweep + leaderboard handle
| Script | Description |
|---|---|
npm run lint |
Type-check without emitting |
npm run build |
Bundle to dist/ via tsup |
npm test |
Run unit tests |
npm run test:integration |
Run the integration test (requires JWT_API_TOKEN). Set QTSURFER_TEST_VERBOSE=1 to stream progress + final result |
npm run changeset |
Record a changeset for the next release |
npm run changeset:version |
Consume pending changesets: bump package.json and update CHANGELOG.md |
npm run changeset:publish |
Publish released packages to npm (used by CI) |
Versioning and changelogs are managed with changesets:
npm run changeset.changeset/<slug>.md with your PR.npm run changeset:version locally. It bumps package.json and appends to CHANGELOG.md.vX.Y.Z, and push the tag; the Publish to npm workflow handles the rest.Apache-2.0 — see LICENSE.