@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/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/
|
|
79
|
-
|
|
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
|
-
|
|
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 ??
|
|
108
|
-
maxDelay: opts.maxPollIntervalMs ??
|
|
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
|
|
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
|
-
...
|
|
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
|
|
148
|
+
async function prepareDataset(target, policy, run = {}) {
|
|
134
149
|
const { data, error } = await prepareBacktest({
|
|
135
|
-
path: { exchangeId:
|
|
136
|
-
body: { instrument:
|
|
137
|
-
...
|
|
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:
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
-
//
|
|
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();
|