@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.
@@ -2,30 +2,20 @@ import {
2
2
  cancelBacktest,
3
3
  executeBacktest,
4
4
  getBacktestResult,
5
- getPrepareStatus,
6
- compileStrategy as apiCompileStrategy,
7
- prepareBacktest,
8
- type DataSourceType,
9
5
  type ResultMap,
10
6
  } from '@qtsurfer/api-client';
7
+ import { QTSCanceledError, QTSExecutionError } from '../errors';
11
8
  import {
12
- ExponentialBackoff,
13
- TaskCancelledError,
14
- TimeoutStrategy,
15
- handleWhenResult,
16
- retry,
17
- timeout,
18
- wrap,
19
- type IPolicy,
20
- type ICancellationContext,
21
- } from 'cockatiel';
9
+ buildStagePolicy,
10
+ normalizeStatus,
11
+ runStage,
12
+ type StagePolicy,
13
+ } from '../internal/polling';
22
14
  import {
23
- QTSCanceledError,
24
- QTSExecutionError,
25
- QTSPreparationError,
26
- QTSStrategyCompileError,
27
- QTSTimeoutError,
28
- } from '../errors';
15
+ TICKER,
16
+ compileStrategySource,
17
+ prepareDataset,
18
+ } from '../internal/preparation';
29
19
 
