@qtsurfer/sdk 0.7.0 → 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/dist/index.js CHANGED
@@ -5,20 +5,8 @@ import { client as apiClient } from "@qtsurfer/api-client";
5
5
  import {
6
6
  cancelBacktest,
7
7
  executeBacktest,
8
- getBacktestResult,
9
- getPrepareStatus,
10
- compileStrategy as apiCompileStrategy,
11
- prepareBacktest
8
+ getBacktestResult
12
9
  } from "@qtsurfer/api-client";
13
- import {
14
- ExponentialBackoff,
15
- TaskCancelledError,
16
- TimeoutStrategy,
17
- handleWhenResult,
18
- retry,
19
- timeout,
20
- wrap
21
- } from "cockatiel";
22
10
 
23
11
  // src/errors.ts
24
12
  var QTSError = class extends Error {
@@ -75,27 +63,27 @@ var QTSAuthError = class extends QTSError {
75
63
  }
76
64
  };
77
65
 
78
- // src/workflows/backtest.ts
79
- var TICKER = "ticker";
66
+ // src/internal/polling.ts
67
+ import {
68
+ ExponentialBackoff,
69
+ TaskCancelledError,
70
+ TimeoutStrategy,
71
+ handleWhenResult,
72
+ retry,
73
+ timeout,
74
+ wrap
75
+ } from "cockatiel";
80
76
  function normalizeStatus(raw) {
81
77
  const value = typeof raw === "string" ? raw.toLowerCase() : "";
82
78
  if (value === "completed") return "completed";
79
+ if (value === "partial") return "completed";
83
80
  if (value === "failed") return "failed";
84
81
  if (value === "aborted" || value === "cancelled" || value === "canceled") {
85
82
  return "aborted";
86
83
  }
87
84
  return "in-progress";
88
85
  }
