@qtsurfer/sdk 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -10
- package/dist/index.d.ts +780 -7
- package/dist/index.js +516 -65
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/session.ts +86 -2
- package/src/client.ts +153 -3
- package/src/errors.ts +4 -2
- package/src/index.ts +33 -0
- package/src/internal/polling.ts +142 -0
- package/src/internal/preparation.ts +121 -0
- package/src/internal/requestError.ts +36 -0
- package/src/workflows/backtest.ts +46 -167
- package/src/workflows/catalog.ts +74 -0
- package/src/workflows/strategies.ts +118 -0
- package/src/workflows/sweep.ts +861 -0
package/README.md
CHANGED
|
@@ -98,6 +98,122 @@ Polling uses exponential backoff (`intervalMs * 1.5`, capped at `maxIntervalMs`)
|
|
|
98
98
|
|
|
99
99
|
Progress is emitted on every stage transition and after each poll whose `size > 0`.
|
|
100
100
|
|
|
101
|
+
## Parameter sweeps
|
|
102
|
+
|
|
103
|
+
`sweep()` runs the same strategy once per parameter vector over one instrument and one window,
|
|
104
|
+
then scores and ranks the trials against a single objective. It is one call — compile → prepare →
|
|
105
|
+
`executeSweep` → poll the leaderboard — because the execute-sweep endpoint is addressed by the id
|
|
106
|
+
of an already-prepared dataset, and preparing is idempotent.
|
|
107
|
+
|
|
108
|
+
It resolves as soon as the platform accepts the sweep, handing back a handle while the leaderboard
|
|
109
|
+
keeps being polled in the background.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const handle = await qts.sweep(
|
|
113
|
+
{
|
|
114
|
+
strategy: source,
|
|
115
|
+
exchangeId: 'binance',
|
|
116
|
+
instrument: 'BTC/USDT',
|
|
117
|
+
from: '2026-01-01T00:00:00Z',
|
|
118
|
+
to: '2026-02-01T00:00:00Z',
|
|
119
|
+
params: {
|
|
120
|
+
rsiPeriod: { from: 7, to: 28, step: 1 },
|
|
121
|
+
useTrendFilter: { values: [true, false] },
|
|
122
|
+
},
|
|
123
|
+
objective: 'sharpe',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
onProgress: (p) => {
|
|
127
|
+
if (p.stage === 'executing') console.log(p.percent, p.snapshot?.etaSeconds);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Available before a single trial has run.
|
|
133
|
+
handle.sweepId;
|
|
134
|
+
handle.accepted.seed; // effective seed — resubmit it to replay a sampled sweep exactly
|
|
135
|
+
handle.accepted.queued; // false ⇒ an identical sweep already existed; nothing was enqueued
|
|
136
|
+
handle.accepted.walkForward; // present ⇒ this sweep answers in the walk-forward shape
|
|
137
|
+
|
|
138
|
+
const result = await handle.result;
|
|
139
|
+
const sensitivity = await handle.sensitivity();
|
|
140
|
+
|
|
141
|
+
// Same sweep, another view — a read, not a re-run.
|
|
142
|
+
const everyRow = await handle.results({ order: 'natural' });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`params` axes take one of two shapes: `{ from, to, step }` for a numeric range, or
|
|
146
|
+
`{ values: [...] }` for an explicit list of numbers or booleans. `sampler: 'random' | 'lhs'` draws
|
|
147
|
+
`samples` vectors instead of the full cross product.
|
|
148
|
+
|
|
149
|
+
### Reading the leaderboard
|
|
150
|
+
|
|
151
|
+
Five things the types cannot tell you:
|
|
152
|
+
|
|
153
|
+
- **The default order is not the raw objective order.** `ranking` defaults to `'plateau'` — the
|
|
154
|
+
objective of the worst run in a point's neighbourhood — because the highest raw score is often a
|
|
155
|
+
spike that does not survive small parameter moves. `result.ranking` reports which ordering was
|
|
156
|
+
*actually* applied, which is not always the one requested.
|
|
157
|
+
- **`neighbourCount: 0` means unevidenced, not confirmed.** Read it together with `plateauScore`:
|
|
158
|
+
the point simply had no neighbours in the grid to compare against.
|
|
159
|
+
- **`truncated === true` means rows were dropped** from the ranked view. `order: 'natural'` is the
|
|
160
|
+
route to them — every available row, untruncated, in `runIx` order. Reach them with
|
|
161
|
+
`handle.results({ order: 'natural' })`, described below.
|
|
162
|
+
- **`deflatedSharpe`** is the probability that a row's Sharpe reflects real edge rather than the
|
|
163
|
+
best draw from however many vectors were tried; ~0.95 and up survives the multiple-testing
|
|
164
|
+
correction, ~0.5 and below does not. **`pbo`** says the same thing about the search as a whole:
|
|
165
|
+
above ~0.5 the sweep is selecting noise, whatever its top row says. Both are absent when there is
|
|
166
|
+
too little to compute them from — `pbo` also while the sweep is still running.
|
|
167
|
+
- **`aborted` and `failedShards` on the progress snapshot count different things** — runs that ran
|
|
168
|
+
badly versus whole units of work that never reported. Adding them double-counts. `etaSeconds` is
|
|
169
|
+
omitted rather than zeroed when it cannot be computed.
|
|
170
|
+
|
|
171
|
+
### Re-reading a sweep under another view
|
|
172
|
+
|
|
173
|
+
`order` and `ranking` are query parameters on the result endpoint, so looking at the same sweep a
|
|
174
|
+
different way is a **read**, not a re-run:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const everyRow = await handle.results({ order: 'natural' });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`handle.results(view?)` compiles nothing, prepares nothing and submits nothing — no second sweep is
|
|
181
|
+
created. An absent property takes the platform default, and `ranking` is ignored alongside
|
|
182
|
+
`order: 'natural'` (that view is always `runIx`-ordered, and the response reports `'raw'`). It works
|
|
183
|
+
on a sweep still in flight, returning the rows finished so far, exactly like `sensitivity()`.
|
|
184
|
+
|
|
185
|
+
The `order` / `ranking` passed in `SweepOptions` only decide what the background poll behind
|
|
186
|
+
`handle.result` reads; `results()` is how to change the view afterwards.
|
|
187
|
+
|
|
188
|
+
### Walk-forward validation
|
|
189
|
+
|
|
190
|
+
Adding `walkForward: { folds, inSamplePct? }` changes what the sweep does, not just how much of it
|
|
191
|
+
runs: the data is cut into sequential folds, each optimizing the whole grid on its own window and
|
|
192
|
+
then scoring only its winner on the window immediately after. It costs folds × grid, so it is
|
|
193
|
+
opt-in, and a request that multiplies past the platform's sweep budget is rejected.
|
|
194
|
+
|
|
195
|
+
The answer arrives in a different shape, and `walkForward` is the discriminator — present from
|
|
196
|
+
acceptance onward, so it is safe to branch on while polling. Its leaderboard is one row per
|
|
197
|
+
completed fold, with **`runIx` carrying the fold index rather than a grid position**, and no
|
|
198
|
+
plateau, deflated-Sharpe or PBO figure is reported. An absent `paramDrift` is *not* zero: the
|
|
199
|
+
figure could not be computed, and zero is itself a meaningful reading there.
|
|
200
|
+
|
|
201
|
+
### Sensitivity
|
|
202
|
+
|
|
203
|
+
`handle.sensitivity(objective?)` answers what a leaderboard cannot: whether an axis moved the
|
|
204
|
+
objective at all. Marginals collapse every axis but one; heatmaps do the same over a pair.
|
|
205
|
+
**Check `heatmapsTruncated`** — marginals are always complete, but the pair surfaces are quadratic
|
|
206
|
+
in the axis count and may be capped, so a short list is not necessarily the whole interaction set.
|
|
207
|
+
|
|
208
|
+
### Cancelling a sweep
|
|
209
|
+
|
|
210
|
+
Pass an `AbortSignal`, as with `backtest()`. Unlike `backtest()`, **awaiting a cancelled sweep
|
|
211
|
+
resolves rather than rejecting**: cancellation is requested between parameter vectors and the rows
|
|
212
|
+
already completed stay readable, so the SDK keeps polling until the platform reports the sweep
|
|
213
|
+
`CANCELLED` and then hands back the partial leaderboard. Read `result.status` to tell
|
|
214
|
+
`COMPLETED`, `PARTIAL` and `CANCELLED` apart. Aborting *before* the sweep is accepted rejects the
|
|
215
|
+
`sweep()` call itself — there is no sweep yet, and so no rows to keep.
|
|
216
|
+
|
|
101
217
|
## Hourly tickers/klines downloads
|
|
102
218
|
|
|
103
219
|
Stream one hour of raw ticker or kline data for an instrument. The default wire format is [Lastra](https://github.com/QTSurfer/lastra-ts) (`application/vnd.lastra`); pass `format: 'parquet'` for on-the-fly Parquet conversion.
|
|
@@ -124,6 +240,120 @@ const klines = await qts.klines({
|
|
|
124
240
|
|
|
125
241
|
HTTP errors surface as `QTSDownloadError` (subclass of `QTSError`).
|
|
126
242
|
|
|
243
|
+
## Exchanges and instruments
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const exchanges = await qts.exchanges();
|
|
247
|
+
|
|
248
|
+
// The exchange's default segment (spot today).
|
|
249
|
+
const spot = await qts.instruments('binance');
|
|
250
|
+
|
|
251
|
+
// A specific segment.
|
|
252
|
+
const futures = await qts.instruments('binance', 'futures');
|
|
253
|
+
|
|
254
|
+
console.log(spot[0]?.coverage?.tickers); // which dates are actually available
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The API answers the instrument routes with a HAL envelope (`data` / `meta` / `_links`); the SDK
|
|
258
|
+
unwraps it and hands you the array. That also means `meta.segment` never reaches you, so if you need
|
|
259
|
+
certainty about which segment you are looking at, pass `segment` explicitly instead of relying on
|
|
260
|
+
the default.
|
|
261
|
+
|
|
262
|
+
## Strategy validation
|
|
263
|
+
|
|
264
|
+
`validateStrategy()` asks the platform to instantiate the compiled class and drive it through a
|
|
265
|
+
bounded synthetic series, so a wiring fault surfaces before your first backtest instead of during
|
|
266
|
+
it. It is idempotent and has **two outcomes**, which the SDK keeps distinct.
|
|
267
|
+
|
|
268
|
+
The `strategyId` is the one returned when the source was compiled and registered. This SDK does not
|
|
269
|
+
surface compilation on its own — `backtest()` and `sweep()` each do it internally and keep the id —
|
|
270
|
+
so obtain it from `compileStrategy()` in
|
|
271
|
+
[`@qtsurfer/api-client`](https://github.com/QTSurfer/api-client-ts) if you need to validate a
|
|
272
|
+
strategy before running it. That is a real difference between the two SDKs rather than an oversight
|
|
273
|
+
in either: the Java SDK exposes a standalone `compile(...)`, and this one does not.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const outcome = await qts.validateStrategy(strategyId);
|
|
277
|
+
|
|
278
|
+
if (outcome.queued) {
|
|
279
|
+
// A check was just started. NOT terminal — poll, under a deadline of your own.
|
|
280
|
+
const deadline = Date.now() + 60_000;
|
|
281
|
+
let state = await qts.strategy(strategyId);
|
|
282
|
+
while (state.validation === 'pending' && Date.now() < deadline) {
|
|
283
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
284
|
+
state = await qts.strategy(strategyId);
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// A verdict already existed and nothing was queued — but read
|
|
288
|
+
// `state.validation`, because it can itself still be 'pending'.
|
|
289
|
+
console.log(outcome.state.validation);
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Two things worth internalizing, because no type can express them:
|
|
294
|
+
|
|
295
|
+
- **`'passed'` is a floor, not a guarantee.** The class loaded and survived the first event of a
|
|
296
|
+
short synthetic run — not your instrument, not your window, not the rest of the run. It says
|
|
297
|
+
nothing about whether the strategy is correct or safe to run at scale. `dryRunIncomplete` marks a
|
|
298
|
+
check that ran out of its budget, which makes the floor lower still and makes an empty `notices`
|
|
299
|
+
list no longer a clean bill of health.
|
|
300
|
+
- **`'pending'` is not guaranteed to resolve.** A queued check can go unreported for far longer than
|
|
301
|
+
one takes, which the platform eventually flags as `validationStalled` — nothing is disproved about
|
|
302
|
+
the strategy, the check simply did not run. That is why the loop above has a deadline and why the
|
|
303
|
+
SDK ships no polling helper: the timeout is your policy, not the SDK's.
|
|
304
|
+
|
|
305
|
+
A verdict also describes the bytecode that existed when it was recorded. If `compiledAt` is newer
|
|
306
|
+
than `validatedAt`, the strategy was recompiled afterwards and the verdict no longer describes what
|
|
307
|
+
would run — ask for validation again.
|
|
308
|
+
|
|
309
|
+
## API coverage
|
|
310
|
+
|
|
311
|
+
Measured against **API spec 0.107.0**: 18 operations, all 18 reachable from this SDK.
|
|
312
|
+
|
|
313
|
+
It exists because the generated `@qtsurfer/api-client` tracks the spec automatically and this
|
|
314
|
+
hand-written layer does not, so an operation the platform serves could otherwise have no way in
|
|
315
|
+
without anything failing to compile.
|
|
316
|
+
|
|
317
|
+
**Maintenance contract.** When the spec gains an operation, it gains a row here. If this layer
|
|
318
|
+
deliberately does not wrap it, the row says why.
|
|
319
|
+
|
|
320
|
+
There are two ways an operation is reached:
|
|
321
|
+
|
|
322
|
+
- **Direct** — callable on its own, without running a workflow. The client methods below
|
|
323
|
+
(`exchanges`, `instruments`, `tickers`, `klines`, `validateStrategy`, `strategy`) exist on
|
|
324
|
+
`QTSurfer` and, identically, on the authenticated session. The remaining direct rows are reached
|
|
325
|
+
otherwise: `authenticate()` is a top-level export rather than a method on either class; the two
|
|
326
|
+
`Sweep.*` entries live on the handle `sweep()` hands back and, being handle-scoped, sit outside
|
|
327
|
+
the session's refresh-on-401 policy; and the two cancels are an option you pass in rather than a
|
|
328
|
+
call you make.
|
|
329
|
+
- **Via workflow** — reachable only as a stage inside `backtest(...)` or `sweep(...)`, with no
|
|
330
|
+
standalone method. Deliberate rather than missing: the workflow owns the dataset lifecycle.
|
|
331
|
+
Prepare, execute and result are addressed by ids the workflow mints and threads through the
|
|
332
|
+
stages, so exposing a stage on its own would hand the caller a `requestId` to keep alive and pass
|
|
333
|
+
around correctly, and buy nothing in return — preparing is idempotent, so preparing on every run
|
|
334
|
+
duplicates no work.
|
|
335
|
+
|
|
336
|
+
| Operation | How it is reached |
|
|
337
|
+
| --- | --- |
|
|
338
|
+
| `authenticate` | Direct — `authenticate()` |
|
|
339
|
+
| `listExchanges` | Direct — `exchanges()` |
|
|
340
|
+
| `listInstruments` | Direct — `instruments(exchangeId)` |
|
|
341
|
+
| `listSegmentInstruments` | Direct — `instruments(exchangeId, segment)` |
|
|
342
|
+
| `downloadTickers` | Direct — `tickers(...)` |
|
|
343
|
+
| `downloadKlines` | Direct — `klines(...)` |
|
|
344
|
+
| `compileStrategy` | Via workflow — inside `backtest(...)` / `sweep(...)`; no standalone method, unlike the Java SDK |
|
|
345
|
+
| `validateStrategy` | Direct — `validateStrategy(strategyId)` |
|
|
346
|
+
| `getStrategy` | Direct — `strategy(strategyId)` |
|
|
347
|
+
| `prepareBacktest` | Via workflow |
|
|
348
|
+
| `getPrepareStatus` | Via workflow |
|
|
349
|
+
| `executeBacktest` | Via workflow — `backtest(...)` |
|
|
350
|
+
| `getBacktestResult` | Via workflow |
|
|
351
|
+
| `cancelBacktest` | Direct — the `signal` (`AbortSignal`) option on `BacktestOptions` |
|
|
352
|
+
| `executeSweep` | Via workflow — `sweep(...)` |
|
|
353
|
+
| `getSweepResult` | Via workflow (the background poll behind `Sweep.result`) and direct — `Sweep.results(view?)` re-reads the same sweep under another view |
|
|
354
|
+
| `cancelSweep` | Direct — the `signal` option on `SweepOptions` |
|
|
355
|
+
| `getSweepSensitivity` | Direct — `Sweep.sensitivity(objective?)` |
|
|
356
|
+
|
|
127
357
|
## Error hierarchy
|
|
128
358
|
|
|
129
359
|
All SDK errors extend `QTSError` so you can catch them generically or match by subclass.
|
|
@@ -168,21 +398,18 @@ setTimeout(() => controller.abort(), 60_000);
|
|
|
168
398
|
await qts.backtest(req, { signal: controller.signal });
|
|
169
399
|
```
|
|
170
400
|
|
|
401
|
+
`sweep()` takes the same option but answers differently once the sweep has been accepted — see
|
|
402
|
+
[Cancelling a sweep](#cancelling-a-sweep).
|
|
403
|
+
|
|
171
404
|
## Under the hood
|
|
172
405
|
|
|
173
406
|
Polling, retry, backoff, timeout, and cancellation are delegated to [`cockatiel`](https://github.com/connor4312/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`.
|
|
174
407
|
|
|
175
408
|
## Roadmap
|
|
176
409
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
### v0.1 — Core workflow ✅
|
|
181
|
-
|
|
182
|
-
- [x] `QTSurfer` client over `@qtsurfer/api-client`
|
|
183
|
-
- [x] `qts.backtest()` orchestrating compile → prepare → execute
|
|
184
|
-
- [x] Backoff, timeout, and `AbortSignal` cancellation via `cockatiel` policies
|
|
185
|
-
- [x] Error hierarchy: `QTSError`, `QTSStrategyCompileError`, `QTSPreparationError`, `QTSExecutionError`, `QTSTimeoutError`, `QTSCanceledError`
|
|
410
|
+
What the SDK reaches today is the [API coverage](#api-coverage) table; which release added what is
|
|
411
|
+
in [CHANGELOG.md](./CHANGELOG.md). Milestone labels below track feature scope, not the npm package's
|
|
412
|
+
semver.
|
|
186
413
|
|
|
187
414
|
### v0.2 — Domain objects
|
|
188
415
|
|
|
@@ -210,9 +437,16 @@ src/
|
|
|
210
437
|
├── auth/
|
|
211
438
|
│ ├── session.ts # authenticate() — session bootstrap + JWT refresh on 401
|
|
212
439
|
│ └── tokenStore.ts # TokenStore contract + default InMemoryTokenStore
|
|
440
|
+
├── internal/
|
|
441
|
+
│ ├── polling.ts # the one poll loop + status normalization every stage runs on
|
|
442
|
+
│ ├── preparation.ts # compile and prepare, shared by every workflow that needs a dataset
|
|
443
|
+
│ └── requestError.ts # QTSError construction for single-request calls
|
|
213
444
|
└── workflows/
|
|
214
445
|
├── backtest.ts # compile → prepare → execute (cockatiel policies)
|
|
215
|
-
|
|
446
|
+
├── catalog.ts # exchanges + instruments (HAL envelope unwrapped)
|
|
447
|
+
├── downloads.ts # hourly tickers/klines as Lastra/Parquet blobs
|
|
448
|
+
├── strategies.ts # validation request + recorded strategy state
|
|
449
|
+
└── sweep.ts # compile → prepare → executeSweep + leaderboard handle
|
|
216
450
|
```
|
|
217
451
|
|
|
218
452
|
## Development
|