@qtsurfer/sdk
    Preparing search index...

    @qtsurfer/sdk

    @qtsurfer/sdk

    CI npm TypeDoc License

    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,
    });

    Orchestrates the full four-step workflow that the raw API exposes:

    1. Compile the strategy (POST /strategy), which answers synchronously with the strategyId.
    2. Prepare the data range (POST /backtest/{exchange}/ticker/prepare) and poll until Completed.
    3. Execute the backtest (POST /backtest/{exchange}/ticker/execute) and poll GET /backtest/.../execute/{jobId} until Completed.
    4. Return the 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:

    • The default order is not the raw objective order. 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:

    • Direct — callable on its own, without running a workflow. The client methods below (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.
    • Via workflow — reachable only as a stage inside 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()
    • [ ] TTL cache for exchanges / instruments
    • [ ] job.stream() returns AsyncIterator<BacktestProgress>
    • [ ] Server-side hooks (when the backend exposes SSE/WebSocket)
    • [ ] Helpers to load signalsUrl Parquet into DuckDB / Lastra
    • [ ] Framework adapters (@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 # compileprepareexecute (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 # compileprepareexecuteSweep + 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:

    1. Create a changeset describing your change: npm run changeset
    2. Commit the generated .changeset/<slug>.md with your PR.
    3. When ready to release, run npm run changeset:version locally. It bumps package.json and appends to CHANGELOG.md.
    4. Commit the version bump, tag vX.Y.Z, and push the tag; the Publish to npm workflow handles the rest.

    Apache-2.0 — see LICENSE.