@qtsurfer/sdk 0.7.1 → 0.9.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.
@@ -0,0 +1,861 @@
1
+ import {
2
+ cancelSweep,
3
+ executeSweep,
4
+ getSweepResult,
5
+ getSweepSensitivity,
6
+ type ExecuteSweepAccepted,
7
+ type ExecuteSweepRequest,
8
+ type ExecuteSweepResult,
9
+ type GetSweepResultData,
10
+ type SweepHeatmap as ApiSweepHeatmap,
11
+ type SweepHeatmapCell as ApiSweepHeatmapCell,
12
+ type SweepMarginal as ApiSweepMarginal,
13
+ type SweepMarginalPoint as ApiSweepMarginalPoint,
14
+ type SweepProgress as ApiSweepProgress,
15
+ type SweepRunRow as ApiSweepRunRow,
16
+ type SweepSensitivity as ApiSweepSensitivity,
17
+ type SweepSpecRequest,
18
+ type WalkForwardFold as ApiWalkForwardFold,
19
+ type WalkForwardResult as ApiWalkForwardResult,
20
+ } from '@qtsurfer/api-client';
21
+ import { QTSCanceledError, QTSError, QTSExecutionError } from '../errors';
22
+ import {
23
+ buildStagePolicy,
24
+ normalizeStatus,
25
+ runStage,
26
+ type StagePolicy,
27
+ } from '../internal/polling';
28
+ import { TICKER, compileStrategySource, prepareDataset } from '../internal/preparation';
29
+ import { requestFailed } from '../internal/requestError';
30
+ import type { BacktestStage } from './backtest';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Vocabulary
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * The metric a sweep optimizes, and the one its leaderboard and its
38
+ * sensitivity surfaces are read against.
39
+ *
40
+ * One vocabulary throughout: the objective a {@link SweepRequest} is submitted
41
+ * with is the objective the leaderboard is ranked by, and the one
42
+ * {@link Sweep.sensitivity} aggregates unless a different one is asked for.
43
+ *
44
+ * - `'sharpe'` — risk-adjusted return; the platform default when a request names none.
45
+ * - `'sortino'` — downside-risk-adjusted return.
46
+ * - `'pnl'` — absolute net profit and loss.
47
+ * - `'maxdd'` — maximum drawdown.
48
+ */
49
+ export type SweepObjective = 'sharpe' | 'sortino' | 'pnl' | 'maxdd';
50
+
51
+ /**
52
+ * How the parameter grid is turned into the list of vectors that actually run.
53
+ *
54
+ * - `'grid'` — every combination of every axis value. The platform default;
55
+ * cost is the product of the axis sizes.
56
+ * - `'random'` — uniformly random draws from the grid, capped at
57
+ * {@link SweepRequest.samples}.
58
+ * - `'lhs'` — Latin hypercube draws, which spread the sample more evenly than
59
+ * uniform random.
60
+ *
61
+ * `samples` is required by `'random'` and `'lhs'` and ignored by `'grid'`.
62
+ */
63
+ export type SweepSampler = 'grid' | 'random' | 'lhs';
64
+
65
+ /**
66
+ * How the ranked leaderboard is ordered.
67
+ *
68
+ * The **platform default is `'plateau'`**, so the order a sweep answers with is
69
+ * *not* raw objective order unless you ask for it. A plateau score is the
70
+ * objective of the worst run in a point's immediate neighbourhood, so a point
71
+ * only ranks well when the region around it does too — which exists because
72
+ * the highest raw score is very often a spike that does not survive the
73
+ * parameters moving slightly.
74
+ *
75
+ * **What you request is not always what you get.** Read `ranking` on the
76
+ * result to find out which ordering was actually applied: a sweep with no
77
+ * stored parameter grid has no neighbourhood to score against and falls back
78
+ * to `'raw'`, and a walk-forward sweep is always `'raw'` because its
79
+ * leaderboard is one out-of-sample row per fold rather than a grid.
80
+ *
81
+ * This applies to the ranked view only. Alongside `order: 'natural'` it is
82
+ * **ignored** — that view is always ordered by `runIx`.
83
+ */
84
+ export type SweepRanking = 'plateau' | 'raw';
85
+
86
+ /**
87
+ * Which view of a sweep's rows to read: the display leaderboard, or every row
88
+ * in a stable order.
89
+ *
90
+ * - `'ranked'` — the platform default. Sorted, and capped at a display limit;
91
+ * `truncated` on the result is `true` when the cap actually bit, in which
92
+ * case rows exist that this view does not carry. This is the view
93
+ * {@link SweepRanking} applies to.
94
+ * - `'natural'` — every available row, untruncated, in deterministic `runIx`
95
+ * order. The view to read when materialising durable trial rows rather than
96
+ * showing a top-N, and the only way to reach rows the ranked view dropped —
97
+ * {@link Sweep.results} reads it off an existing sweep without re-running it.
98
+ * {@link SweepRanking} is **ignored** here and the response reports `'raw'`;
99
+ * rank, plateau score and neighbour count belong to the ranked view and are
100
+ * not part of this one.
101
+ */
102
+ export type SweepOrder = 'ranked' | 'natural';
103
+
104
+ /**
105
+ * Sweep lifecycle as observed by the SDK, readable off {@link Sweep.state}.
106
+ *
107
+ * - `'executing'` — submitted and being polled.
108
+ * - `'completed'` — finished. The platform's own status may still be `'PARTIAL'`.
109
+ * - `'failed'` — the poll itself failed: transport, HTTP, or a stage timeout.
110
+ * - `'canceled'` — the sweep was aborted and the platform reported it cancelled.
111
+ */
112
+ export type SweepState = 'executing' | 'completed' | 'failed' | 'canceled';
113
+
114
+ /**
115
+ * One strategy property and the values a sweep should try for it: either a
116
+ * numeric range walked in fixed steps, or an explicit list.
117
+ *
118
+ * ```ts
119
+ * const params: Record<string, ParamAxis> = {
120
+ * rsiPeriod: { from: 7, to: 28, step: 1 },
121
+ * useTrendFilter: { values: [true, false] },
122
+ * };
123
+ * ```
124
+ *
125
+ * The two shapes are mutually exclusive on the wire, and mixing them is a
126
+ * request the platform rejects rather than reconciles. A list entry is a number
127
+ * or a boolean — the axis of a boolean flag is `{ values: [true, false] }`, not
128
+ * a range.
129
+ */
130
+ export type ParamAxis =
131
+ | {
132
+ /** First value. */
133
+ from: number;
134
+ /** Last value the walk may reach. */
135
+ to: number;
136
+ /** Increment; must be greater than zero. */
137
+ step: number;
138
+ }
139
+ | {
140
+ /** The values to try; at least one. */
141
+ values: Array<number | boolean>;
142
+ };
143
+
144
+ /**
145
+ * Opt a sweep into walk-forward validation.
146
+ *
147
+ * Attaching this changes what the sweep does, not just how much of it runs.
148
+ * Instead of scoring every parameter vector once over the whole range, the data
149
+ * is cut into sequential folds; each fold optimizes the whole grid on its own
150
+ * window and then scores only its winner on the window immediately after — data
151
+ * that winner was never chosen on. The question it answers is not "which
152
+ * parameters won" but "does re-optimizing this periodically actually work".
153
+ *
154
+ * Omit it and nothing about the sweep changes, including the shape of the
155
+ * response.
156
+ *
157
+ * **It costs folds × grid.** Four folds over a 500-point grid is roughly 2000
158
+ * backtests where the plain sweep is 500, which is why it is opt-in. The
159
+ * platform rejects the request outright when that product exceeds its sweep
160
+ * budget.
161
+ *
162
+ * **It is a different sweep, not a variant of one.** Two requests that differ
163
+ * only in this block do not deduplicate against each other.
164
+ *
165
+ * The answer arrives as `walkForward` on the {@link SweepResult} — see
166
+ * {@link Sweep.result} for how to read it.
167
+ */
168
+ export interface SweepWalkForward {
169
+ /**
170
+ * How many sequential optimize-then-score windows to run. Two is the floor
171
+ * and the reason is structural rather than a tuning preference: parameter
172
+ * drift is measured between consecutive fold winners, and a single fold has
173
+ * no consecutive pair, so it would report the strongest possible stability
174
+ * having measured nothing. The ceiling is a platform setting; exceeding it is
175
+ * rejected.
176
+ */
177
+ folds: number;
178
+ /**
179
+ * Share of each fold's window spent optimizing, the rest being where its
180
+ * winner is scored. Omit to take the platform default. Lower values leave
181
+ * more data to score on and, on short sessions, are what let the requested
182
+ * fold count tile the data at all. Must be within 10..90.
183
+ */
184
+ inSamplePct?: number;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Platform models, re-exported so callers never have to reach for api-client
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * What the platform answered when it accepted the sweep, exactly as it sent it
193
+ * — see {@link Sweep.accepted} for the three fields that make it worth reading
194
+ * before any result exists.
195
+ */
196
+ export type SweepAccepted = ExecuteSweepAccepted;
197
+
198
+ /**
199
+ * A sweep snapshot: status, progress, and the rows available for the selected
200
+ * view. Resolved by {@link Sweep.result}, which is also where the semantics of
201
+ * every field are documented.
202
+ */
203
+ export type SweepResult = ExecuteSweepResult;
204
+
205
+ /**
206
+ * The platform's own progress record for a running sweep, carried on
207
+ * {@link SweepProgressEvent.snapshot}. Distinct from the event that wraps it:
208
+ * this is what the server reported, the event is what the SDK emitted.
209
+ *
210
+ * Two of its fields are easy to add together by mistake. `aborted` counts
211
+ * individual runs that executed and aborted — a row-level count. `failedShards`
212
+ * counts whole units of work (shards, or folds on a walk-forward sweep) that
213
+ * failed and will not be retried, having never reported anything. A shard that
214
+ * dies before producing a single row leaves `aborted` at zero, which is exactly
215
+ * why the second count exists; **summing them double-counts nothing and
216
+ * describes nothing**.
217
+ *
218
+ * `retrying` is not a failure count either — those units failed on something
219
+ * transient and are queued to be attempted again, so a sweep with a non-zero
220
+ * value there is still expected to finish.
221
+ *
222
+ * `etaSeconds` is **omitted, never zero** when it cannot be computed: a sweep
223
+ * with nothing finished has no observed rate to extrapolate from, and a zero
224
+ * would read as "about to finish". When present it runs conservative — it
225
+ * excludes queue wait entirely, and a sweep that spent part of its life being
226
+ * retried will have diluted the rate it is derived from.
227
+ */
228
+ export type SweepProgress = ApiSweepProgress;
229
+
230
+ /** One trial on a sweep's leaderboard. See {@link Sweep.result} for how to read it. */
231
+ export type SweepRunRow = ApiSweepRunRow;
232
+
233
+ /**
234
+ * Sensitivity aggregates over a sweep's stored rows, returned by
235
+ * {@link Sweep.sensitivity}. Marginals are always complete; the pair surfaces
236
+ * may be capped, in which case `heatmapsTruncated` is `true`.
237
+ */
238
+ export type SweepSensitivity = ApiSweepSensitivity;
239
+
240
+ /** One axis, with every other axis collapsed away. */
241
+ export type SweepMarginal = ApiSweepMarginal;
242
+
243
+ /** How the objective behaved at one value of one axis. */
244
+ export type SweepMarginalPoint = ApiSweepMarginalPoint;
245
+
246
+ /** The surface for one pair of axes, with all others collapsed away. */
247
+ export type SweepHeatmap = ApiSweepHeatmap;
248
+
249
+ /** One cell of a {@link SweepHeatmap}. */
250
+ export type SweepHeatmapCell = ApiSweepHeatmapCell;
251
+
252
+ /**
253
+ * The walk-forward section of a {@link SweepResult}, present exactly when the
254
+ * sweep was submitted with {@link SweepWalkForward}.
255
+ *
256
+ * **`paramDrift` absent is not zero.** The field is omitted whenever the figure
257
+ * could not be computed — fewer than two folds finished, or no stored grid to
258
+ * place the winners on — and zero is itself a meaningful reading there (winners
259
+ * that never moved), so a placeholder would be indistinguishable from perfect
260
+ * stability.
261
+ */
262
+ export type WalkForwardResult = ApiWalkForwardResult;
263
+
264
+ /**
265
+ * What one fold concluded. The out-of-sample row is the answer; the in-sample
266
+ * figure is only there to be compared against it, since any grid produces a
267
+ * flattering in-sample winner — that is what optimizing does. The gap between
268
+ * them is the whole reading.
269
+ */
270
+ export type WalkForwardFold = ApiWalkForwardFold;
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Request, options, progress
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * A parameter sweep over one instrument and one window: the same strategy run
278
+ * once per parameter vector, scored and ranked against a single objective.
279
+ *
280
+ * ```ts
281
+ * const request: SweepRequest = {
282
+ * strategy: source,
283
+ * exchangeId: 'binance',
284
+ * instrument: 'BTC/USDT',
285
+ * from: '2026-01-01T00:00:00Z',
286
+ * to: '2026-02-01T00:00:00Z',
287
+ * params: {
288
+ * rsiPeriod: { from: 7, to: 28, step: 1 },
289
+ * useTrendFilter: { values: [true, false] },
290
+ * },
291
+ * objective: 'sharpe',
292
+ * };
293
+ * ```
294
+ */
295
+ export interface SweepRequest {
296
+ /** Strategy source code (Java), compiled once and reused by every trial. */
297
+ strategy: string;
298
+ /** Exchange id, e.g. `binance`. */
299
+ exchangeId: string;
300
+ /** Instrument symbol, e.g. `BTC/USDT`. */
301
+ instrument: string;
302
+ /** Range start (ISO-8601, ISO DATE, or BASIC ISO DATE). */
303
+ from: string;
304
+ /** Range end (same formats as `from`; must be later than `from`). */
305
+ to: string;
306
+ /** The grid: one {@link ParamAxis} per strategy property to vary. At least one. */
307
+ params: Record<string, ParamAxis>;
308
+ /**
309
+ * How the grid becomes the list of vectors actually run. Omit to keep the
310
+ * platform default, the full cross product.
311
+ */
312
+ sampler?: SweepSampler;
313
+ /**
314
+ * How many vectors to draw for `'random'` and `'lhs'`; ignored by `'grid'`.
315
+ */
316
+ samples?: number;
317
+ /**
318
+ * Reproducibility seed. Omit to let the platform generate one and report it
319
+ * back on {@link Sweep.accepted}, so a randomly sampled sweep can be replayed
320
+ * exactly by submitting the same seed again.
321
+ */
322
+ seed?: number;
323
+ /**
324
+ * The metric to optimize and rank by; omit to keep the platform default
325
+ * (`'sharpe'`). It is also what {@link Sweep.sensitivity} aggregates unless
326
+ * told otherwise.
327
+ */
328
+ objective?: SweepObjective;
329
+ /**
330
+ * Opt into walk-forward validation, which changes both what runs and the
331
+ * shape of the answer. Omit to run an ordinary sweep.
332
+ */
333
+ walkForward?: SweepWalkForward;
334
+ }
335
+
336
+ /**
337
+ * Emitted at stage transitions and after each poll of a running sweep.
338
+ *
339
+ * The `snapshot` is where the detail lives — see {@link SweepProgress}, whose
340
+ * counts measure different things and must not be added together.
341
+ */
342
+ export interface SweepProgressEvent {
343
+ /** Current workflow stage. */
344
+ stage: BacktestStage;
345
+ /**
346
+ * 0-100, computed from the runs finished out of the runs expected (`done` /
347
+ * `total` on the snapshot, both run-level counts — not the shard counts,
348
+ * which partition units of work rather than runs). Absent on stage
349
+ * transitions before the first poll.
350
+ */
351
+ percent?: number;
352
+ /**
353
+ * Fraction (0-1) of the requested window that actually holds data, as
354
+ * reported once preparation completes. Present only on the final `preparing`
355
+ * event. Worth reading on a sweep in particular: a thinly covered window is
356
+ * about to be scored once per parameter vector.
357
+ */
358
+ coverageRatio?: number;
359
+ /**
360
+ * The platform's progress record for the sweep. Present only on `executing`
361
+ * events.
362
+ */
363
+ snapshot?: SweepProgress;
364
+ }
365
+
366
+ /** Tuning knobs for a {@link QTSurfer.sweep} invocation. */
367
+ export interface SweepOptions {
368
+ /**
369
+ * Ask the platform to stop the sweep between parameter vectors.
370
+ *
371
+ * **Aborting does not reject {@link Sweep.result}** — it resolves with
372
+ * whatever was scored before the stop. That is a deliberate divergence from
373
+ * `backtest()`, which rejects with `QTSCanceledError` when its run is
374
+ * aborted; see {@link Sweep.result} for why. Aborting *before* the platform
375
+ * has accepted the sweep — during compile, prepare or submission — rejects
376
+ * the {@link QTSurfer.sweep} call itself with `QTSCanceledError`, since there
377
+ * is no sweep yet and so no rows to keep.
378
+ */
379
+ signal?: AbortSignal;
380
+ /** Called on stage transitions and after each poll with updated progress. */
381
+ onProgress?: (p: SweepProgressEvent) => void;
382
+ /**
383
+ * Initial interval between polls. Default 2000ms, backed off up to
384
+ * `maxPollIntervalMs`. Longer than a single backtest's, because a sweep is
385
+ * many backtests and its leaderboard changes on the timescale of shards
386
+ * finishing rather than ticks.
387
+ */
388
+ pollIntervalMs?: number;
389
+ /** Upper bound for exponential backoff. Default 15000ms. */
390
+ maxPollIntervalMs?: number;
391
+ /**
392
+ * Per-stage timeout. Default none. A sweep is many backtests, so the execute
393
+ * stage legitimately outlasts anything a single run would take.
394
+ */
395
+ timeoutMs?: number;
396
+ /**
397
+ * How to order the leaderboard the poll reads. Omit to send no preference and
398
+ * take the platform default, which is `'plateau'` — so leaving this unset
399
+ * does **not** give you raw objective order. What was actually applied is
400
+ * reported on the result. **Ignored entirely when `order` is `'natural'`**,
401
+ * which is always ordered by `runIx`; the platform accepts both and answers
402
+ * with the ordering it applied.
403
+ */
404
+ ranking?: SweepRanking;
405
+ /**
406
+ * Which view of the rows the background poll reads. Omit to take the platform
407
+ * default, `'ranked'` — the sorted, display-capped leaderboard. `'natural'`
408
+ * returns every available row untruncated.
409
+ *
410
+ * This only decides what {@link Sweep.result} resolves with. Reading the same
411
+ * sweep another way afterwards — to reach the rows a `truncated` ranked view
412
+ * dropped, say — is {@link Sweep.results}, which re-reads rather than
413
+ * re-running.
414
+ */
415
+ order?: SweepOrder;
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Handle
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /**
423
+ * Handle for a running parameter sweep, returned by {@link QTSurfer.sweep} once
424
+ * the platform has accepted it. The leaderboard keeps being polled in the
425
+ * background.
426
+ */
427
+ export interface Sweep {
428
+ /** Server-side sweep identifier. */
429
+ readonly sweepId: string;
430
+ /**
431
+ * The prepared dataset every trial ran against — the prepare jobId the
432
+ * workflow resolved before submitting, which is also what addresses this
433
+ * sweep on the wire.
434
+ *
435
+ * This is the value the workflow prepared with, not the acceptance echo of
436
+ * it. {@link Sweep.accepted} carries the echo, unmodified, for anyone who
437
+ * wants to compare the two.
438
+ */
439
+ readonly requestId: string;
440
+ /** The compiled strategy every trial shares. */
441
+ readonly strategyId: string;
442
+ /**
443
+ * What acceptance already answered, before a single trial has run, exactly as
444
+ * the platform sent it.
445
+ *
446
+ * Three of its fields are the reason this is exposed rather than folded away:
447
+ *
448
+ * - `seed` — the effective seed, generated platform-side when the request
449
+ * omitted one. Submitting it again is what makes a randomly sampled sweep
450
+ * replayable.
451
+ * - `queued` — `false` means an identical sweep already existed and nothing
452
+ * new was enqueued. The handle is still valid and still resolves; it is
453
+ * just reading a sweep this call did not start.
454
+ * - `walkForward` — present exactly when this is a walk-forward sweep. It is
455
+ * the discriminator, and it is available here immediately, so code watching
456
+ * progress can branch on the answer's shape without waiting for
457
+ * {@link Sweep.result}.
458
+ */
459
+ readonly accepted: SweepAccepted;
460
+ /**
461
+ * Local snapshot of the sweep lifecycle; reading it does not contact the
462
+ * server.
463
+ */
464
+ readonly state: SweepState;
465
+ /**
466
+ * Resolves with the final leaderboard once the sweep stops advancing.
467
+ *
468
+ * **This resolves on every terminal status, cancellation included** —
469
+ * `'COMPLETED'`, `'PARTIAL'` and `'CANCELLED'` all hand back the result
470
+ * rather than raising. That is a deliberate divergence from `backtest()`,
471
+ * which rejects with `QTSCanceledError` when its run is aborted: cancelling a
472
+ * sweep is documented as leaving completed rows readable, and throwing them
473
+ * away would lose the only reason to cancel a sweep late rather than early.
474
+ * Read `status` to find out which of the three you got. The promise rejects
475
+ * only for transport failures, HTTP errors, and stage timeouts.
476
+ *
477
+ * `'PARTIAL'` means at least one unit of work died and its runs are simply
478
+ * missing. There is no failed status for a sweep as a whole, so a sweep whose
479
+ * every shard died is `'PARTIAL'` with an empty leaderboard — check
480
+ * `leaderboardSize` before reading anything into a top row.
481
+ *
482
+ * ### Reading the leaderboard
483
+ *
484
+ * **The default order is not the raw objective order.** It is plateau order,
485
+ * and `ranking` on the result says which was actually applied — not always
486
+ * the one requested, because a sweep with no stored parameter grid cannot be
487
+ * plateau-ranked and falls back to raw. See {@link SweepRanking}.
488
+ *
489
+ * **The default view is capped.** When `truncated` is `true`, rows exist that
490
+ * the leaderboard does not carry — `leaderboardSize` counts what is
491
+ * available. {@link Sweep.results} with `order: 'natural'` is what returns
492
+ * all of them, in `runIx` order and with no ranking applied. That is a
493
+ * re-read of this same sweep, not a second one.
494
+ *
495
+ * **`plateauScore` and `neighbourCount` are read together.** A neighbour
496
+ * count of `0` means the point had no neighbours in the grid to compare
497
+ * against, so its plateau score is unevidenced rather than confirmed — on its
498
+ * own it is indistinguishable from a genuinely robust one.
499
+ *
500
+ * **`deflatedSharpe`** is the probability that a row's Sharpe reflects real
501
+ * edge rather than the best draw from however many vectors were tried. Around
502
+ * 0.95 and up it survives the multiple-testing correction; near 0.5 or below
503
+ * it is not distinguishable from the best of a pile of coin flips. It is
504
+ * absent on aborted runs, and on sweeps with too few trials to establish any
505
+ * dispersion to deflate against.
506
+ *
507
+ * **`pbo`** is the probability of backtest overfitting for the sweep as a
508
+ * whole: how often the configuration that won in-sample lands below median
509
+ * out-of-sample. Above roughly 0.5 the sweep is selecting noise, and that
510
+ * verdict is about the search, not about any one row — a high value
511
+ * discredits the top row however good it looks. It is computed once the last
512
+ * unit of work finishes, so it is absent while the sweep is still running and
513
+ * on sweeps too small for the statistic to mean anything.
514
+ *
515
+ * ### A walk-forward sweep answers in a different shape
516
+ *
517
+ * `walkForward` is the discriminator, and it appears as soon as the sweep is
518
+ * accepted — before any fold has finished — so it is safe to branch on while
519
+ * polling (it is also on {@link Sweep.accepted}). When it is present, the
520
+ * leaderboard is one row per *completed fold*: that fold's winner as it
521
+ * scored out-of-sample, with **`runIx` carrying the fold index rather than a
522
+ * position in the grid**. No plateau score, deflated Sharpe or PBO figure is
523
+ * reported for one — the out-of-sample numbers are already the honest
524
+ * measurement. See {@link WalkForwardResult} for why an absent `paramDrift`
525
+ * is not a zero.
526
+ *
527
+ * ### An empty leaderboard is not always an empty answer
528
+ *
529
+ * A sweep can finish having scored nothing, because every shard failed before
530
+ * producing a row. When that happens `failReason` carries the cause reported
531
+ * by the *first* shard to fail — typically something the whole grid would have
532
+ * hit, such as a strategy that could not be loaded. Read it before concluding
533
+ * that a sweep with no rows simply found nothing: those are different
534
+ * outcomes and the leaderboard alone cannot tell them apart. Only the first
535
+ * failure is recorded, so where several shards failed for different reasons
536
+ * this names one of them rather than summarising all — pair it with
537
+ * `progress.failedShards` for the count.
538
+ */
539
+ readonly result: Promise<SweepResult>;
540
+ /**
541
+ * Re-read this sweep's rows under a different view.
542
+ *
543
+ * **This is a read, not a re-run.** It compiles nothing, prepares nothing and
544
+ * submits nothing: the same sweep is asked for its rows again with different
545
+ * query parameters, so no second sweep is created and nothing is enqueued.
546
+ * The view a {@link SweepOptions} chose applies to the background poll behind
547
+ * {@link Sweep.result}; this is how to look at the same sweep another way
548
+ * afterwards.
549
+ *
550
+ * **It is the route to rows the ranked view dropped.** When `truncated` is
551
+ * `true` on a result, rows exist that the leaderboard does not carry;
552
+ * `order: 'natural'` returns every available row untruncated, in
553
+ * deterministic `runIx` order.
554
+ *
555
+ * **`ranking` is ignored when `order` is `'natural'`** — that view is always
556
+ * ordered by `runIx`, and the response reports `'raw'`. The platform accepts
557
+ * both rather than rejecting the pair, and answers with the ordering it
558
+ * actually applied.
559
+ *
560
+ * Readable while the sweep is still running, in which case it returns the
561
+ * rows finished so far — exactly like {@link Sweep.sensitivity}. Like every
562
+ * handle-scoped call, it does not take part in an
563
+ * {@link AuthenticatedClient}'s refresh-on-401 policy.
564
+ *
565
+ * @param view which view to read; an absent property takes the platform
566
+ * default (`order: 'ranked'`, `ranking: 'plateau'`)
567
+ * @throws QTSError on any non-2xx response, with the HTTP status on `status`.
568
+ */
569
+ results(view?: { order?: SweepOrder; ranking?: SweepRanking }): Promise<SweepResult>;
570
+ /**
571
+ * How the objective moves as each parameter moves — the question a
572
+ * leaderboard cannot answer. A leaderboard says which point won; a sweep can
573
+ * spend its whole budget on an axis that never moved the objective at all,
574
+ * and the top rows hide that completely.
575
+ *
576
+ * A *marginal* takes one axis and collapses every other one: for each value
577
+ * of that axis it aggregates every run that used it, whatever the rest of the
578
+ * parameters were. A flat marginal means the axis did not matter over the
579
+ * range swept. `best`, `mean` and `worst` are all reported because them
580
+ * disagreeing is the signal — a value with a high best and a poor mean only
581
+ * works in specific company, which is an interaction, and a single number
582
+ * would hide it. A *heatmap* does the same over a pair of axes, where that
583
+ * interaction is visible directly.
584
+ *
585
+ * **Check `heatmapsTruncated`.** Marginals are always complete; the pair
586
+ * surfaces are quadratic in the axis count and may be capped to stay inside
587
+ * the response budget. When the flag is `true`, at least one pair was left
588
+ * out, so the list you have is not the full set of interactions. This method
589
+ * hands back the whole {@link SweepSensitivity} rather than just its surfaces
590
+ * precisely so that flag cannot be lost on the way out.
591
+ *
592
+ * Readable while the sweep is still running, in which case the aggregates
593
+ * describe the runs finished so far and `rowsAnalysed` says how many that
594
+ * was. Aborted runs are excluded throughout: a run that threw measured
595
+ * nothing, and counting it as a bad outcome would invent evidence against a
596
+ * parameter value that was never really tested.
597
+ *
598
+ * Like every handle-scoped call, this does not take part in an
599
+ * {@link AuthenticatedClient}'s refresh-on-401 policy.
600
+ *
601
+ * @param objective which metric to aggregate; omit to use the objective the
602
+ * sweep was submitted with
603
+ * @throws QTSError on any non-2xx response, with the HTTP status on `status`.
604
+ */
605
+ sensitivity(objective?: SweepObjective): Promise<SweepSensitivity>;
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Workflow
610
+ // ---------------------------------------------------------------------------
611
+
612
+ /** Initial interval between polls of a sweep's leaderboard. */
613
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
614
+ /** Backoff ceiling for a sweep's leaderboard poll. */
615
+ const DEFAULT_MAX_POLL_INTERVAL_MS = 15000;
616
+
617
+ /**
618
+ * Orchestrate compile → prepare → executeSweep, then poll the leaderboard until
619
+ * the sweep stops advancing.
620
+ *
621
+ * One call, not composable stages, and deliberately so: the execute-sweep
622
+ * endpoint is addressed by the id of an already-prepared dataset, so a
623
+ * stage-level API would hand dataset lifecycle to the caller for no gain.
624
+ * Preparing is idempotent — the same instrument and window always resolve to
625
+ * the same job — so preparing on every sweep duplicates no work.
626
+ */
627
+ export async function sweep(req: SweepRequest, opts: SweepOptions = {}): Promise<Sweep> {
628
+ validateRequest(req);
629
+ const policy = buildStagePolicy(opts, DEFAULT_POLL_INTERVAL_MS, DEFAULT_MAX_POLL_INTERVAL_MS);
630
+
631
+ opts.onProgress?.({ stage: 'compiling' });
632
+ const strategyId = await compileStrategySource(req.strategy, opts.signal);
633
+
634
+ opts.onProgress?.({ stage: 'preparing' });
635
+ const requestId = await prepareData(req, policy, opts);
636
+
637
+ opts.onProgress?.({ stage: 'executing' });
638
+ const { data, error } = await executeSweep({
639
+ path: { exchangeId: req.exchangeId, type: TICKER, requestId },
640
+ body: buildSweepBody(req, strategyId),
641
+ ...(opts.signal ? { signal: opts.signal } : {}),
642
+ });
643
+ if (error) throw new QTSExecutionError('Sweep submission failed', error);
644
+ if (!data?.sweepId) throw new QTSExecutionError('Missing sweepId in executeSweep response');
645
+
646
+ // The handle addresses the sweep with the requestId of the dataset just prepared — the value
647
+ // this workflow already knows — rather than the acceptance echo of it. `data` is stored as the
648
+ // server sent it; nothing is written back onto a generated response model.
649
+ return createHandle(req, opts, policy, data, requestId, strategyId);
650
+ }
651
+
652
+ /**
653
+ * Reject the request shapes the platform would only reject over the network.
654
+ * Same set as the sibling Java SDK validates, so both answer the same question
655
+ * the same way.
656
+ */
657
+ function validateRequest(req: SweepRequest): void {
658
+ const names = Object.keys(req.params ?? {});
659
+ if (names.length === 0) {
660
+ throw new QTSError('sweep: params must hold at least one axis');
661
+ }
662
+ for (const name of names) {
663
+ const axis = req.params[name];
664
+ if ('values' in axis) {
665
+ if (axis.values.length === 0) {
666
+ throw new QTSError(`sweep: axis "${name}" must hold at least one value`);
667
+ }
668
+ } else if (!(axis.step > 0)) {
669
+ throw new QTSError(`sweep: axis "${name}" needs step > 0, got ${axis.step}`);
670
+ }
671
+ }
672
+
673
+ const wf = req.walkForward;
674
+ if (!wf) return;
675
+ if (wf.folds < 2) {
676
+ throw new QTSError(`sweep: walkForward.folds must be >= 2, got ${wf.folds}`);
677
+ }
678
+ if (wf.inSamplePct !== undefined && (wf.inSamplePct < 10 || wf.inSamplePct > 90)) {
679
+ throw new QTSError(
680
+ `sweep: walkForward.inSamplePct must be within 10..90, got ${wf.inSamplePct}`,
681
+ );
682
+ }
683
+ }
684
+
685
+ function prepareData(
686
+ req: SweepRequest,
687
+ policy: StagePolicy,
688
+ opts: SweepOptions,
689
+ ): Promise<string> {
690
+ return prepareDataset(
691
+ {
692
+ exchangeId: req.exchangeId,
693
+ instrument: req.instrument,
694
+ from: req.from,
695
+ to: req.to,
696
+ },
697
+ policy,
698
+ {
699
+ ...(opts.signal ? { signal: opts.signal } : {}),
700
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
701
+ onPercent: (percent) => opts.onProgress?.({ stage: 'preparing', percent }),
702
+ // A thinly covered window is about to be scored once per parameter vector, so the
703
+ // coverage ratio is worth at least as much here as on a single backtest.
704
+ onPrepared: (state) =>
705
+ opts.onProgress?.({
706
+ stage: 'preparing',
707
+ percent: 100,
708
+ coverageRatio: state.coverageRatio,
709
+ }),
710
+ },
711
+ );
712
+ }
713
+
714
+ function buildSweepBody(req: SweepRequest, strategyId: string): ExecuteSweepRequest {
715
+ const spec: SweepSpecRequest = { params: req.params };
716
+ if (req.sampler !== undefined) spec.sampler = req.sampler;
717
+ if (req.samples !== undefined) spec.samples = req.samples;
718
+ if (req.seed !== undefined) spec.seed = req.seed;
719
+ if (req.objective !== undefined) spec.objective = req.objective;
720
+
721
+ const body: ExecuteSweepRequest = { strategyId, sweep: spec };
722
+ if (req.walkForward) {
723
+ body.walkForward = {
724
+ folds: req.walkForward.folds,
725
+ ...(req.walkForward.inSamplePct !== undefined
726
+ ? { inSamplePct: req.walkForward.inSamplePct }
727
+ : {}),
728
+ };
729
+ }
730
+ return body;
731
+ }
732
+
733
+ function createHandle(
734
+ req: SweepRequest,
735
+ opts: SweepOptions,
736
+ policy: StagePolicy,
737
+ accepted: SweepAccepted,
738
+ requestId: string,
739
+ strategyId: string,
740
+ ): Sweep {
741
+ const sweepId = accepted.sweepId;
742
+ const path = { exchangeId: req.exchangeId, type: TICKER, requestId, sweepId };
743
+ let state: SweepState = 'executing';
744
+
745
+ const withQuery = viewQuery(opts);
746
+
747
+ const result = (async () => {
748
+ try {
749
+ // Deliberately runs without `opts.signal`: aborting a sweep must not stop the poll, because
750
+ // the rows already scored are only reachable by polling on until the platform reports the
751
+ // sweep CANCELLED. The abort listener below asks the platform to stop instead.
752
+ const finalResult = await runStage(
753
+ policy,
754
+ async ({ signal }) => {
755
+ const res = await getSweepResult({ path, ...withQuery, signal });
756
+ if (res.error) throw new QTSExecutionError('Sweep result request failed', res.error);
757
+ if (!res.data) throw new QTSExecutionError('Empty sweep result response');
758
+ return res.data;
759
+ },
760
+ {
761
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
762
+ onEachAttempt: (r) => {
763
+ const percent = percentOf(r);
764
+ opts.onProgress?.({
765
+ stage: 'executing',
766
+ ...(percent !== undefined ? { percent } : {}),
767
+ snapshot: r.progress,
768
+ });
769
+ },
770
+ },
771
+ );
772
+ state = normalizeStatus(finalResult.status) === 'aborted' ? 'canceled' : 'completed';
773
+ return finalResult;
774
+ } catch (err) {
775
+ state = err instanceof QTSCanceledError ? 'canceled' : 'failed';
776
+ throw err;
777
+ }
778
+ })();
779
+ // A handle whose result is never awaited must not take the process down with an unhandled
780
+ // rejection; the caller still sees the failure on their own `await`.
781
+ void result.catch(() => undefined);
782
+
783
+ const { signal } = opts;
784
+ if (signal) {
785
+ const requestCancel = (): void => {
786
+ // Only ever 'executing' → 'canceled'; a cancel that lands after the last unit of work has
787
+ // finished changes nothing, and the poll's own completion settles the state either way.
788
+ if (state === 'executing') state = 'canceled';
789
+ void cancelSweep({ path }).catch(() => undefined);
790
+ };
791
+ if (signal.aborted) {
792
+ requestCancel();
793
+ } else {
794
+ signal.addEventListener('abort', requestCancel, { once: true });
795
+ const detach = (): void => signal.removeEventListener('abort', requestCancel);
796
+ void result.then(detach, detach);
797
+ }
798
+ }
799
+
800
+ return {
801
+ sweepId,
802
+ requestId,
803
+ strategyId,
804
+ accepted,
805
+ get state() {
806
+ return state;
807
+ },
808
+ result,
809
+ results: async (view = {}): Promise<SweepResult> => {
810
+ // A read of the sweep that already exists: same path, different query. Nothing here
811
+ // compiles, prepares or submits, so asking for the natural view costs one request rather
812
+ // than a second pipeline.
813
+ const res = await getSweepResult({ path, ...viewQuery(view) });
814
+ if (res.error) {
815
+ throw requestFailed('sweep results call', res.error, res.response?.status);
816
+ }
817
+ if (!res.data) throw new QTSError('Empty sweep result response');
818
+ return res.data;
819
+ },
820
+ sensitivity: async (objective?: SweepObjective): Promise<SweepSensitivity> => {
821
+ const res = await getSweepSensitivity({
822
+ path,
823
+ ...(objective !== undefined ? { query: { objective } } : {}),
824
+ });
825
+ if (res.error) {
826
+ throw requestFailed('sweep sensitivity call', res.error, res.response?.status);
827
+ }
828
+ if (!res.data) throw new QTSError('Empty sweep sensitivity response');
829
+ return res.data;
830
+ },
831
+ };
832
+ }
833
+
834
+ /**
835
+ * The `order` / `ranking` query for one view of a sweep's rows.
836
+ *
837
+ * `ranking` is sent as asked even alongside `order: 'natural'`, which ignores
838
+ * it: the platform accepts both and answers with the ordering it actually
839
+ * applied, which is more informative than the SDK silently dropping the
840
+ * preference. Left off entirely when neither is set, so a default read carries
841
+ * no query string and takes the platform's own defaults.
842
+ */
843
+ function viewQuery(view: { order?: SweepOrder; ranking?: SweepRanking }): {
844
+ query?: NonNullable<GetSweepResultData['query']>;
845
+ } {
846
+ const query: NonNullable<GetSweepResultData['query']> = {};
847
+ if (view.order !== undefined) query.order = view.order;
848
+ if (view.ranking !== undefined) query.ranking = view.ranking;
849
+ return Object.keys(query).length > 0 ? { query } : {};
850
+ }
851
+
852
+ /**
853
+ * Percentage of the sweep's runs that have finished. Deliberately computed from
854
+ * the run-level counts: the shard counts alongside them partition units of
855
+ * work, not runs, and mixing the two reports a percentage of neither.
856
+ */
857
+ function percentOf(result: SweepResult): number | undefined {
858
+ const p = result.progress;
859
+ if (!p || !(p.total > 0)) return undefined;
860
+ return (p.done / p.total) * 100;
861
+ }