@qtsurfer/sdk 0.7.1 → 0.9.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 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,158 @@ 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
+ ## Managing registered strategies
310
+
311
+ ```ts
312
+ // Every strategy you've registered and not deleted, most recently compiled first.
313
+ // Never 404s — an empty array means you have none.
314
+ const summaries = await qts.strategies();
315
+
316
+ // The exact source last submitted for an id, whitespace and comments included.
317
+ const code = await qts.strategyCode(strategyId);
318
+
319
+ // Release a registration.
320
+ await qts.deleteStrategy(strategyId);
321
+ ```
322
+
323
+ `strategies()` deliberately omits `validation` on every entry — that is what keeps it cheap
324
+ regardless of how many strategies you have registered. Check a specific one's verdict with
325
+ `strategy(strategyId)`.
326
+
327
+ `strategyCode()`'s `404` covers two cases the response cannot tell apart: the id was never
328
+ registered by you, or it resolves only through a shared/marketplace reference that carries no
329
+ source of its own.
330
+
331
+ `deleteStrategy()` resolves with nothing. It removes the strategy from both `strategy()` and
332
+ `strategies()`, but does not undo anything already run: backtests you ran against it beforehand are
333
+ unaffected, and re-submitting the exact same source afterwards registers a **new** strategy with a
334
+ **new** id rather than undeleting this one. Deleting your own copy of a strategy never affects
335
+ anyone else's copy of the same source (e.g. a shared/marketplace listing).
336
+
337
+ A full `StrategyState` — from `strategy()`, and from `validateStrategy()`'s already-validated `200`
338
+ — carries an optional `_links.code` discovery link pointing at the same source `strategyCode()`
339
+ fetches by id. It is absent from `validateStrategy()`'s `queued: true` (`202`) outcome, which is a
340
+ deliberately partial stub. The SDK does not follow this link for you; it passes through unmodified
341
+ from api-client, so read it off `StrategyState` directly if you want it.
342
+
343
+ ## API coverage
344
+
345
+ Measured against **API spec 0.109.2**: 21 operations, all 21 reachable from this SDK.
346
+
347
+ It exists because the generated `@qtsurfer/api-client` tracks the spec automatically and this
348
+ hand-written layer does not, so an operation the platform serves could otherwise have no way in
349
+ without anything failing to compile.
350
+
351
+ **Maintenance contract.** When the spec gains an operation, it gains a row here. If this layer
352
+ deliberately does not wrap it, the row says why.
353
+
354
+ There are two ways an operation is reached:
355
+
356
+ - **Direct** — callable on its own, without running a workflow. The client methods below
357
+ (`exchanges`, `instruments`, `tickers`, `klines`, `validateStrategy`, `strategy`, `strategies`,
358
+ `deleteStrategy`, `strategyCode`) exist on `QTSurfer` and, identically, on the authenticated
359
+ session. The remaining direct rows are reached
360
+ otherwise: `authenticate()` is a top-level export rather than a method on either class; the two
361
+ `Sweep.*` entries live on the handle `sweep()` hands back and, being handle-scoped, sit outside
362
+ the session's refresh-on-401 policy; and the two cancels are an option you pass in rather than a
363
+ call you make.
364
+ - **Via workflow** — reachable only as a stage inside `backtest(...)` or `sweep(...)`, with no
365
+ standalone method. Deliberate rather than missing: the workflow owns the dataset lifecycle.
366
+ Prepare, execute and result are addressed by ids the workflow mints and threads through the
367
+ stages, so exposing a stage on its own would hand the caller a `requestId` to keep alive and pass
368
+ around correctly, and buy nothing in return — preparing is idempotent, so preparing on every run
369
+ duplicates no work.
370
+
371
+ | Operation | How it is reached |
372
+ | --- | --- |
373
+ | `authenticate` | Direct — `authenticate()` |
374
+ | `listExchanges` | Direct — `exchanges()` |
375
+ | `listInstruments` | Direct — `instruments(exchangeId)` |
376
+ | `listSegmentInstruments` | Direct — `instruments(exchangeId, segment)` |
377
+ | `downloadTickers` | Direct — `tickers(...)` |
378
+ | `downloadKlines` | Direct — `klines(...)` |
379
+ | `listStrategies` | Direct — `strategies()` |
380
+ | `compileStrategy` | Via workflow — inside `backtest(...)` / `sweep(...)`; no standalone method, unlike the Java SDK |
381
+ | `validateStrategy` | Direct — `validateStrategy(strategyId)` |
382
+ | `getStrategy` | Direct — `strategy(strategyId)` |
383
+ | `deleteStrategy` | Direct — `deleteStrategy(strategyId)` |
384
+ | `getStrategyCode` | Direct — `strategyCode(strategyId)` |
385
+ | `prepareBacktest` | Via workflow |
386
+ | `getPrepareStatus` | Via workflow |
387
+ | `executeBacktest` | Via workflow — `backtest(...)` |
388
+ | `getBacktestResult` | Via workflow |
389
+ | `cancelBacktest` | Direct — the `signal` (`AbortSignal`) option on `BacktestOptions` |
390
+ | `executeSweep` | Via workflow — `sweep(...)` |
391
+ | `getSweepResult` | Via workflow (the background poll behind `Sweep.result`) and direct — `Sweep.results(view?)` re-reads the same sweep under another view |
392
+ | `cancelSweep` | Direct — the `signal` option on `SweepOptions` |
393
+ | `getSweepSensitivity` | Direct — `Sweep.sensitivity(objective?)` |
394
+
127
395
  ## Error hierarchy
