@qtsurfer/sdk 0.8.0 → 0.10.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.
@@ -24,8 +24,12 @@ import {
24
24
  import {
25
25
  getStrategy,
26
26
  validateStrategy as runValidateStrategy,
27
+ listStrategies,
28
+ deleteStrategy as runDeleteStrategy,
29
+ getStrategyCode,
27
30
  type StrategyState,
28
31
  type StrategyValidation,
32
+ type StrategySummary,
29
33
  } from '../workflows/strategies';
30
34
  import {
31
35
  sweep as runSweep,
@@ -238,6 +242,33 @@ export class AuthenticatedClient {
238
242
  strategy(strategyId: string): Promise<StrategyState> {
239
243
  return this.withRefreshOn401(() => getStrategy(strategyId));
240
244
  }
245
+
246
+ /**
247
+ * List every strategy you have registered and not deleted, most recently
248
+ * compiled first. Refreshes the token once on `401` before retrying. See
249
+ * {@link QTSurfer.strategies}.
250
+ */
251
+ strategies(): Promise<StrategySummary[]> {
252
+ return this.withRefreshOn401(() => listStrategies());
253
+ }
254
+
255
+ /**
256
+ * Release a registered strategy. Refreshes the token once on `401` before
257
+ * retrying. See {@link QTSurfer.deleteStrategy} for what this does and
258
+ * does not undo.
259
+ */
260
+ deleteStrategy(strategyId: string): Promise<void> {
261
+ return this.withRefreshOn401(() => runDeleteStrategy(strategyId));
262
+ }
263
+
264
+ /**
265
+ * Read back a strategy's exact registered source. Refreshes the token
266
+ * once on `401` before retrying. See {@link QTSurfer.strategyCode} for
267
+ * what its `404` covers.
268
+ */
269
+ strategyCode(strategyId: string): Promise<string> {
270
+ return this.withRefreshOn401(() => getStrategyCode(strategyId));
271
+ }
241
272
  }
242
273
 
243
274
  /**
@@ -247,7 +278,8 @@ export class AuthenticatedClient {
247
278
  * environment. The returned {@link AuthenticatedClient} caches the JWT,
248
279
  * refreshes it on 401, and exposes the same surface as `QTSurfer`
249
280
  * (`backtest`, `sweep`, `tickers`, `klines`, `exchanges`, `instruments`,
250
- * `validateStrategy`, `strategy`).
281
+ * `validateStrategy`, `strategy`, `strategies`, `deleteStrategy`,
282
+ * `strategyCode`).
251
283
  *
252
284
  * @throws {QTSAuthError} if no apikey is supplied or available in env.
253
285
  */
package/src/client.ts CHANGED
@@ -20,8 +20,12 @@ import {
20
20
  import {
21
21
  getStrategy,
22
22
  validateStrategy as runValidateStrategy,
23
+ listStrategies,
24
+ deleteStrategy as runDeleteStrategy,
25
+ getStrategyCode,
23
26
  type StrategyState,
24
27
  type StrategyValidation,
28
+ type StrategySummary,
25
29
  } from './workflows/strategies';
26
30
  import {
27
31
  sweep as runSweep,
@@ -62,7 +66,7 @@ export interface DownloadHourArgs {
62
66
  * Thin, stateless wrapper over `@qtsurfer/api-client` that exposes the SDK's
63
67
  * workflow methods (`backtest`, `sweep`, `tickers`, `klines`), the platform catalog
64
68
  * (`exchanges`, `instruments`) and the strategy surface (`validateStrategy`,
65
- * `strategy`). Constructing an
69
+ * `strategy`, `strategies`, `deleteStrategy`, `strategyCode`). Constructing an
66
70
  * instance reconfigures the underlying api-client singleton, so avoid
67
71
  * holding two `QTSurfer`s with different `baseUrl`s or tokens alive in the
68
72
  * same process — they will race. Prefer the `authenticate()` helper over
@@ -107,7 +111,8 @@ export class QTSurfer {
107
111
  * if a stage exceeds `timeoutMs`, or {@link QTSCanceledError} if the caller's
108
112
  * signal fires before the sweep is accepted. A plain {@link QTSError} means
109
113
  * 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.
114
+ * walk-forward block with fewer than two folds, or naming both/neither of
115
+ * `instrument`/`datasetId`) and never reached the network.
111
116
  *
112
117
  * What the sweep *found* arrives through {@link Sweep.result}, which is also
113
118
  * where the semantics of the leaderboard are documented. Acceptance already
@@ -232,6 +237,51 @@ export class QTSurfer {
232
237
  return getStrategy(strategyId);
233
238
  }
234
239
 
240
+ /**
241
+ * List every strategy you have registered and not deleted, most recently
242
+ * compiled first. Never `404`s — an empty array means you have none.
243
+ * Each entry deliberately omits `validation`; check a specific strategy's
244
+ * verdict with {@link QTSurfer.strategy}. See {@link StrategySummary}.
245
+ *
246
+ * @throws QTSError on any non-2xx response, with the HTTP status on
247
+ * `status`.
248
+ */
249
+ strategies(): Promise<StrategySummary[]> {
250
+ return listStrategies();
251
+ }
252
+
253
+ /**
254
+ * Release a registered strategy: removes it from both {@link
255
+ * QTSurfer.strategy} and {@link QTSurfer.strategies}.
256
+ *
257
+ * Backtests already run against this strategy are unaffected, and
258
+ * re-submitting the same source afterwards registers a **new** strategy
259
+ * with a **new** id rather than undeleting this one. Deleting your own
260
+ * copy of a strategy never affects anyone else's copy of the same source
261
+ * (e.g. a shared/marketplace listing).
262
+ *
263
+ * @param strategyId the id returned when the strategy was compiled
264
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
265
+ * means no such registered strategy for this caller.
266
+ */
267
+ deleteStrategy(strategyId: string): Promise<void> {
268
+ return runDeleteStrategy(strategyId);
269
+ }
270
+
271
+ /**
272
+ * Read back the exact source last submitted for a strategy id, whitespace
273
+ * and comments included.
274
+ *
275
+ * A `404` (carried on `status`) covers two indistinguishable cases: the id
276
+ * was never registered by you, or it resolves only through a shared/
277
+ * marketplace reference that carries no source of its own.
278
+ *
279
+ * @param strategyId the id returned when the strategy was compiled
280
+ */
281
+ strategyCode(strategyId: string): Promise<string> {
282
+ return getStrategyCode(strategyId);
283
+ }
284
+
235
285
  // Future surface:
236
286
  // TTL cache for exchanges / instruments
237
287
  // jobs: { cancel, stream, result }
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export type {
55
55
  export type {
56
56
  StrategyState,
57
57
  StrategyValidation,
58
+ StrategySummary,
58
59
  } from './workflows/strategies';
59
60
  export {
60
61
  authenticate,
@@ -5,7 +5,7 @@ import {
5
5
  type DataSourceType,
6
6
  type PrepareJobState,
7
7
  } from '@qtsurfer/api-client';
8
- import { QTSCanceledError, QTSPreparationError, QTSStrategyCompileError } from '../errors';
8
+ import { QTSCanceledError, QTSError, QTSPreparationError, QTSStrategyCompileError } from '../errors';
9
9
  import { normalizeStatus, runStage, type StagePolicy } from './polling';
10
10
 
11
11
  /** The only data source the workflows prepare against today. @internal */
@@ -45,14 +45,52 @@ export async function compileStrategySource(
45
45
  return data.strategyId;
46
46
  }
47
47
 
48
- /** The instrument and window one prepare covers. @internal */
48
+ /**
49
+ * The instrument (or dataset) and window one prepare covers. Exactly one of
50
+ * `instrument`/`datasetId` is set — {@link validatePrepareTarget} enforces that
51
+ * before any network call. `datasetId` pairs with the reserved `exchangeId: 'user'`.
52
+ *
53
+ * @internal
54
+ */
49
55
  export interface PrepareTarget {
50
56
  exchangeId: string;
51
- instrument: string;
57
+ instrument?: string;
58
+ /** Id of a previously uploaded dataset, in place of `instrument`. */
59
+ datasetId?: string;
60
+ /** Optional specific version of `datasetId`; requires `datasetId`. */
61
+ datasetVersionId?: string;
52
62
  from: string;
53
63
  to: string;
54
64
  }
55
65
 
66
+ /**
67
+ * Reject a prepare target that names both, or neither, of `instrument`/`datasetId`,
68
+ * or that sets `datasetVersionId` without `datasetId` — the same three shapes the
69
+ * platform would only reject over the network. Called first thing by each workflow,
70
+ * before compiling the strategy, so a malformed request never reaches the network.
71
+ *
72
+ * @param workflow name of the caller, used to prefix the error message (e.g. `'backtest'`)
73
+ * @internal
74
+ */
75
+ export function validatePrepareTarget(
76
+ workflow: string,
77
+ target: Pick<PrepareTarget, 'instrument' | 'datasetId' | 'datasetVersionId'>,
78
+ ): void {
79
+ const hasInstrument = target.instrument !== undefined;
80
+ const hasDataset = target.datasetId !== undefined;
81
+ if (hasInstrument && hasDataset) {
82
+ throw new QTSError(`${workflow}: exactly one of instrument/datasetId is required, got both`);
83
+ }
84
+ if (!hasInstrument && !hasDataset) {
85
+ throw new QTSError(
86
+ `${workflow}: exactly one of instrument/datasetId is required, got neither`,
87
+ );
88
+ }
89
+ if (target.datasetVersionId !== undefined && !hasDataset) {
90
+ throw new QTSError(`${workflow}: datasetVersionId requires datasetId`);
91
+ }
92
+ }
93
+
56
94
  /** Reporting and cancellation for {@link prepareDataset}. @internal */
57
95
  export interface PrepareRun {
58
96
  signal?: AbortSignal;
@@ -82,7 +120,15 @@ export async function prepareDataset(
82
120
  ): Promise<string> {
83
121
  const { data, error } = await prepareBacktest({
84
122
  path: { exchangeId: target.exchangeId, type: TICKER },
85
- body: { instrument: target.instrument, from: target.from, to: target.to },
123
+ body: {
124
+ ...(target.instrument !== undefined ? { instrument: target.instrument } : {}),
125
+ ...(target.datasetId !== undefined ? { datasetId: target.datasetId } : {}),
126
+ ...(target.datasetVersionId !== undefined
127
+ ? { datasetVersionId: target.datasetVersionId }
128
+ : {}),
129
+ from: target.from,
130
+ to: target.to,
131
+ },
86
132
  ...(run.signal ? { signal: run.signal } : {}),
87
133
  });
88
134
  if (error) throw new QTSPreparationError('Prepare submission failed', error);
@@ -15,15 +15,47 @@ import {
15
15
  TICKER,
16
16
  compileStrategySource,
17
17
  prepareDataset,
18
+ validatePrepareTarget,
18
19
  } from '../internal/preparation';
19
20
 
21
+ /**
22
+ * ```ts
23
+ * const request: BacktestRequest = {
24
+ * strategy: source,
25
+ * exchangeId: 'binance',
26
+ * instrument: 'BTC/USDT',
27
+ * from: '2026-01-01T00:00:00Z',
28
+ * to: '2026-02-01T00:00:00Z',
29
+ * };
30
+ * ```
31
+ *
32
+ * Backtest against a dataset you uploaded instead of an exchange instrument by
33
+ * replacing `instrument` with `datasetId` (and `exchangeId: 'user'`):
34
+ *
35
+ * ```ts
36
+ * const request: BacktestRequest = {
37
+ * strategy: source,
38
+ * exchangeId: 'user',
39
+ * datasetId: 'ds_123',
40
+ * from: '2026-01-01T00:00:00Z',
41
+ * to: '2026-02-01T00:00:00Z',
42
+ * };
43
+ * ```
44
+ */
20
45
  export interface BacktestRequest {
21
46
  /** Strategy source code (Java) */
22
47
  strategy: string;
23
- /** Exchange id, e.g. `binance` */
48
+ /** Exchange id, e.g. `binance`, or the reserved value `user` when backtesting against `datasetId`. */
24
49
  exchangeId: string;
25
- /** Instrument symbol, e.g. `BTC/USDT` */
26
- instrument: string;
50
+ /** Instrument symbol, e.g. `BTC/USDT`. Exactly one of `instrument`/`datasetId` is required. */
51
+ instrument?: string;
52
+ /**
53
+ * Id of a dataset you uploaded, in place of `instrument`. Exactly one of
54
+ * `instrument`/`datasetId` is required. Pairs with `exchangeId: 'user'`.
55
+ */
56
+ datasetId?: string;
57
+ /** Optional specific version of `datasetId`; omit to use its current version. Requires `datasetId`. */
58
+ datasetVersionId?: string;
27
59
  /** Date range start (ISO-8601, ISO DATE or BASIC ISO DATE) */
28
60
  from: string;
29
61
  /** Date range end (same formats as `from`) */
@@ -78,6 +110,7 @@ export async function backtest(
78
110
  req: BacktestRequest,
79
111
  opts: BacktestOptions = {},
80
112
  ): Promise<BacktestResult> {
113
+ validatePrepareTarget('backtest', req);
81
114
  const policy = buildStagePolicy(opts, DEFAULT_POLL_INTERVAL_MS, DEFAULT_MAX_POLL_INTERVAL_MS);
82
115
 
83
116
  // 1. Compile strategy (single synchronous request)
@@ -101,7 +134,9 @@ function prepareData(
101
134
  return prepareDataset(
102
135
  {
103
136
  exchangeId: req.exchangeId,
104
- instrument: req.instrument,
137
+ ...(req.instrument !== undefined ? { instrument: req.instrument } : {}),
138
+ ...(req.datasetId !== undefined ? { datasetId: req.datasetId } : {}),
139
+ ...(req.datasetVersionId !== undefined ? { datasetVersionId: req.datasetVersionId } : {}),
105
140
  from: req.from,
106
141
  to: req.to,
107
142
  },
@@ -1,7 +1,11 @@
1
1
  import {
2
2
  getStrategy as apiGetStrategy,
3
3
  validateStrategy as apiValidateStrategy,
4
+ listStrategies as apiListStrategies,
5
+ deleteStrategy as apiDeleteStrategy,
6
+ getStrategyCode as apiGetStrategyCode,
4
7
  type StrategyState as ApiStrategyState,
8
+ type StrategySummary as ApiStrategySummary,
5
9
  } from '@qtsurfer/api-client';
6
10
  import { QTSError } from '../errors';
7
11
  import { requestFailed } from '../internal/requestError';
@@ -28,6 +32,15 @@ import { requestFailed } from '../internal/requestError';
28
32
  * A verdict describes the bytecode that existed when it was recorded:
29
33
  * `compiledAt` newer than `validatedAt` means the strategy was recompiled
30
34
  * afterwards and the verdict no longer describes what would run.
35
+ *
36
+ * `_links.code`, when present, is a discovery link to this strategy's raw
37
+ * source (`GET /strategy/{strategyId}/code` — the same thing
38
+ * {@link QTSurfer.strategyCode} fetches by id, so there is no need to follow
39
+ * the link yourself). It is present on a full `StrategyState` body — this
40
+ * function's result, and {@link QTSurfer.validateStrategy}'s already-validated
41
+ * `200` — and **absent** from that same operation's `queued: true` (`202`)
42
+ * outcome, which is a deliberately partial stub. This field passes through
43
+ * unmodified from api-client, so it needs no unwrapping on the SDK's part.
31
44
  */
32
45
  export type StrategyState = ApiStrategyState;
33
46
 
@@ -116,3 +129,88 @@ export async function getStrategy(strategyId: string): Promise<StrategyState> {
116
129
  if (!data) throw new QTSError('Empty strategy response');
117
130
  return data;
118
131
  }
132
+
133
+ /**
134
+ * One entry in {@link QTSurfer.strategies}'s result: the same provenance
135
+ * {@link QTSurfer.strategy} reports — `compiledAt`, `requiredSources` — but
136
+ * never `validation`, which is what keeps listing cheap no matter how many
137
+ * strategies you have registered. Check a specific strategy's verdict with
138
+ * {@link QTSurfer.strategy}.
139
+ *
140
+ * Note: the spec types this endpoint's `requiredSources` as a plain
141
+ * `string[]`, not the `'Ticker' | 'KLine' | 'FundingRate'` union that
142
+ * {@link StrategyState}'s own `requiredSources` carries — narrow it yourself
143
+ * if you need the literal type. Alias for api-client's `StrategySummary`.
144
+ */
145
+ export type StrategySummary = ApiStrategySummary;
146
+
147
+ /**
148
+ * List every strategy you have registered and not deleted, most recently
149
+ * compiled first.
150
+ *
151
+ * **Never `404`.** An empty array means you have none registered — not an
152
+ * error. Each entry omits `validation` on purpose (see {@link
153
+ * StrategySummary}); check a specific strategy's verdict with {@link
154
+ * QTSurfer.strategy}.
155
+ *
156
+ * @throws QTSError on any non-2xx response, with the HTTP status on `status`.
157
+ */
158
+ export async function listStrategies(): Promise<StrategySummary[]> {
159
+ const { data, error, response } = await apiListStrategies();
160
+ if (error) throw requestFailed('strategies list', error, response?.status);
161
+ if (!data) throw new QTSError('Empty strategies response');
162
+ return data.strategies;
163
+ }
164
+
165
+ /**
166
+ * Release a registered strategy: removes it from both {@link
167
+ * QTSurfer.strategy} and {@link QTSurfer.strategies}.
168
+ *
169
+ * **Does not undo anything already run.** Backtests you ran against this
170
+ * strategy before deleting it are completely unaffected — deleting only
171
+ * stops you from validating or re-running it under this id going forward.
172
+ * Re-submitting the exact same source afterwards registers a **new**
173
+ * strategy with a **new** id; it does not "undelete" this one, because
174
+ * nothing about the id itself is restored.
175
+ *
176
+ * **Scoped to your own registration.** If you copied someone else's strategy
177
+ * (a shared/marketplace listing), deleting your copy never affects theirs,
178
+ * or anyone else's, regardless of how many callers registered the same
179
+ * source independently.
180
+ *
181
+ * Resolves with nothing: the response body is `{ strategyId, deleted: true }`,
182
+ * and both fields are things the caller already knows before calling this —
183
+ * `strategyId` is the argument just passed in, and `deleted` is always `true`
184
+ * on a `200`. There is nothing in it a `void` return would lose.
185
+ *
186
+ * @param strategyId the id returned when the strategy was compiled
187
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
188
+ * means no such registered strategy for this caller.
189
+ */
190
+ export async function deleteStrategy(strategyId: string): Promise<void> {
191
+ const { error, response } = await apiDeleteStrategy({ path: { strategyId } });
192
+ if (error) throw requestFailed('strategy delete', error, response?.status);
193
+ }
194
+
195
+ /**
196
+ * Read back the exact source last submitted for a strategy id — the same
197
+ * text `strategyId` was derived from, whitespace and comments included.
198
+ *
199
+ * **A `404` here covers two cases the response cannot tell apart:** the id
200
+ * was never registered by you, or it resolves only through a shared/
201
+ * marketplace reference that carries no source of its own (a strategy you
202
+ * copied by reference rather than by resubmitting its code). Both read as
203
+ * "nothing to return" from this endpoint's point of view, and the SDK does
204
+ * not attempt to distinguish them — there is nothing in the response to tell
205
+ * them apart with.
206
+ *
207
+ * @param strategyId the id returned when the strategy was compiled
208
+ * @throws QTSError on any non-2xx response; a `404` (carried on `status`)
209
+ * is the two-case ambiguity described above.
210
+ */
211
+ export async function getStrategyCode(strategyId: string): Promise<string> {
212
+ const { data, error, response } = await apiGetStrategyCode({ path: { strategyId } });
213
+ if (error) throw requestFailed('strategy code lookup', error, response?.status);
214
+ if (!data) throw new QTSError('Empty strategy code response');
215
+ return data.code;
216
+ }
@@ -25,7 +25,12 @@ import {
25
25
  runStage,
26
26
  type StagePolicy,
27
27
  } from '../internal/polling';
28
- import { TICKER, compileStrategySource, prepareDataset } from '../internal/preparation';
28
+ import {
29
+ TICKER,
30
+ compileStrategySource,
31
+ prepareDataset,
32
+ validatePrepareTarget,
33
+ } from '../internal/preparation';
29
34
  import { requestFailed } from '../internal/requestError';
30
35
  import type { BacktestStage } from './backtest';
31
36
 
@@ -291,14 +296,35 @@ export type WalkForwardFold = ApiWalkForwardFold;
291
296
  * objective: 'sharpe',
292
297
  * };
293
298
  * ```
299
+ *
300
+ * Sweep against a dataset you uploaded instead of an exchange instrument by
301
+ * replacing `instrument` with `datasetId` (and `exchangeId: 'user'`):
302
+ *
303
+ * ```ts
304
+ * const request: SweepRequest = {
305
+ * strategy: source,
306
+ * exchangeId: 'user',
307
+ * datasetId: 'ds_123',
308
+ * from: '2026-01-01T00:00:00Z',
309
+ * to: '2026-02-01T00:00:00Z',
310
+ * params: { rsiPeriod: { from: 7, to: 28, step: 1 } },
311
+ * };
312
+ * ```
294
313
  */
295
314
  export interface SweepRequest {
296
315
  /** Strategy source code (Java), compiled once and reused by every trial. */
297
316
  strategy: string;
298
- /** Exchange id, e.g. `binance`. */
317
+ /** Exchange id, e.g. `binance`, or the reserved value `user` when sweeping against `datasetId`. */
299
318
  exchangeId: string;
300
- /** Instrument symbol, e.g. `BTC/USDT`. */
301
- instrument: string;
319
+ /** Instrument symbol, e.g. `BTC/USDT`. Exactly one of `instrument`/`datasetId` is required. */
320
+ instrument?: string;
321
+ /**
322
+ * Id of a dataset you uploaded, in place of `instrument`. Exactly one of
323
+ * `instrument`/`datasetId` is required. Pairs with `exchangeId: 'user'`.
324
+ */
325
+ datasetId?: string;
326
+ /** Optional specific version of `datasetId`; omit to use its current version. Requires `datasetId`. */
327
+ datasetVersionId?: string;
302
328
  /** Range start (ISO-8601, ISO DATE, or BASIC ISO DATE). */
303
329
  from: string;
304
330
  /** Range end (same formats as `from`; must be later than `from`). */
@@ -655,6 +681,8 @@ export async function sweep(req: SweepRequest, opts: SweepOptions = {}): Promise
655
681
  * the same way.
656
682
  */
657
683
  function validateRequest(req: SweepRequest): void {
684
+ validatePrepareTarget('sweep', req);
685
+
658
686
  const names = Object.keys(req.params ?? {});
659
687
  if (names.length === 0) {
660
688
  throw new QTSError('sweep: params must hold at least one axis');
@@ -690,7 +718,9 @@ function prepareData(
690
718
  return prepareDataset(
691
719
  {
692
720
  exchangeId: req.exchangeId,
693
- instrument: req.instrument,
721
+ ...(req.instrument !== undefined ? { instrument: req.instrument } : {}),
722
+ ...(req.datasetId !== undefined ? { datasetId: req.datasetId } : {}),
723
+ ...(req.datasetVersionId !== undefined ? { datasetVersionId: req.datasetVersionId } : {}),
694
724
  from: req.from,
695
725
  to: req.to,
696
726
  },