30
20
  export interface BacktestRequest {
31
21
  /** Strategy source code (Java) */
@@ -79,43 +69,20 @@ export interface BacktestOptions {
79
69
  timeoutMs?: number;
80
70
  }
81
71
 
82
- const TICKER: DataSourceType = 'ticker';
83
-
84
- type JobStatus = 'New' | 'Started' | 'Completed' | 'Aborted' | 'Failed';
85
-
86
- /**
87
- * Normalize the backend job status to a stable lowercase form so we can
88
- * reason about it regardless of OpenAPI spec drift (the live API sometimes
89
- * returns lowercase values like `queued` / `completed` / `failed`).
90
- *
91
- * Only the three terminal statuses end a poll loop. Everything else — including a
92
- * **missing** status — means "keep asking": the API answers `202` with an empty body when a
93
- * job is known but its result is not readable yet, and that response carries no state at all.
94
- * Mapping absent to in-progress is what makes a 202 continue the loop under its timeout
95
- * instead of being mistaken for a finished job with no data.
96
- */
97
- type NormalizedStatus = 'in-progress' | 'completed' | 'failed' | 'aborted';
98
-
99
- function normalizeStatus(raw: unknown): NormalizedStatus {
100
- const value = typeof raw === 'string' ? raw.toLowerCase() : '';
101
- if (value === 'completed') return 'completed';
102
- if (value === 'failed') return 'failed';
103
- if (value === 'aborted' || value === 'cancelled' || value === 'canceled') {
104
- return 'aborted';
105
- }
106
- // new / started / queued / running / absent (202) / anything else → still running
107
- return 'in-progress';
108
- }
72
+ /** Initial interval between polls of a single backtest. */
73
+ const DEFAULT_POLL_INTERVAL_MS = 500;
74
+ /** Backoff ceiling for a single backtest. */
75
+ const DEFAULT_MAX_POLL_INTERVAL_MS = 5000;
109
76
 
110
77
  export async function backtest(
111
78
  req: BacktestRequest,
112
79
  opts: BacktestOptions = {},
113
80
  ): Promise<BacktestResult> {
114
- const policy = buildStagePolicy(opts);
81
+ const policy = buildStagePolicy(opts, DEFAULT_POLL_INTERVAL_MS, DEFAULT_MAX_POLL_INTERVAL_MS);
115
82
 
116
83
  // 1. Compile strategy (single synchronous request)
117
84
  opts.onProgress?.({ stage: 'compiling' });
118
- const strategyId = await compileStrategy(req.strategy, opts);
85
+ const strategyId = await compileStrategySource(req.strategy, opts.signal);
119
86
 
120
87
  // 2. Prepare data
121
88
  opts.onProgress?.({ stage: 'preparing' });
@@ -126,106 +93,40 @@ export async function backtest(
126
93
  return executeStrategy(req, prepareJobId, strategyId, policy, opts);
127
94
  }
128
95
 
129
- function buildStagePolicy(opts: BacktestOptions): IPolicy<ICancellationContext, never> {
130
- const retryPolicy = retry(
131
- handleWhenResult((r) => {
132
- const status = (r as { status?: unknown } | undefined)?.status;
133
- return normalizeStatus(status) === 'in-progress';
134
- }),
135
- {
136
- maxAttempts: Number.MAX_SAFE_INTEGER,
137
- backoff: new ExponentialBackoff({
138
- initialDelay: opts.pollIntervalMs ?? 500,
139
- maxDelay: opts.maxPollIntervalMs ?? 5000,
140
- }),
141
- },
142
- );
143
-
144
- return opts.timeoutMs
145
- ? wrap(timeout(opts.timeoutMs, TimeoutStrategy.Cooperative), retryPolicy)
146
- : retryPolicy;
147
- }
148
-
149
- /**
150
- * Compile in a single request: the API answers synchronously with the `strategyId`,
151
- * so there is no job to poll. A compile error arrives here as a `400`, not on a later poll.
152
- */
153
- async function compileStrategy(source: string, opts: BacktestOptions): Promise<string> {
154
- const { data, error, response } = await apiCompileStrategy({
155
- body: source,
156
- ...(opts.signal ? { signal: opts.signal } : {}),
157
- });
158
-
159
- if (error) {
160
- // A 429 means the platform is holding too many compilations at once and the source was never
161
- // judged — worth separating from the 400 that says the source itself does not compile.
162
- // Read the status inside this branch and optionally: a transport failure carries no response,
163
- // and dereferencing one would raise a TypeError that buries the error actually being reported.
164
- if (response?.status === 429) {
165
- throw new QTSStrategyCompileError(
166
- 'Strategy was not compiled, too many compilations in flight; retry later',
167
- error,
168
- );
169
- }
170
- throw new QTSStrategyCompileError('Strategy compilation failed', error);
171
- }
172
- if (!data?.strategyId) {
173
- throw new QTSStrategyCompileError('Compile response missing strategyId');
174
- }
175
- return data.strategyId;
176
- }
177
-
178
- async function prepareData(
96
+ function prepareData(
179
97
  req: BacktestRequest,
180
- policy: IPolicy<ICancellationContext, never>,
98
+ policy: StagePolicy,
181
99
  opts: BacktestOptions,
182
100
  ): Promise<string> {
183
- const { data, error } = await prepareBacktest({
184
- path: { exchangeId: req.exchangeId, type: TICKER },
185
- body: { instrument: req.instrument, from: req.from, to: req.to },
186
- ...(opts.signal ? { signal: opts.signal } : {}),
187
- });
188
- if (error) throw new QTSPreparationError('Prepare submission failed', error);
189
- if (!data?.jobId) throw new QTSPreparationError('Missing jobId in prepare response');
190
-
191
- const prepareJobId = data.jobId;
192
- const state = await runStage(
193
- policy,
194
- opts,
195
- async ({ signal }) => {
196
- const res = await getPrepareStatus({
197
- path: { exchangeId: req.exchangeId, type: TICKER, jobId: prepareJobId },
198
- signal,
199
- });
200
- if (res.error) throw new QTSPreparationError('Preparation status request failed', res.error);
201
- if (!res.data) throw new QTSPreparationError('Empty preparation status response');
202
- return res.data;
101
+ return prepareDataset(
102
+ {
103
+ exchangeId: req.exchangeId,
104
+ instrument: req.instrument,
105
+ from: req.from,
106
+ to: req.to,
203
107
  },
204
- (r) => {
205
- if (r.size > 0) {
206
- opts.onProgress?.({ stage: 'preparing', percent: (r.completed / r.size) * 100 });
207
- }
108
+ policy,
109
+ {
110
+ ...(opts.signal ? { signal: opts.signal } : {}),
111
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
112
+ onPercent: (percent) => opts.onProgress?.({ stage: 'preparing', percent }),
113
+ // Surface the backend's coverage ratio for the prepared window (spec 0.98.0) on the
114
+ // final preparing event, so callers can react to a partially-covered range.
115
+ onPrepared: (state) =>
116
+ opts.onProgress?.({
117
+ stage: 'preparing',
118
+ percent: 100,
119
+ coverageRatio: state.coverageRatio,
120
+ }),
208
121
  },
209
122
  );
210
-
211
- const prepNorm = normalizeStatus(state.status);
212
- if (prepNorm === 'failed') {
213
- throw new QTSPreparationError(state.statusDetail ?? 'Data preparation failed');
214
- }
215
- if (prepNorm === 'aborted') {
216
- throw new QTSCanceledError('Data preparation aborted');
217
- }
218
- // Surface the backend's coverage ratio for the prepared window (spec 0.98.0) on the
219
- // final preparing event, so callers can react to a partially-covered range.
220
- opts.onProgress?.({ stage: 'preparing', percent: 100, coverageRatio: state.coverageRatio });
221
- return prepareJobId;
222
123
  }
223
124
 
224
125
  async function executeStrategy(
225
126
  req: BacktestRequest,
226
127
  prepareJobId: string,
227
128
  strategyId: string,
228
- policy: IPolicy<ICancellationContext, never>,
129
+ policy: StagePolicy,
229
130
  opts: BacktestOptions,
230
131
  ): Promise<BacktestResult> {
231
132
  const { data, error } = await executeBacktest({
@@ -245,7 +146,6 @@ async function executeStrategy(
245
146
  try {
246
147
  const finalResult = await runStage(
247
148
  policy,
248
- opts,
249
149
  async ({ signal }) => {
250
150
  const res = await getBacktestResult({
251
151
  path: { exchangeId: req.exchangeId, type: TICKER, jobId: executeJobId },
@@ -258,10 +158,14 @@ async function executeStrategy(
258
158
  // see normalizeStatus. Do not "fix" this into a throw or an early return of the result.
259
159
  return { ...res.data.state, __result: res.data.results };
260
160
  },
261
- (r) => {
262
- if (r.size > 0) {
263
- opts.onProgress?.({ stage: 'executing', percent: (r.completed / r.size) * 100 });
264
- }
161
+ {
162
+ ...(opts.signal ? { signal: opts.signal } : {}),
163
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
164
+ onEachAttempt: (r) => {
165
+ if (r.size > 0) {
166
+ opts.onProgress?.({ stage: 'executing', percent: (r.completed / r.size) * 100 });
167
+ }
168
+ },
265
169
  },
266
170
  );
267
171
 
@@ -282,28 +186,3 @@ async function executeStrategy(
282
186
  throw err;
283
187
  }
284
188
  }
285
-
286
- async function runStage<T extends { status: JobStatus }>(
287
- policy: IPolicy<ICancellationContext, never>,
288
- opts: BacktestOptions,
289
- fetchFn: (ctx: ICancellationContext) => Promise<T>,
290
- onEachAttempt?: (r: T) => void,
291
- ): Promise<T> {
292
- try {
293
- return await policy.execute(async (ctx) => {
294
- if (opts.signal?.aborted) throw new QTSCanceledError('Workflow aborted');
295
- const result = await fetchFn(ctx);
296
- onEachAttempt?.(result);
297
- if (opts.signal?.aborted) throw new QTSCanceledError('Workflow aborted');
298
- return result;
299
- }, opts.signal);
300
- } catch (err) {
301
- if (err instanceof QTSCanceledError) throw err;
302
- if (err instanceof TaskCancelledError) {
303
- if (opts.signal?.aborted) throw new QTSCanceledError('Workflow aborted', err);
304
- throw new QTSTimeoutError(`Stage exceeded ${opts.timeoutMs}ms`, err);
305
- }
306
- if (opts.signal?.aborted) throw new QTSCanceledError('Workflow aborted', err);
307
- throw err;
308
- }
309
- }
@@ -0,0 +1,74 @@
1
+ import {
2
+ listExchanges as apiListExchanges,
3
+ listInstruments as apiListInstruments,
4
+ listSegmentInstruments as apiListSegmentInstruments,
5
+ type Exchange as ApiExchange,
6
+ type InstrumentDetail as ApiInstrumentDetail,
7
+ } from '@qtsurfer/api-client';
8
+ import { QTSError } from '../errors';
9
+ import { requestFailed } from '../internal/requestError';
10
+
11
+ /**
12
+ * One exchange the platform serves. Alias for api-client's `Exchange`:
13
+ * `id` (what every other call takes as `exchangeId`), `name`, and an
14
+ * optional `description`.
15
+ */
16
+ export type Exchange = ApiExchange;
17
+
18
+ /**
19
+ * One instrument on an exchange. Alias for api-client's `InstrumentDetail`:
20
+ * `id` / `base` / `quote`, plus optional `coverage` (the date windows for
21
+ * which tickers and klines actually exist, per data type), `lastPrice` and
22
+ * `volume24h`.
23
+ *
24
+ * `coverage` is what tells you whether a backtest range is downloadable at
25
+ * all; it is optional, and absent means the platform did not report one, not
26
+ * that there is no data.
27
+ */
28
+ export type InstrumentDetail = ApiInstrumentDetail;
29
+
30
+ /**
31
+ * A market segment of an exchange. `'spot'` is the default segment served
32
+ * when {@link QTSurfer.instruments} is called without one.
33
+ */
34
+ export type InstrumentSegment = 'spot' | 'futures';
35
+
36
+ /**
37
+ * List the exchanges the platform serves.
38
+ *
39
+ * @throws QTSError on any non-2xx response, with the HTTP status on `status`.
40
+ */
41
+ export async function listExchanges(): Promise<Exchange[]> {
42
+ const { data, error, response } = await apiListExchanges();
43
+ if (error) throw requestFailed('exchanges call', error, response?.status);
44
+ if (!data) throw new QTSError('Empty exchanges response');
45
+ return data;
46
+ }
47
+
48
+ /**
49
+ * List an exchange's instruments, each with its per-data-type coverage.
50
+ *
51
+ * Omitting `segment` asks for the exchange's **default** segment, which is
52
+ * `'spot'` today. The API answers both routes with a HAL envelope
53
+ * (`data` / `meta` / `_links`) that this function unwraps to the instrument
54
+ * array, so `meta.segment`, `meta.updatedAt` and the `_links`
55
+ * segment-discovery links do not reach the caller: if you need certainty
56
+ * about which segment you are looking at, pass `segment` explicitly rather
57
+ * than relying on the default.
58
+ *
59
+ * @param exchangeId exchange identifier, e.g. `binance`
60
+ * @param segment market segment to list; defaults to the exchange's default
61
+ * segment
62
+ * @throws QTSError on any non-2xx response, with the HTTP status on `status`.
63
+ */
64
+ export async function listInstruments(
65
+ exchangeId: string,
66
+ segment?: InstrumentSegment,
67
+ ): Promise<InstrumentDetail[]> {
68
+ const { data, error, response } = segment
69
+ ? await apiListSegmentInstruments({ path: { exchangeId, segment } })
70
+ : await apiListInstruments({ path: { exchangeId } });
71
+ if (error) throw requestFailed('instruments call', error, response?.status);
72
+ if (!data) throw new QTSError('Empty instruments response');
73
+ return data.data;
74
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ getStrategy as apiGetStrategy,
3
+ validateStrategy as apiValidateStrategy,
4
+ type StrategyState as ApiStrategyState,
5
+ } from '@qtsurfer/api-client';
6
+ import { QTSError } from '../errors';
7
+ import { requestFailed } from '../internal/requestError';
8
+
9
+ /**
10
+ * Everything the platform records about a registered strategy. Alias for
11
+ * api-client's `StrategyState`.
12
+ *
13
+ * `validation` is the verdict and is one of:
14
+ *
15
+ * - `'not_validated'` — registered, never checked.
16
+ * - `'pending'` — a check was asked for and has not answered yet.
17
+ * - `'passed'` — the class loaded and survived its first event.
18
+ * - `'failed'` — it did not; `detail` says how.
19
+ *
20
+ * **`'passed'` is a floor, not a guarantee.** It means the compiled class
21
+ * could be instantiated and got through the first event of a short synthetic
22
+ * run — not the caller's instrument, not the caller's window, and not the
23
+ * rest of the run. It says nothing about whether the strategy is correct,
24
+ * profitable, or safe to run at scale. `dryRunIncomplete` marks a check that
25
+ * ran out of its budget, which makes a `'passed'` verdict a lower floor
26
+ * still, and makes an empty `notices` list no longer a clean bill of health.
27
+ *
28
+ * A verdict describes the bytecode that existed when it was recorded:
29
+ * `compiledAt` newer than `validatedAt` means the strategy was recompiled
30
+ * afterwards and the verdict no longer describes what would run.
31
+ */
32
+ export type StrategyState = ApiStrategyState;
33
+
34
+ /**
35
+ * Outcome of {@link QTSurfer.validateStrategy} — the SDK's rendering of the
36
+ * two answers that operation has, which the response body alone cannot tell
37
+ * apart.
38
+ *
39
+ * - `queued: false` — a verdict already existed for the current compilation
40
+ * and comes back in `state` unchanged; nothing new was queued. This is
41
+ * **not** the same as "terminal": a check queued by an earlier call can
42
+ * still be running, so read `state.validation` rather than treating
43
+ * `queued: false` as "there is an answer".
44
+ * - `queued: true` — a check was just queued. Nothing is known yet; poll
45
+ * {@link QTSurfer.strategy} until `validation` leaves `'pending'`.
46
+ */
47
+ export type StrategyValidation =
48
+ | { queued: false; strategyId: string; state: StrategyState }
49
+ | { queued: true; strategyId: string; state?: undefined };
50
+
51
+ /**
52
+ * Ask the platform to check that a registered strategy can actually run: it
53
+ * instantiates the compiled class and drives it through a bounded synthetic
54
+ * series, so a wiring fault surfaces here instead of at the first backtest.
55
+ *
56
+ * **Idempotent, and two-outcome.** If a verdict already exists for the
57
+ * current compilation it is returned unchanged and nothing is queued
58
+ * (`queued: false`); otherwise a check is queued (`queued: true`) and this
59
+ * call is *not* terminal — poll {@link QTSurfer.strategy} until `validation`
60
+ * is `'passed'` or `'failed'`. Because a `queued: false` answer can itself carry
61
+ * `validation: 'pending'` (a check an earlier call queued), the discriminant
62
+ * tells you whether work was *started*, not whether a verdict *exists*;
63
+ * `state.validation` is what tells you that.
64
+ *
65
+ * **Poll with a deadline of your own.** `'pending'` is not guaranteed to
66
+ * resolve: a queued check can go unreported for far longer than one takes,
67
+ * which the platform eventually flags as `validationStalled` on the strategy.
68
+ * Nothing about the strategy is disproved when that happens — the check
69
+ * simply did not run — but a caller that waits for a terminal verdict without
70
+ * a timeout can wait forever. This SDK deliberately ships no polling helper
71
+ * for that reason; the timeout is the caller's policy to set.
72
+ *
73
+ * Whatever the verdict, remember it is a floor rather than a guarantee — see
74
+ * {@link StrategyState}.
75
+ *
76
+ * @param strategyId the id returned when the strategy was compiled
77
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
78
+ * means no such registered strategy for this caller.
79
+ */
80
+ export async function validateStrategy(strategyId: string): Promise<StrategyValidation> {
81
+ const { data, error, response } = await apiValidateStrategy({ path: { strategyId } });
82
+ if (error) throw requestFailed('strategy validation request', error, response?.status);
83
+ // The two outcomes are distinguishable only by status: a `200` body is a
84
+ // full StrategyState whose `validation` may itself be `'pending'`, so the
85
+ // payload cannot be used to tell "queued just now" from "already queued".
86
+ // The queued branch echoes back the caller's own id rather than reading one
87
+ // out of the body, because an accepted-but-not-done response on this API is
88
+ // not guaranteed to carry a body at all.
89
+ if (response?.status === 202) return { queued: true, strategyId };
90
+ if (!data) throw new QTSError('Empty strategy validation response');
91
+ return { queued: false, strategyId, state: data as StrategyState };
92
+ }
93
+
94
+ /**
95
+ * Read everything the platform records about a strategy: whether it is
96
+ * registered at all, its validation verdict, the market data its compiled
97
+ * class requires, and any engine notices the check raised.
98
+ *
99
+ * This is the endpoint to poll after {@link QTSurfer.validateStrategy}
100
+ * returns `queued: true`, and the only place a verdict is read from.
101
+ *
102
+ * Check `compiledAt` against `validatedAt` before trusting a verdict: the
103
+ * strategy may have been recompiled since the verdict was recorded, in which
104
+ * case the verdict describes bytecode that is no longer what would run.
105
+ * Re-request validation to get an answer about the current compilation.
106
+ *
107
+ * @param strategyId the id returned when the strategy was compiled
108
+ * @throws QTSError on any non-2xx response. A `404` (carried on `status`)
109
+ * means exactly one thing — no such registered strategy for this caller. It is
110
+ * never a stale or expired answer: registration and verdict are stored
111
+ * durably, not cached.
112
+ */
113
+ export async function getStrategy(strategyId: string): Promise<StrategyState> {
114
+ const { data, error, response } = await apiGetStrategy({ path: { strategyId } });
115
+ if (error) throw requestFailed('strategy lookup', error, response?.status);
116
+ if (!data) throw new QTSError('Empty strategy response');
117
+ return data;
118
+ }