@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/src/client.ts CHANGED
@@ -5,11 +5,30 @@ import {
5
5
  type BacktestRequest,
6
6
  type BacktestResult,
7
7
  } from './workflows/backtest';
8
+ import {
9
+ listExchanges,
10
+ listInstruments,
11
+ type Exchange,
12
+ type InstrumentDetail,
13
+ type InstrumentSegment,
14
+ } from './workflows/catalog';
8
15
  import {
9
16
  downloadKlines,
10
17
  downloadTickers,
11
18
  type DownloadFormat,
12
19
  } from './workflows/downloads';
20
+ import {
21
+ getStrategy,
22
+ validateStrategy as runValidateStrategy,
23
+ type StrategyState,
24
+ type StrategyValidation,
25
+ } from './workflows/strategies';
26
+ import {
27
+ sweep as runSweep,
28
+ type Sweep,
29
+ type SweepOptions,
30
+ type SweepRequest,
31
+ } from './workflows/sweep';
13
32
 
14
33
  /** Configuration for {@link QTSurfer}. */
15
34
  export interface QTSurferOptions {
@@ -41,7 +60,9 @@ export interface DownloadHourArgs {
41
60
 
42
61
  /**
43
62
  * Thin, stateless wrapper over `@qtsurfer/api-client` that exposes the SDK's
44
- * workflow methods (`backtest`, `tickers`, `klines`). Constructing an
63
+ * workflow methods (`backtest`, `sweep`, `tickers`, `klines`), the platform catalog
64
+ * (`exchanges`, `instruments`) and the strategy surface (`validateStrategy`,
65
+ * `strategy`). Constructing an
45
66
  * instance reconfigures the underlying api-client singleton, so avoid
46
67
  * holding two `QTSurfer`s with different `baseUrl`s or tokens alive in the
47
68
  * same process — they will race. Prefer the `authenticate()` helper over
@@ -68,6 +89,48 @@ export class QTSurfer {
68
89
  return backtest(req, opts);
69
90
  }
70
91
 
92
+ /**
93
+ * Run the full compile → prepare → executeSweep pipeline and resolve once the
94
+ * platform has accepted the sweep, handing back a {@link Sweep} that keeps
95
+ * polling the leaderboard in the background.
96
+ *
97
+ * The whole sweep is one call because the execute-sweep endpoint is addressed
98
+ * by the id of an already-prepared dataset: exposing the stages separately
99
+ * would hand dataset lifecycle to the caller and buy nothing. Preparing is
100
+ * idempotent, so sweeping the same window twice prepares it once.
101
+ *
102
+ * The returned promise rejects with {@link QTSStrategyCompileError} if
103
+ * compilation fails, {@link QTSPreparationError} if data preparation fails,
104
+ * {@link QTSExecutionError} if the platform rejects the sweep — an expanded
105
+ * grid over the server limit, or a walk-forward request whose fold count
106
+ * multiplies past the sweep budget, both answer `400` — {@link QTSTimeoutError}
107
+ * if a stage exceeds `timeoutMs`, or {@link QTSCanceledError} if the caller's
108
+ * signal fires before the sweep is accepted. A plain {@link QTSError} means
109
+ * the request itself is malformed (an empty grid, a non-positive `step`, a
110
+ * walk-forward block with fewer than two folds) and never reached the network.
111
+ *
112
+ * What the sweep *found* arrives through {@link Sweep.result}, which is also
113
+ * where the semantics of the leaderboard are documented. Acceptance already
114
+ * answers three things worth reading before any result exists — the effective
115
+ * seed, whether this submission enqueued anything, and whether this is a
116
+ * walk-forward sweep — see {@link Sweep.accepted}.
117
+ *
118
+ * ```ts
119
+ * const handle = await qts.sweep({
120
+ * strategy: source,
121
+ * exchangeId: 'binance',
122
+ * instrument: 'BTC/USDT',
123
+ * from: '2026-01-01T00:00:00Z',
124
+ * to: '2026-02-01T00:00:00Z',
125
+ * params: { rsiPeriod: { from: 7, to: 28, step: 1 } },
126
+ * });
127
+ * const leaderboard = await handle.result;
128
+ * ```
129
+ */
130
+ sweep(req: SweepRequest, opts?: SweepOptions): Promise<Sweep> {
131
+ return runSweep(req, opts);
132
+ }
133
+
71
134
  /**
72
135
  * Download one hour of raw tickers for an instrument as a {@link Blob}.
73
136
  * Defaults to Lastra; pass `{ format: 'parquet' }` for Parquet.
@@ -81,8 +144,95 @@ export class QTSurfer {
81
144
  return downloadKlines(args);
82
145
  }
83
146
 
147
+ /**
148
+ * List the exchanges the platform serves. Each `id` is what every other
149
+ * method takes as `exchangeId`.
150
+ *
151
+ * @throws QTSError on any non-2xx response, with the HTTP status on
152
+ * `status`.
153
+ */
154
+ exchanges(): Promise<Exchange[]> {
155
+ return listExchanges();
156
+ }
157
+
158
+ /**
159
+ * List an exchange's instruments, each with the per-data-type `coverage`
160
+ * that says which date windows are actually downloadable.
161
+ *
162
+ * Omitting `segment` asks for the exchange's **default** segment, which is
163
+ * `'spot'` today. The API answers with a HAL envelope that this method
164
+ * unwraps to the instrument array, so the envelope's `meta.segment`,
165
+ * `meta.updatedAt` and segment-discovery `_links` do not reach you: if you
166
+ * need certainty about which segment you are looking at, pass `segment`
167
+ * explicitly rather than relying on the default.
168
+ *
169
+ * @param exchangeId exchange identifier, e.g. `binance`
170
+ * @throws QTSError on any non-2xx response, with the HTTP status on
171
+ * `status`.
172
+ */
173
+ instruments(
174
+ exchangeId: string,
175
+ segment?: InstrumentSegment,
176
+ ): Promise<InstrumentDetail[]> {
177
+ return listInstruments(exchangeId, segment);
178
+ }
179
+
180
+ /**
181
+ * Ask the platform to check that a registered strategy can actually run:
182
+ * it instantiates the compiled class and drives it through a bounded
183
+ * synthetic series, so a wiring fault surfaces here instead of at the first
184
+ * backtest.
185
+ *
186
+ * **Idempotent, and two-outcome.** `queued: false` means a verdict already
187
+ * existed for the current compilation and came back unchanged in `state` —
188
+ * nothing was queued. `queued: true` means a check was just started, and is
189
+ * **not** terminal: poll {@link QTSurfer.strategy} until `validation`
190
+ * leaves `'pending'`. The discriminant reports whether work was *started*,
191
+ * not whether a verdict *exists*, because a `queued: false` answer can
192
+ * itself carry `validation: 'pending'` from a check an earlier call queued;
193
+ * `state.validation` is what tells you that.
194
+ *
195
+ * **Poll with a deadline of your own.** `'pending'` is not guaranteed to
196
+ * resolve — a queued check can go unreported for far longer than one takes,
197
+ * which the platform eventually flags as `validationStalled`. Nothing about
198
+ * the strategy is disproved when that happens, but a caller that waits for
199
+ * a terminal verdict without a timeout can wait forever. This SDK ships no
200
+ * polling helper for that reason: the timeout is the caller's policy.
201
+ *
202
+ * Whatever the verdict, it is a floor rather than a guarantee — see
203
+ * {@link StrategyState}.
204
+ *
205
+ * @param strategyId the id returned when the strategy was compiled
206
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
207
+ * means no such registered strategy for this caller.
208
+ */
209
+ validateStrategy(strategyId: string): Promise<StrategyValidation> {
210
+ return runValidateStrategy(strategyId);
211
+ }
212
+
213
+ /**
214
+ * Read everything the platform records about a strategy: whether it is
215
+ * registered at all, its validation verdict, the market data its compiled
216
+ * class requires, and any engine notices the check raised. This is what to
217
+ * poll after {@link QTSurfer.validateStrategy} returns `queued: true`, and
218
+ * the only place a verdict is read from.
219
+ *
220
+ * Check `compiledAt` against `validatedAt` before trusting a verdict: the
221
+ * strategy may have been recompiled since it was recorded, in which case
222
+ * the verdict describes bytecode that is no longer what would run.
223
+ * See {@link StrategyState} for why even a fresh `'passed'` is a floor
224
+ * rather than a guarantee.
225
+ *
226
+ * @param strategyId the id returned when the strategy was compiled
227
+ * @throws QTSError on any non-2xx response. A `404` (carried on `status`)
228
+ * means exactly one thing — no such registered strategy for this caller.
229
+ * It is never a stale or expired answer.
230
+ */
231
+ strategy(strategyId: string): Promise<StrategyState> {
232
+ return getStrategy(strategyId);
233
+ }
234
+
84
235
  // Future surface:
85
- // strategies: { compile, status, list }
86
- // instruments: { list, get } with TTL cache
236
+ // TTL cache for exchanges / instruments
87
237
  // jobs: { cancel, stream, result }
88
238
  }
package/src/errors.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  * Base class for every error the SDK throws. Catch this to handle all SDK
3
3
  * failures generically, or catch a specific subclass below to tell which
4
4
  * stage failed. `status` is only set when the throw site had an HTTP status
5
- * to attach — today that is just {@link QTSDownloadError}; the workflow-stage
6
- * errors carry `cause` instead and encode retryability in their message.
5
+ * to attach: {@link QTSDownloadError} always carries one, and so does the
6
+ * plain `QTSError` thrown by the single-request calls (`exchanges`,
7
+ * `instruments`, `validateStrategy`, `strategy`). The workflow-stage errors
8
+ * carry `cause` instead and encode retryability in their message.
7
9
  */
8
10
  export class QTSError extends Error {
9
11
  /** HTTP status code, when the underlying transport surfaced one. */
package/src/index.ts CHANGED
@@ -22,7 +22,40 @@ export type {
22
22
  BacktestStage,
23
23
  BacktestOptions,
24
24
  } from './workflows/backtest';
25
+ export type {
26
+ ParamAxis,
27
+ Sweep,
28
+ SweepAccepted,
29
+ SweepHeatmap,
30
+ SweepHeatmapCell,
31
+ SweepMarginal,
32
+ SweepMarginalPoint,
33
+ SweepObjective,
34
+ SweepOptions,
35
+ SweepOrder,
36
+ SweepProgress,
37
+ SweepProgressEvent,
38
+ SweepRanking,
39
+ SweepRequest,
40
+ SweepResult,
41
+ SweepRunRow,
42
+ SweepSampler,
43
+ SweepSensitivity,
44
+ SweepState,
45
+ SweepWalkForward,
46
+ WalkForwardFold,
47
+ WalkForwardResult,
48
+ } from './workflows/sweep';
25
49
  export type { DownloadFormat } from './workflows/downloads';
50
+ export type {
51
+ Exchange,
52
+ InstrumentDetail,
53
+ InstrumentSegment,
54
+ } from './workflows/catalog';
55
+ export type {
56
+ StrategyState,
57
+ StrategyValidation,
58
+ } from './workflows/strategies';
26
59
  export {
27
60
  authenticate,
28
61
  AuthenticatedClient,
@@ -0,0 +1,142 @@
1
+ import {
2
+ ExponentialBackoff,
3
+ TaskCancelledError,
4
+ TimeoutStrategy,
5
+ handleWhenResult,
6
+ retry,
7
+ timeout,
8
+ wrap,
9
+ type ICancellationContext,
10
+ type IPolicy,
11
+ } from 'cockatiel';
12
+ import { QTSCanceledError, QTSTimeoutError } from '../errors';
13
+
14
+ /**
15
+ * The stable form of a backend job/sweep status, so the rest of the SDK can
16
+ * reason about it regardless of OpenAPI spec drift (the live API sometimes
17
+ * returns lowercase values like `queued` / `completed` / `failed`).
18
+ *
19
+ * @internal
20
+ */
21
+ export type NormalizedStatus = 'in-progress' | 'completed' | 'failed' | 'aborted';
22
+
23
+ /**
24
+ * Normalize a raw status value.
25
+ *
26
+ * Only the terminal statuses end a poll loop. Everything else — including a
27
+ * **missing** status — means "keep asking": the API answers `202` with an empty body when a
28
+ * job is known but its result is not readable yet, and that response carries no state at all.
29
+ * Mapping absent to in-progress is what makes a 202 continue the loop under its timeout
30
+ * instead of being mistaken for a finished job with no data.
31
+ *
32
+ * @internal
33
+ */
34
+ export function normalizeStatus(raw: unknown): NormalizedStatus {
35
+ const value = typeof raw === 'string' ? raw.toLowerCase() : '';
36
+ if (value === 'completed') return 'completed';
37
+ // A sweep that finishes with at least one shard dead reports `partial`, and that is
38
+ // terminal: the rows it did produce are readable and nothing more is coming, so treating it
39
+ // as in-progress would poll a finished sweep forever. `PARTIAL` exists only on the two sweep
40
+ // schemas (`ExecuteSweepResult.status` and `SweepSensitivity.status`) and is absent from
41
+ // `JobState`, so mapping it here cannot reach the prepare or execute paths. It folds into
42
+ // `completed` because this enum drives the poll loop — stop asking, hand the response back —
43
+ // and the caller reads `partial` off the response itself, where the distinction survives.
44
+ if (value === 'partial') return 'completed';
45
+ if (value === 'failed') return 'failed';
46
+ if (value === 'aborted' || value === 'cancelled' || value === 'canceled') {
47
+ return 'aborted';
48
+ }
49
+ // new / started / queued / running / absent (202) / anything else → still running
50
+ return 'in-progress';
51
+ }
52
+
53
+ /** The retry-with-backoff policy one workflow stage polls under. @internal */
54
+ export type StagePolicy = IPolicy<ICancellationContext, never>;
55
+
56
+ /** Poll tuning shared by every workflow's options object. @internal */
57
+ export interface PollTuning {
58
+ pollIntervalMs?: number;
59
+ maxPollIntervalMs?: number;
60
+ timeoutMs?: number;
61
+ }
62
+
63
+ /**
64
+ * Build the poll policy for a workflow's stages.
65
+ *
66
+ * The defaults are a per-workflow argument rather than a constant, because how
67
+ * often it is worth asking depends on what is being watched: a single backtest
68
+ * advances tick by tick, while a sweep's leaderboard changes on the timescale
69
+ * of shards finishing.
70
+ *
71
+ * @param opts caller overrides
72
+ * @param defaultPollMs initial interval when the caller sets none
73
+ * @param defaultMaxPollMs backoff ceiling when the caller sets none
74
+ *
75
+ * @internal
76
+ */
77
+ export function buildStagePolicy(
78
+ opts: PollTuning,
79
+ defaultPollMs: number,
80
+ defaultMaxPollMs: number,
81
+ ): StagePolicy {
82
+ const retryPolicy = retry(
83
+ handleWhenResult((r) => {
84
+ const status = (r as { status?: unknown } | undefined)?.status;
85
+ return normalizeStatus(status) === 'in-progress';
86
+ }),
87
+ {
88
+ maxAttempts: Number.MAX_SAFE_INTEGER,
89
+ backoff: new ExponentialBackoff({
90
+ initialDelay: opts.pollIntervalMs ?? defaultPollMs,
91
+ maxDelay: opts.maxPollIntervalMs ?? defaultMaxPollMs,
92
+ }),
93
+ },
94
+ );
95
+
96
+ return opts.timeoutMs
97
+ ? wrap(timeout(opts.timeoutMs, TimeoutStrategy.Cooperative), retryPolicy)
98
+ : retryPolicy;
99
+ }
100
+
101
+ /** How one call to {@link runStage} is cancelled and reported. @internal */
102
+ export interface StageRun<T> {
103
+ /**
104
+ * Aborts the stage, surfacing as {@link QTSCanceledError}. Omit it to run a
105
+ * stage the caller's signal must **not** interrupt.
106
+ */
107
+ signal?: AbortSignal;
108
+ /** Only used to render the timeout message. */
109
+ timeoutMs?: number;
110
+ /** Called with every attempt's result, terminal or not. */
111
+ onEachAttempt?: (r: T) => void;
112
+ }
113
+
114
+ /**
115
+ * Poll `fetchFn` under `policy` until its result stops normalizing to
116
+ * `in-progress`, then return that result.
117
+ *
118
+ * @internal
119
+ */
120
+ export async function runStage<T>(
121
+ policy: StagePolicy,
122
+ fetchFn: (ctx: ICancellationContext) => Promise<T>,
123
+ run: StageRun<T> = {},
124
+ ): Promise<T> {
125
+ try {
126
+ return await policy.execute(async (ctx) => {
127
+ if (run.signal?.aborted) throw new QTSCanceledError('Workflow aborted');
128
+ const result = await fetchFn(ctx);
129
+ run.onEachAttempt?.(result);
130
+ if (run.signal?.aborted) throw new QTSCanceledError('Workflow aborted');
131
+ return result;
132
+ }, run.signal);
133
+ } catch (err) {
134
+ if (err instanceof QTSCanceledError) throw err;
135
+ if (err instanceof TaskCancelledError) {
136
+ if (run.signal?.aborted) throw new QTSCanceledError('Workflow aborted', err);
137
+ throw new QTSTimeoutError(`Stage exceeded ${run.timeoutMs}ms`, err);
138
+ }
139
+ if (run.signal?.aborted) throw new QTSCanceledError('Workflow aborted', err);
140
+ throw err;
141
+ }
142
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ getPrepareStatus,
3
+ compileStrategy as apiCompileStrategy,
4
+ prepareBacktest,
5
+ type DataSourceType,
6
+ type PrepareJobState,
7
+ } from '@qtsurfer/api-client';
8
+ import { QTSCanceledError, QTSPreparationError, QTSStrategyCompileError } from '../errors';
9
+ import { normalizeStatus, runStage, type StagePolicy } from './polling';
10
+
11
+ /** The only data source the workflows prepare against today. @internal */
12
+ export const TICKER: DataSourceType = 'ticker';
13
+
14
+ /**
15
+ * Compile in a single request: the API answers synchronously with the `strategyId`,
16
+ * so there is no job to poll. A compile error arrives here as a `400`, not on a later poll.
17
+ *
18
+ * @internal
19
+ */
20
+ export async function compileStrategySource(
21
+ source: string,
22
+ signal?: AbortSignal,
23
+ ): Promise<string> {
24
+ const { data, error, response } = await apiCompileStrategy({
25
+ body: source,
26
+ ...(signal ? { signal } : {}),
27
+ });
28
+
29
+ if (error) {
30
+ // A 429 means the platform is holding too many compilations at once and the source was never
31
+ // judged — worth separating from the 400 that says the source itself does not compile.
32
+ // Read the status inside this branch and optionally: a transport failure carries no response,
33
+ // and dereferencing one would raise a TypeError that buries the error actually being reported.
34
+ if (response?.status === 429) {
35
+ throw new QTSStrategyCompileError(
36
+ 'Strategy was not compiled, too many compilations in flight; retry later',
37
+ error,
38
+ );
39
+ }
40
+ throw new QTSStrategyCompileError('Strategy compilation failed', error);
41
+ }
42
+ if (!data?.strategyId) {
43
+ throw new QTSStrategyCompileError('Compile response missing strategyId');
44
+ }
45
+ return data.strategyId;
46
+ }
47
+
48
+ /** The instrument and window one prepare covers. @internal */
49
+ export interface PrepareTarget {
50
+ exchangeId: string;
51
+ instrument: string;
52
+ from: string;
53
+ to: string;
54
+ }
55
+
56
+ /** Reporting and cancellation for {@link prepareDataset}. @internal */
57
+ export interface PrepareRun {
58
+ signal?: AbortSignal;
59
+ timeoutMs?: number;
60
+ /** Called after each poll that reports size information. */
61
+ onPercent?: (percent: number) => void;
62
+ /** Called once with the terminal state, so the caller can read its coverage. */
63
+ onPrepared?: (state: PrepareJobState) => void;
64
+ }
65
+
66
+ /**
67
+ * Submit a prepare and poll it to a terminal state.
68
+ *
69
+ * One implementation on purpose. Preparing is idempotent — the same instrument
70
+ * and window always resolve to the same job — so a workflow that prepares on
71
+ * every call duplicates no work, and that argument only holds while there is a
72
+ * single place where it is true.
73
+ *
74
+ * @returns the prepare jobId, which is what identifies the prepared dataset
75
+ *
76
+ * @internal
77
+ */
78
+ export async function prepareDataset(
79
+ target: PrepareTarget,
80
+ policy: StagePolicy,
81
+ run: PrepareRun = {},
82
+ ): Promise<string> {
83
+ const { data, error } = await prepareBacktest({
84
+ path: { exchangeId: target.exchangeId, type: TICKER },
85
+ body: { instrument: target.instrument, from: target.from, to: target.to },
86
+ ...(run.signal ? { signal: run.signal } : {}),
87
+ });
88
+ if (error) throw new QTSPreparationError('Prepare submission failed', error);
89
+ if (!data?.jobId) throw new QTSPreparationError('Missing jobId in prepare response');
90
+
91
+ const prepareJobId = data.jobId;
92
+ const state = await runStage(
93
+ policy,
94
+ async ({ signal }) => {
95
+ const res = await getPrepareStatus({
96
+ path: { exchangeId: target.exchangeId, type: TICKER, jobId: prepareJobId },
97
+ signal,
98
+ });
99
+ if (res.error) throw new QTSPreparationError('Preparation status request failed', res.error);
100
+ if (!res.data) throw new QTSPreparationError('Empty preparation status response');
101
+ return res.data;
102
+ },
103
+ {
104
+ ...(run.signal ? { signal: run.signal } : {}),
105
+ ...(run.timeoutMs !== undefined ? { timeoutMs: run.timeoutMs } : {}),
106
+ onEachAttempt: (r) => {
107
+ if (r.size > 0) run.onPercent?.((r.completed / r.size) * 100);
108
+ },
109
+ },
110
+ );
111
+
112
+ const prepNorm = normalizeStatus(state.status);
113
+ if (prepNorm === 'failed') {
114
+ throw new QTSPreparationError(state.statusDetail ?? 'Data preparation failed');
115
+ }
116
+ if (prepNorm === 'aborted') {
117
+ throw new QTSCanceledError('Data preparation aborted');
118
+ }
119
+ run.onPrepared?.(state);
120
+ return prepareJobId;
121
+ }
@@ -0,0 +1,36 @@
1
+ import { QTSError } from '../errors';
2
+
3
+ /**
4
+ * Build the {@link QTSError} for a failed single-request call.
5
+ *
6
+ * The HTTP status is attached to the error rather than only rendered into the
7
+ * message, because callers branch on it — a `4xx` means the request itself was
8
+ * wrong, a `5xx` is generally worth retrying, and the authenticated session
9
+ * re-mints its JWT on a `401`.
10
+ *
11
+ * @param what short description of the call, e.g. `'exchanges call'`
12
+ * @param error the api-client error payload
13
+ * @param status HTTP status of the failing response
14
+ *
15
+ * @internal
16
+ */
17
+ export function requestFailed(
18
+ what: string,
19
+ error: unknown,
20
+ status?: number,
21
+ ): QTSError {
22
+ const prefix = status === undefined ? '' : `HTTP ${status} — `;
23
+ return new QTSError(`${what} failed: ${prefix}${describe(error)}`, error, status);
24
+ }
25
+
26
+ function describe(error: unknown): string {
27
+ if (error && typeof error === 'object') {
28
+ const e = error as { code?: unknown; message?: unknown };
29
+ const code = typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined;
30
+ const message = typeof e.message === 'string' ? e.message : undefined;
31
+ if (code !== undefined && message) return `${code}: ${message}`;
32
+ if (message) return message;
33
+ if (code !== undefined) return String(code);
34
+ }
35
+ return String(error);
36
+ }