89
- async function backtest(req, opts = {}) {
90
- const policy = buildStagePolicy(opts);
91
- opts.onProgress?.({ stage: "compiling" });
92
- const strategyId = await compileStrategy(req.strategy, opts);
93
- opts.onProgress?.({ stage: "preparing" });
94
- const prepareJobId = await prepareData(req, policy, opts);
95
- opts.onProgress?.({ stage: "executing" });
96
- return executeStrategy(req, prepareJobId, strategyId, policy, opts);
97
- }
98
- function buildStagePolicy(opts) {
86
+ function buildStagePolicy(opts, defaultPollMs, defaultMaxPollMs) {
99
87
  const retryPolicy = retry(
100
88
  handleWhenResult((r) => {
101
89
  const status = r?.status;
@@ -104,17 +92,44 @@ function buildStagePolicy(opts) {
104
92
  {
105
93
  maxAttempts: Number.MAX_SAFE_INTEGER,
106
94
  backoff: new ExponentialBackoff({
107
- initialDelay: opts.pollIntervalMs ?? 500,
108
- maxDelay: opts.maxPollIntervalMs ?? 5e3
95
+ initialDelay: opts.pollIntervalMs ?? defaultPollMs,
96
+ maxDelay: opts.maxPollIntervalMs ?? defaultMaxPollMs
109
97
  })
110
98
  }
111
99
  );
112
100
  return opts.timeoutMs ? wrap(timeout(opts.timeoutMs, TimeoutStrategy.Cooperative), retryPolicy) : retryPolicy;
113
101
  }
114
- async function compileStrategy(source, opts) {
102
+ async function runStage(policy, fetchFn, run = {}) {
103
+ try {
104
+ return await policy.execute(async (ctx) => {
105
+ if (run.signal?.aborted) throw new QTSCanceledError("Workflow aborted");
106
+ const result = await fetchFn(ctx);
107
+ run.onEachAttempt?.(result);
108
+ if (run.signal?.aborted) throw new QTSCanceledError("Workflow aborted");
109
+ return result;
110
+ }, run.signal);
111
+ } catch (err) {
112
+ if (err instanceof QTSCanceledError) throw err;
113
+ if (err instanceof TaskCancelledError) {
114
+ if (run.signal?.aborted) throw new QTSCanceledError("Workflow aborted", err);
115
+ throw new QTSTimeoutError(`Stage exceeded ${run.timeoutMs}ms`, err);
116
+ }
117
+ if (run.signal?.aborted) throw new QTSCanceledError("Workflow aborted", err);
118
+ throw err;
119
+ }
120
+ }
121
+
122
+ // src/internal/preparation.ts
123
+ import {
124
+ getPrepareStatus,
125
+ compileStrategy as apiCompileStrategy,
126
+ prepareBacktest
127
+ } from "@qtsurfer/api-client";
128
+ var TICKER = "ticker";
129
+ async function compileStrategySource(source, signal) {
115
130
  const { data, error, response } = await apiCompileStrategy({
116
131
  body: source,
117
- ...opts.signal ? { signal: opts.signal } : {}
132
+ ...signal ? { signal } : {}
118
133
  });
119
134
  if (error) {
120
135
  if (response?.status === 429) {
@@ -130,30 +145,31 @@ async function compileStrategy(source, opts) {
130
145
  }
131
146
  return data.strategyId;
132
147
  }
133
- async function prepareData(req, policy, opts) {
148
+ async function prepareDataset(target, policy, run = {}) {
134
149
  const { data, error } = await prepareBacktest({
135
- path: { exchangeId: req.exchangeId, type: TICKER },
136
- body: { instrument: req.instrument, from: req.from, to: req.to },
137
- ...opts.signal ? { signal: opts.signal } : {}
150
+ path: { exchangeId: target.exchangeId, type: TICKER },
151
+ body: { instrument: target.instrument, from: target.from, to: target.to },
152
+ ...run.signal ? { signal: run.signal } : {}
138
153
  });
139
154
  if (error) throw new QTSPreparationError("Prepare submission failed", error);
140
155
  if (!data?.jobId) throw new QTSPreparationError("Missing jobId in prepare response");
141
156
  const prepareJobId = data.jobId;
142
157
  const state = await runStage(
143
158
  policy,
144
- opts,
145
159
  async ({ signal }) => {
146
160
  const res = await getPrepareStatus({
147
- path: { exchangeId: req.exchangeId, type: TICKER, jobId: prepareJobId },
161
+ path: { exchangeId: target.exchangeId, type: TICKER, jobId: prepareJobId },
148
162
  signal
149
163
  });
150
164
  if (res.error) throw new QTSPreparationError("Preparation status request failed", res.error);
151
165
  if (!res.data) throw new QTSPreparationError("Empty preparation status response");
152
166
  return res.data;
153
167
  },
154
- (r) => {
155
- if (r.size > 0) {
156
- opts.onProgress?.({ stage: "preparing", percent: r.completed / r.size * 100 });
168
+ {
169
+ ...run.signal ? { signal: run.signal } : {},
170
+ ...run.timeoutMs !== void 0 ? { timeoutMs: run.timeoutMs } : {},
171
+ onEachAttempt: (r) => {
172
+ if (r.size > 0) run.onPercent?.(r.completed / r.size * 100);
157
173
  }
158
174
  }
159
175
  );
@@ -164,9 +180,45 @@ async function prepareData(req, policy, opts) {
164
180
  if (prepNorm === "aborted") {
165
181
  throw new QTSCanceledError("Data preparation aborted");
166
182
  }
167
- opts.onProgress?.({ stage: "preparing", percent: 100, coverageRatio: state.coverageRatio });
183
+ run.onPrepared?.(state);
168
184
  return prepareJobId;
169
185
  }
186
+
187
+ // src/workflows/backtest.ts
188
+ var DEFAULT_POLL_INTERVAL_MS = 500;
189
+ var DEFAULT_MAX_POLL_INTERVAL_MS = 5e3;
190
+ async function backtest(req, opts = {}) {
191
+ const policy = buildStagePolicy(opts, DEFAULT_POLL_INTERVAL_MS, DEFAULT_MAX_POLL_INTERVAL_MS);
192
+ opts.onProgress?.({ stage: "compiling" });
193
+ const strategyId = await compileStrategySource(req.strategy, opts.signal);
194
+ opts.onProgress?.({ stage: "preparing" });
195
+ const prepareJobId = await prepareData(req, policy, opts);
196
+ opts.onProgress?.({ stage: "executing" });
197
+ return executeStrategy(req, prepareJobId, strategyId, policy, opts);
198
+ }
199
+ function prepareData(req, policy, opts) {
200
+ return prepareDataset(
201
+ {
202
+ exchangeId: req.exchangeId,
203
+ instrument: req.instrument,
204
+ from: req.from,
205
+ to: req.to
206
+ },
207
+ policy,
208
+ {
209
+ ...opts.signal ? { signal: opts.signal } : {},
210
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
211
+ onPercent: (percent) => opts.onProgress?.({ stage: "preparing", percent }),
212
+ // Surface the backend's coverage ratio for the prepared window (spec 0.98.0) on the
213
+ // final preparing event, so callers can react to a partially-covered range.
214
+ onPrepared: (state) => opts.onProgress?.({
215
+ stage: "preparing",
216
+ percent: 100,
217
+ coverageRatio: state.coverageRatio
218
+ })
219
+ }
220
+ );
221
+ }
170
222
  async function executeStrategy(req, prepareJobId, strategyId, policy, opts) {
171
223
  const { data, error } = await executeBacktest({
172
224
  path: { exchangeId: req.exchangeId, type: TICKER },
@@ -183,7 +235,6 @@ async function executeStrategy(req, prepareJobId, strategyId, policy, opts) {
183
235
  try {
184
236
  const finalResult = await runStage(
185
237
  policy,
186
- opts,
187
238
  async ({ signal }) => {
188
239
  const res = await getBacktestResult({
189
240
  path: { exchangeId: req.exchangeId, type: TICKER, jobId: executeJobId },
@@ -193,9 +244,13 @@ async function executeStrategy(req, prepareJobId, strategyId, policy, opts) {
193
244
  if (!res.data) throw new QTSExecutionError("Empty execution result response");
194
245
  return { ...res.data.state, __result: res.data.results };
195
246
  },
196
- (r) => {
197
- if (r.size > 0) {
198
- opts.onProgress?.({ stage: "executing", percent: r.completed / r.size * 100 });
247
+ {
248
+ ...opts.signal ? { signal: opts.signal } : {},
249
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
250
+ onEachAttempt: (r) => {
251
+ if (r.size > 0) {
252
+ opts.onProgress?.({ stage: "executing", percent: r.completed / r.size * 100 });
253
+ }
199
254
  }
200
255
  }
201
256
  );
@@ -216,24 +271,43 @@ async function executeStrategy(req, prepareJobId, strategyId, policy, opts) {
216
271
  throw err;
217
272
  }
218
273
  }
219
- async function runStage(policy, opts, fetchFn, onEachAttempt) {
220
- try {
221
- return await policy.execute(async (ctx) => {
222
- if (opts.signal?.aborted) throw new QTSCanceledError("Workflow aborted");
223
- const result = await fetchFn(ctx);
224
- onEachAttempt?.(result);
225
- if (opts.signal?.aborted) throw new QTSCanceledError("Workflow aborted");
226
- return result;
227
- }, opts.signal);
228
- } catch (err) {
229
- if (err instanceof QTSCanceledError) throw err;
230
- if (err instanceof TaskCancelledError) {
231
- if (opts.signal?.aborted) throw new QTSCanceledError("Workflow aborted", err);
232
- throw new QTSTimeoutError(`Stage exceeded ${opts.timeoutMs}ms`, err);
233
- }
234
- if (opts.signal?.aborted) throw new QTSCanceledError("Workflow aborted", err);
235
- throw err;
274
+
275
+ // src/workflows/catalog.ts
276
+ import {
277
+ listExchanges as apiListExchanges,
278
+ listInstruments as apiListInstruments,
279
+ listSegmentInstruments as apiListSegmentInstruments
280
+ } from "@qtsurfer/api-client";
281
+
282
+ // src/internal/requestError.ts
283
+ function requestFailed(what, error, status) {
284
+ const prefix = status === void 0 ? "" : `HTTP ${status} \u2014 `;
285
+ return new QTSError(`${what} failed: ${prefix}${describe(error)}`, error, status);
286
+ }
287
+ function describe(error) {
288
+ if (error && typeof error === "object") {
289
+ const e = error;
290
+ const code = typeof e.code === "string" || typeof e.code === "number" ? e.code : void 0;
291
+ const message = typeof e.message === "string" ? e.message : void 0;
292
+ if (code !== void 0 && message) return `${code}: ${message}`;
293
+ if (message) return message;
294
+ if (code !== void 0) return String(code);
236
295
  }
296
+ return String(error);
297
+ }
298
+
299
+ // src/workflows/catalog.ts
300
+ async function listExchanges() {
301
+ const { data, error, response } = await apiListExchanges();
302
+ if (error) throw requestFailed("exchanges call", error, response?.status);
303
+ if (!data) throw new QTSError("Empty exchanges response");
304
+ return data;
305
+ }
306
+ async function listInstruments(exchangeId, segment) {
307
+ const { data, error, response } = segment ? await apiListSegmentInstruments({ path: { exchangeId, segment } }) : await apiListInstruments({ path: { exchangeId } });
308
+ if (error) throw requestFailed("instruments call", error, response?.status);
309
+ if (!data) throw new QTSError("Empty instruments response");
310
+ return data.data;
237
311
  }
238
312
 
239
313
  // src/workflows/downloads.ts
@@ -249,7 +323,7 @@ async function downloadTickers(params) {
249
323
  });
250
324
  if (error) {
251
325
  throw new QTSDownloadError(
252
- `tickers download failed: HTTP ${response.status} \u2014 ${describe(error)}`,
326
+ `tickers download failed: HTTP ${response.status} \u2014 ${describe2(error)}`,
253
327
  error,
254
328
  response.status
255
329
  );
@@ -264,14 +338,14 @@ async function downloadKlines(params) {
264
338
  });
265
339
  if (error) {
266
340
  throw new QTSDownloadError(
267
- `klines download failed: HTTP ${response.status} \u2014 ${describe(error)}`,
341
+ `klines download failed: HTTP ${response.status} \u2014 ${describe2(error)}`,
268
342
  error,
269
343
  response.status
270
344
  );
271
345
  }
272
346
  return data;
273
347
  }
274
- function describe(error) {
348
+ function describe2(error) {
275
349
  if (error && typeof error === "object") {
276
350
  const e = error;
277
351
  const code = typeof e.code === "string" ? e.code : void 0;
@@ -283,6 +357,206 @@ function describe(error) {
283
357
  return String(error);
284
358
  }
285
359
 
360
+ // src/workflows/strategies.ts
361
+ import {
362
+ getStrategy as apiGetStrategy,
363
+ validateStrategy as apiValidateStrategy
364
+ } from "@qtsurfer/api-client";
365
+ async function validateStrategy(strategyId) {
366
+ const { data, error, response } = await apiValidateStrategy({ path: { strategyId } });
367
+ if (error) throw requestFailed("strategy validation request", error, response?.status);
368
+ if (response?.status === 202) return { queued: true, strategyId };
369
+ if (!data) throw new QTSError("Empty strategy validation response");
370
+ return { queued: false, strategyId, state: data };
371
+ }
372
+ async function getStrategy(strategyId) {
373
+ const { data, error, response } = await apiGetStrategy({ path: { strategyId } });
374
+ if (error) throw requestFailed("strategy lookup", error, response?.status);
375
+ if (!data) throw new QTSError("Empty strategy response");
376
+ return data;
377
+ }
378
+
379
+ // src/workflows/sweep.ts
380
+ import {
381
+ cancelSweep,
382
+ executeSweep,
383
+ getSweepResult,
384
+ getSweepSensitivity
385
+ } from "@qtsurfer/api-client";
386
+ var DEFAULT_POLL_INTERVAL_MS2 = 2e3;
387
+ var DEFAULT_MAX_POLL_INTERVAL_MS2 = 15e3;
388
+ async function sweep(req, opts = {}) {
389
+ validateRequest(req);
390
+ const policy = buildStagePolicy(opts, DEFAULT_POLL_INTERVAL_MS2, DEFAULT_MAX_POLL_INTERVAL_MS2);
391
+ opts.onProgress?.({ stage: "compiling" });
392
+ const strategyId = await compileStrategySource(req.strategy, opts.signal);
393
+ opts.onProgress?.({ stage: "preparing" });
394
+ const requestId = await prepareData2(req, policy, opts);
395
+ opts.onProgress?.({ stage: "executing" });
396
+ const { data, error } = await executeSweep({
397
+ path: { exchangeId: req.exchangeId, type: TICKER, requestId },
398
+ body: buildSweepBody(req, strategyId),
399
+ ...opts.signal ? { signal: opts.signal } : {}
400
+ });
401
+ if (error) throw new QTSExecutionError("Sweep submission failed", error);
402
+ if (!data?.sweepId) throw new QTSExecutionError("Missing sweepId in executeSweep response");
403
+ return createHandle(req, opts, policy, data, requestId, strategyId);
404
+ }
405
+ function validateRequest(req) {
406
+ const names = Object.keys(req.params ?? {});
407
+ if (names.length === 0) {
408
+ throw new QTSError("sweep: params must hold at least one axis");
409
+ }
410
+ for (const name of names) {
411
+ const axis = req.params[name];
412
+ if ("values" in axis) {
413
+ if (axis.values.length === 0) {
414
+ throw new QTSError(`sweep: axis "${name}" must hold at least one value`);
415
+ }
416
+ } else if (!(axis.step > 0)) {
417
+ throw new QTSError(`sweep: axis "${name}" needs step > 0, got ${axis.step}`);
418
+ }
419
+ }
420
+ const wf = req.walkForward;
421
+ if (!wf) return;
422
+ if (wf.folds < 2) {
423
+ throw new QTSError(`sweep: walkForward.folds must be >= 2, got ${wf.folds}`);
424
+ }
425
+ if (wf.inSamplePct !== void 0 && (wf.inSamplePct < 10 || wf.inSamplePct > 90)) {
426
+ throw new QTSError(
427
+ `sweep: walkForward.inSamplePct must be within 10..90, got ${wf.inSamplePct}`
428
+ );
429
+ }
430
+ }
431
+ function prepareData2(req, policy, opts) {
432
+ return prepareDataset(
433
+ {
434
+ exchangeId: req.exchangeId,
435
+ instrument: req.instrument,
436
+ from: req.from,
437
+ to: req.to
438
+ },
439
+ policy,
440
+ {
441
+ ...opts.signal ? { signal: opts.signal } : {},
442
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
443
+ onPercent: (percent) => opts.onProgress?.({ stage: "preparing", percent }),
444
+ // A thinly covered window is about to be scored once per parameter vector, so the
445
+ // coverage ratio is worth at least as much here as on a single backtest.
446
+ onPrepared: (state) => opts.onProgress?.({
447
+ stage: "preparing",
448
+ percent: 100,
449
+ coverageRatio: state.coverageRatio
450
+ })
451
+ }
452
+ );
453
+ }
454
+ function buildSweepBody(req, strategyId) {
455
+ const spec = { params: req.params };
456
+ if (req.sampler !== void 0) spec.sampler = req.sampler;
457
+ if (req.samples !== void 0) spec.samples = req.samples;
458
+ if (req.seed !== void 0) spec.seed = req.seed;
459
+ if (req.objective !== void 0) spec.objective = req.objective;
460
+ const body = { strategyId, sweep: spec };
461
+ if (req.walkForward) {
462
+ body.walkForward = {
463
+ folds: req.walkForward.folds,
464
+ ...req.walkForward.inSamplePct !== void 0 ? { inSamplePct: req.walkForward.inSamplePct } : {}
465
+ };
466
+ }
467
+ return body;
468
+ }
469
+ function createHandle(req, opts, policy, accepted, requestId, strategyId) {
470
+ const sweepId = accepted.sweepId;
471
+ const path = { exchangeId: req.exchangeId, type: TICKER, requestId, sweepId };
472
+ let state = "executing";
473
+ const withQuery = viewQuery(opts);
474
+ const result = (async () => {
475
+ try {
476
+ const finalResult = await runStage(
477
+ policy,
478
+ async ({ signal: signal2 }) => {
479
+ const res = await getSweepResult({ path, ...withQuery, signal: signal2 });
480
+ if (res.error) throw new QTSExecutionError("Sweep result request failed", res.error);
481
+ if (!res.data) throw new QTSExecutionError("Empty sweep result response");
482
+ return res.data;
483
+ },
484
+ {
485
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
486
+ onEachAttempt: (r) => {
487
+ const percent = percentOf(r);
488
+ opts.onProgress?.({
489
+ stage: "executing",
490
+ ...percent !== void 0 ? { percent } : {},
491
+ snapshot: r.progress
492
+ });
493
+ }
494
+ }
495
+ );
496
+ state = normalizeStatus(finalResult.status) === "aborted" ? "canceled" : "completed";
497
+ return finalResult;
498
+ } catch (err) {
499
+ state = err instanceof QTSCanceledError ? "canceled" : "failed";
500
+ throw err;
501
+ }
502
+ })();
503
+ void result.catch(() => void 0);
504
+ const { signal } = opts;
505
+ if (signal) {
506
+ const requestCancel = () => {
507
+ if (state === "executing") state = "canceled";
508
+ void cancelSweep({ path }).catch(() => void 0);
509
+ };
510
+ if (signal.aborted) {
511
+ requestCancel();
512
+ } else {
513
+ signal.addEventListener("abort", requestCancel, { once: true });
514
+ const detach = () => signal.removeEventListener("abort", requestCancel);
515
+ void result.then(detach, detach);
516
+ }
517
+ }
518
+ return {
519
+ sweepId,
520
+ requestId,
521
+ strategyId,
522
+ accepted,
523
+ get state() {
524
+ return state;
525
+ },
526
+ result,
527
+ results: async (view = {}) => {
528
+ const res = await getSweepResult({ path, ...viewQuery(view) });
529
+ if (res.error) {
530
+ throw requestFailed("sweep results call", res.error, res.response?.status);
531
+ }
532
+ if (!res.data) throw new QTSError("Empty sweep result response");
533
+ return res.data;
534
+ },
535
+ sensitivity: async (objective) => {
536
+ const res = await getSweepSensitivity({
537
+ path,
538
+ ...objective !== void 0 ? { query: { objective } } : {}
539
+ });
540
+ if (res.error) {
541
+ throw requestFailed("sweep sensitivity call", res.error, res.response?.status);
542
+ }
543
+ if (!res.data) throw new QTSError("Empty sweep sensitivity response");
544
+ return res.data;
545
+ }
546
+ };
547
+ }
548
+ function viewQuery(view) {
549
+ const query = {};
550
+ if (view.order !== void 0) query.order = view.order;
551
+ if (view.ranking !== void 0) query.ranking = view.ranking;
552
+ return Object.keys(query).length > 0 ? { query } : {};
553
+ }
554
+ function percentOf(result) {
555
+ const p = result.progress;
556
+ if (!p || !(p.total > 0)) return void 0;
557
+ return p.done / p.total * 100;
558
+ }
559
+
286
560
  // src/client.ts
287
561
  var QTSurfer = class {
288
562
  constructor(options) {
@@ -301,6 +575,47 @@ var QTSurfer = class {
301
575
  backtest(req, opts) {
302
576
  return backtest(req, opts);
303
577
  }
578
+ /**
579
+ * Run the full compile → prepare → executeSweep pipeline and resolve once the
580
+ * platform has accepted the sweep, handing back a {@link Sweep} that keeps
581
+ * polling the leaderboard in the background.
582
+ *
583
+ * The whole sweep is one call because the execute-sweep endpoint is addressed
584
+ * by the id of an already-prepared dataset: exposing the stages separately
585
+ * would hand dataset lifecycle to the caller and buy nothing. Preparing is
586
+ * idempotent, so sweeping the same window twice prepares it once.
587
+ *
588
+ * The returned promise rejects with {@link QTSStrategyCompileError} if
589
+ * compilation fails, {@link QTSPreparationError} if data preparation fails,
590
+ * {@link QTSExecutionError} if the platform rejects the sweep — an expanded
591
+ * grid over the server limit, or a walk-forward request whose fold count
592
+ * multiplies past the sweep budget, both answer `400` — {@link QTSTimeoutError}
593
+ * if a stage exceeds `timeoutMs`, or {@link QTSCanceledError} if the caller's
594
+ * signal fires before the sweep is accepted. A plain {@link QTSError} means
595
+ * the request itself is malformed (an empty grid, a non-positive `step`, a
596
+ * walk-forward block with fewer than two folds) and never reached the network.
597
+ *
598
+ * What the sweep *found* arrives through {@link Sweep.result}, which is also
599
+ * where the semantics of the leaderboard are documented. Acceptance already
600
+ * answers three things worth reading before any result exists — the effective
601
+ * seed, whether this submission enqueued anything, and whether this is a
602
+ * walk-forward sweep — see {@link Sweep.accepted}.
603
+ *
604
+ * ```ts
605
+ * const handle = await qts.sweep({
606
+ * strategy: source,
607
+ * exchangeId: 'binance',
608
+ * instrument: 'BTC/USDT',
609
+ * from: '2026-01-01T00:00:00Z',
610
+ * to: '2026-02-01T00:00:00Z',
611
+ * params: { rsiPeriod: { from: 7, to: 28, step: 1 } },
612
+ * });
613
+ * const leaderboard = await handle.result;
614
+ * ```
615
+ */
616
+ sweep(req, opts) {
617
+ return sweep(req, opts);
618
+ }
304
619
  /**
305
620
  * Download one hour of raw tickers for an instrument as a {@link Blob}.
306
621
  * Defaults to Lastra; pass `{ format: 'parquet' }` for Parquet.
@@ -312,9 +627,89 @@ var QTSurfer = class {
312
627
  klines(args) {
313
628
  return downloadKlines(args);
314
629
  }
630
+ /**
631
+ * List the exchanges the platform serves. Each `id` is what every other
632
+ * method takes as `exchangeId`.
633
+ *
634
+ * @throws QTSError on any non-2xx response, with the HTTP status on
635
+ * `status`.
636
+ */
637
+ exchanges() {
638
+ return listExchanges();
639
+ }
640
+ /**
641
+ * List an exchange's instruments, each with the per-data-type `coverage`
642
+ * that says which date windows are actually downloadable.
643
+ *
644
+ * Omitting `segment` asks for the exchange's **default** segment, which is
645
+ * `'spot'` today. The API answers with a HAL envelope that this method
646
+ * unwraps to the instrument array, so the envelope's `meta.segment`,
647
+ * `meta.updatedAt` and segment-discovery `_links` do not reach you: if you
648
+ * need certainty about which segment you are looking at, pass `segment`
649
+ * explicitly rather than relying on the default.
650
+ *
651
+ * @param exchangeId exchange identifier, e.g. `binance`
652
+ * @throws QTSError on any non-2xx response, with the HTTP status on
653
+ * `status`.
654
+ */
655
+ instruments(exchangeId, segment) {
656
+ return listInstruments(exchangeId, segment);
657
+ }
658
+ /**
659
+ * Ask the platform to check that a registered strategy can actually run:
660
+ * it instantiates the compiled class and drives it through a bounded
661
+ * synthetic series, so a wiring fault surfaces here instead of at the first
662
+ * backtest.
663
+ *
664
+ * **Idempotent, and two-outcome.** `queued: false` means a verdict already
665
+ * existed for the current compilation and came back unchanged in `state` —
666
+ * nothing was queued. `queued: true` means a check was just started, and is
667
+ * **not** terminal: poll {@link QTSurfer.strategy} until `validation`
668
+ * leaves `'pending'`. The discriminant reports whether work was *started*,
669
+ * not whether a verdict *exists*, because a `queued: false` answer can
670
+ * itself carry `validation: 'pending'` from a check an earlier call queued;
671
+ * `state.validation` is what tells you that.
672
+ *
673
+ * **Poll with a deadline of your own.** `'pending'` is not guaranteed to
674
+ * resolve — a queued check can go unreported for far longer than one takes,
675
+ * which the platform eventually flags as `validationStalled`. Nothing about
676
+ * the strategy is disproved when that happens, but a caller that waits for
677
+ * a terminal verdict without a timeout can wait forever. This SDK ships no
678
+ * polling helper for that reason: the timeout is the caller's policy.
679
+ *
680
+ * Whatever the verdict, it is a floor rather than a guarantee — see
681
+ * {@link StrategyState}.
682
+ *
683
+ * @param strategyId the id returned when the strategy was compiled
684
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
685
+ * means no such registered strategy for this caller.
686
+ */
687
+ validateStrategy(strategyId) {
688
+ return validateStrategy(strategyId);
689
+ }
690
+ /**
691
+ * Read everything the platform records about a strategy: whether it is
692
+ * registered at all, its validation verdict, the market data its compiled
693
+ * class requires, and any engine notices the check raised. This is what to
694
+ * poll after {@link QTSurfer.validateStrategy} returns `queued: true`, and
695
+ * the only place a verdict is read from.
696
+ *
697
+ * Check `compiledAt` against `validatedAt` before trusting a verdict: the
698
+ * strategy may have been recompiled since it was recorded, in which case
699
+ * the verdict describes bytecode that is no longer what would run.
700
+ * See {@link StrategyState} for why even a fresh `'passed'` is a floor
701
+ * rather than a guarantee.
702
+ *
703
+ * @param strategyId the id returned when the strategy was compiled
704
+ * @throws QTSError on any non-2xx response. A `404` (carried on `status`)
705
+ * means exactly one thing — no such registered strategy for this caller.
706
+ * It is never a stale or expired answer.
707
+ */
708
+ strategy(strategyId) {
709
+ return getStrategy(strategyId);
710
+ }
315
711
  // Future surface:
316
- // strategies: { compile, status, list }
317
- // instruments: { list, get } with TTL cache
712
+ // TTL cache for exchanges / instruments
318
713
  // jobs: { cancel, stream, result }
319
714
  };
320
715
 
@@ -437,6 +832,27 @@ var AuthenticatedClient = class {
437
832
  backtest(req, opts) {
438
833
  return this.withRefreshOn401(() => backtest(req, opts));
439
834
  }
835
+ /**
836
+ * Run the full compile → prepare → executeSweep pipeline and resolve once the
837
+ * platform has accepted the sweep, handing back a {@link Sweep} that keeps
838
+ * polling the leaderboard in the background. See {@link QTSurfer.sweep} for
839
+ * why the sweep is one call rather than composable stages, and
840
+ * {@link Sweep.result} for how to read what it found.
841
+ *
842
+ * The currently cached token is sent (minting one first if none is cached),
843
+ * but as with `backtest()` a `401` here is **not** auto-retried: the
844
+ * underlying stage errors carry no HTTP status, so a token that expires
845
+ * mid-pipeline surfaces as `QTSPreparationError`/`QTSExecutionError` rather
846
+ * than triggering a refresh.
847
+ *
848
+ * The background leaderboard poll and {@link Sweep.sensitivity} sit outside
849
+ * the policy for a second, independent reason: both run after this promise
850
+ * has already resolved, so a token that expires while a sweep is in flight
851
+ * surfaces on {@link Sweep.result} whatever the stage errors carry.
852
+ */
853
+ sweep(req, opts) {
854
+ return this.withRefreshOn401(() => sweep(req, opts));
855
+ }
440
856
  /** Download one hour of raw tickers. Refreshes the token once on `401` before retrying. */
441
857
  tickers(args) {
442
858
  return this.withRefreshOn401(() => downloadTickers(args));
@@ -445,6 +861,41 @@ var AuthenticatedClient = class {
445
861
  klines(args) {
446
862
  return this.withRefreshOn401(() => downloadKlines(args));
447
863
  }
864
+ /**
865
+ * List the exchanges the platform serves. Refreshes the token once on `401`
866
+ * before retrying.
867
+ */
868
+ exchanges() {
869
+ return this.withRefreshOn401(() => listExchanges());
870
+ }
871
+ /**
872
+ * List an exchange's instruments, optionally for a specific segment.
873
+ * Refreshes the token once on `401` before retrying. See
874
+ * {@link QTSurfer.instruments} for what the unwrapped HAL envelope leaves
875
+ * out.
876
+ */
877
+ instruments(exchangeId, segment) {
878
+ return this.withRefreshOn401(() => listInstruments(exchangeId, segment));
879
+ }
880
+ /**
881
+ * Ask the platform to check that a registered strategy can actually run.
882
+ * Refreshes the token once on `401` before retrying. Two-outcome — see
883
+ * {@link QTSurfer.validateStrategy}; `queued: true` is not terminal and
884
+ * must be followed by polling {@link AuthenticatedClient.strategy} under a
885
+ * deadline of your own.
886
+ */
887
+ validateStrategy(strategyId) {
888
+ return this.withRefreshOn401(() => validateStrategy(strategyId));
889
+ }
890
+ /**
891
+ * Read a strategy's recorded state, including its validation verdict.
892
+ * Refreshes the token once on `401` before retrying. See
893
+ * {@link StrategyState} for why a `'passed'` verdict is a floor rather than
894
+ * a guarantee.
895
+ */
896
+ strategy(strategyId) {
897
+ return this.withRefreshOn401(() => getStrategy(strategyId));
898
+ }
448
899
  };
449
900
  async function authenticate(apikey, opts = {}) {
450
901
  const resolved = apikey ?? readEnvApikey();