128
396
 
129
397
  All SDK errors extend `QTSError` so you can catch them generically or match by subclass.
@@ -168,21 +436,18 @@ setTimeout(() => controller.abort(), 60_000);
168
436
  await qts.backtest(req, { signal: controller.signal });
169
437
  ```
170
438
 
439
+ `sweep()` takes the same option but answers differently once the sweep has been accepted — see
440
+ [Cancelling a sweep](#cancelling-a-sweep).
441
+
171
442
  ## Under the hood
172
443
 
173
444
  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
445
 
175
446
  ## Roadmap
176
447
 
177
- Milestone labels below (`v0.1`–`v0.4`) track feature scope, not the npm package's
178
- semver — see [CHANGELOG.md](./CHANGELOG.md) for the actual release history.
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`
448
+ What the SDK reaches today is the [API coverage](#api-coverage) table; which release added what is
449
+ in [CHANGELOG.md](./CHANGELOG.md). Milestone labels below track feature scope, not the npm package's
450
+ semver.
186
451
 
187
452
  ### v0.2 — Domain objects
188
453
 
@@ -210,9 +475,16 @@ src/
210
475
  ├── auth/
211
476
  │ ├── session.ts # authenticate() — session bootstrap + JWT refresh on 401
212
477
  │ └── tokenStore.ts # TokenStore contract + default InMemoryTokenStore
478
+ ├── internal/
479
+ │ ├── polling.ts # the one poll loop + status normalization every stage runs on
480
+ │ ├── preparation.ts # compile and prepare, shared by every workflow that needs a dataset
481
+ │ └── requestError.ts # QTSError construction for single-request calls
213
482
  └── workflows/
214
483
  ├── backtest.ts # compile → prepare → execute (cockatiel policies)
215
- └── downloads.ts # hourly tickers/klines as Lastra/Parquet blobs
484
+ ├── catalog.ts # exchanges + instruments (HAL envelope unwrapped)
485
+ ├── downloads.ts # hourly tickers/klines as Lastra/Parquet blobs
486
+ ├── strategies.ts # validation request + recorded strategy state
487
+ └── sweep.ts # compile → prepare → executeSweep + leaderboard handle
216
488
  ```
217
489
 
218
490
  ## Development