outcometick 1.5.2 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/api/lib/backtest-contract.mjs +335 -16
- package/api/lib/backtest-datasets.mjs +125 -1
- package/api/lib/backtest-manifest.mjs +52 -4
- package/api/lib/coverage-window.mjs +63 -1
- package/api/lib/data-taxonomy.mjs +15 -1
- package/cli/commands/run.mjs +206 -37
- package/cli/commands/submit.mjs +40 -3
- package/cli/local-data.mjs +86 -15
- package/cli/ot.mjs +26 -3
- package/index.d.ts +31 -4
- package/package.json +1 -1
- package/runner/archive.mjs +24 -13
- package/runner/engine/book.mjs +12 -1
- package/runner/engine/feed.mjs +116 -0
- package/runner/engine/portfolio.mjs +77 -5
- package/runner/engine/replay.mjs +136 -14
- package/runner/engine/report.mjs +96 -34
- package/runner/events.mjs +788 -55
- package/runner/harness/node/harness.mjs +166 -13
- package/runner/harness/node/sdk/index.d.ts +31 -4
- package/runner/harness/node/sdk/index.mjs +46 -2
- package/runner/harness/protocol.mjs +31 -3
- package/runner/harness/python/harness.py +137 -19
- package/runner/harness/python/otengine.py +116 -9
- package/runner/harness/python/otfeed.py +109 -0
- package/runner/harness/python/otreplay.py +90 -8
- package/runner/harness/python/outcometick.py +48 -4
- package/runner/series-data.mjs +220 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
|
|
6
6
|
An edit made here survives until the next publish and then disappears.
|
|
7
7
|
|
|
8
|
-
Generated from monorepo revision
|
|
8
|
+
Generated from monorepo revision 5c26d7ed75e2172fff108af183089aa1460652ba.
|
|
9
9
|
-->
|
|
10
10
|
|
|
11
11
|
# outcometick
|
|
@@ -117,3 +117,10 @@ Writing your strategy in Python instead? The SDK for it is
|
|
|
117
117
|
those too — it is the one CLI for both languages.
|
|
118
118
|
|
|
119
119
|
Full reference: https://outcometick.com/docs/sdk
|
|
120
|
+
|
|
121
|
+
## Links
|
|
122
|
+
|
|
123
|
+
- [outcometick.com](https://outcometick.com) — what this is, and what the data covers
|
|
124
|
+
- [Run a backtest](https://outcometick.com/backtest) — paste a strategy, watch it run
|
|
125
|
+
- [SDK reference](https://outcometick.com/docs/sdk) — manifest, hooks, `ctx`, limits
|
|
126
|
+
- [Data API](https://outcometick.com/docs) — the archive these strategies read
|
|
@@ -15,7 +15,52 @@ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
|
|
|
15
15
|
export const SCHEMA_VERSION = 1;
|
|
16
16
|
|
|
17
17
|
/** SDK version reported by the docs page and stamped into every report. */
|
|
18
|
-
export const SDK_VERSION = '1.
|
|
18
|
+
export const SDK_VERSION = '1.6.2';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The tag of the sandbox images, and the ONLY place it is written down.
|
|
22
|
+
*
|
|
23
|
+
* The harness is baked into the image, so this tag is really a protocol
|
|
24
|
+
* version: worker and harness have to agree on how results come back. Bump it
|
|
25
|
+
* whenever that agreement changes, or a host still holding the previous images
|
|
26
|
+
* runs the old harness under the new worker — and the failure is silent. The
|
|
27
|
+
* run completes, produces no result line, and every job is refunded while
|
|
28
|
+
* looking like a strategy problem.
|
|
29
|
+
*
|
|
30
|
+
* 1.9.0: the per-event budget judges SUSTAINED cost — the mean — instead of the
|
|
31
|
+
* rate of events over the limit. The old rule measured the machine, not the
|
|
32
|
+
* strategy: the budget brackets each hook with two wall-clock reads, and the
|
|
33
|
+
* sandbox holds one vCPU on a two-core box while the worker decompresses and
|
|
34
|
+
* feeds stdin, so an interrupted event was recorded as an event the strategy
|
|
35
|
+
* spent milliseconds in. The page's own sample was rejected in production at an
|
|
36
|
+
* average of 72us against this 400us budget because 1.1% of its events had been
|
|
37
|
+
* descheduled. Measured in-image: the same strategy averages 7.8us idle and
|
|
38
|
+
* 20.9us under contention, worst case 766us and 4090us respectively — and the
|
|
39
|
+
* slow events are scattered, not front-loaded, so 1.8.0's higher floor could
|
|
40
|
+
* never have fixed it. The mean is what the wall clock is made of, and it is
|
|
41
|
+
* the thing customers were already told the limit means ("sustained breach").
|
|
42
|
+
* 1.8.0: the per-event budget no longer judges a strategy on its first 200
|
|
43
|
+
* events. That window is where lazy imports and every first call land, so a
|
|
44
|
+
* strategy that breached 7 times in 151,606 events — 0.005%, against a 1%
|
|
45
|
+
* tolerance — was killed because 3 of those 7 fell inside the sample. Both
|
|
46
|
+
* engines now need 2,000 events before the ratio means anything, and a
|
|
47
|
+
* conformance test pins the two defaults to the same number: this value decides
|
|
48
|
+
* whether a run is rejected, so a divergence is the same strategy passing in
|
|
49
|
+
* one language and failing in the other. THE FLOOR LIVES IN THE IMAGE, which is
|
|
50
|
+
* why this tag moves — a worker on the old image keeps judging at 200.
|
|
51
|
+
* 1.7.0: a book snapshot now carries only the side it is about, because
|
|
52
|
+
* Polymarket publishes one side per row and an empty ladder is a side the
|
|
53
|
+
* engine resets. Both harnesses track the last ask ladder per side and take the
|
|
54
|
+
* LOWEST ask rather than element zero — the archive sorts descending on one
|
|
55
|
+
* venue and ascending on the other, so an index read the worst offer on one of
|
|
56
|
+
* them.
|
|
57
|
+
* 1.6.0: the harness acknowledges each market-day it finishes replaying, on a
|
|
58
|
+
* new channel, so a watching page counts finished work instead of queued bytes.
|
|
59
|
+
* 1.5.0: results moved from fd 3 to the container's stdout. Docker never
|
|
60
|
+
* forwarded a fourth descriptor, so fd 3 was closed inside the container and no
|
|
61
|
+
* containerised run had ever returned anything.
|
|
62
|
+
*/
|
|
63
|
+
export const SANDBOX_IMAGE_TAG = '1.14.0';
|
|
19
64
|
|
|
20
65
|
// ---------------------------------------------------------------------------
|
|
21
66
|
// Languages
|
|
@@ -162,6 +207,81 @@ export const CAPTURE_WINDOWS = Object.freeze({
|
|
|
162
207
|
}),
|
|
163
208
|
});
|
|
164
209
|
|
|
210
|
+
/**
|
|
211
|
+
* How densely the venue's book stream was actually captured, over time.
|
|
212
|
+
*
|
|
213
|
+
* NOT PART OF THE CONTRACT DOCUMENT, and deliberately absent from every
|
|
214
|
+
* customer-facing surface of the backtest. The data product discloses its own
|
|
215
|
+
* capture cadence — that is the archive's business, and chainlink-data's guide
|
|
216
|
+
* states it. A backtest is a different promise: it says "here is what your
|
|
217
|
+
* strategy would have done", and a reader should not have to hold a table of
|
|
218
|
+
* sampling rates in their head to know whether the first half of their report
|
|
219
|
+
* is comparable to the second.
|
|
220
|
+
*
|
|
221
|
+
* So this exists to REMOVE the difference rather than to report it. See
|
|
222
|
+
* bookThrottleMs: EACH ASSET in a run is replayed at the coarsest cadence its
|
|
223
|
+
* own date range contains, so no asset changes density partway through and no
|
|
224
|
+
* boundary is ever visible. Per asset, not per run — see makeBookThrottle for
|
|
225
|
+
* why levelling a whole basket to its coarsest member was rejected.
|
|
226
|
+
*
|
|
227
|
+
* MIRRORS PRICE_CHANGE_THROTTLE_HISTORY and PREDICT_BOOK_THROTTLE_HISTORY in
|
|
228
|
+
* chainlink-data (scripts/lib/delivery.mjs). Two copies of a fact drift — this
|
|
229
|
+
* repo has the scars — so if this is ever wrong the symptom is a backtest
|
|
230
|
+
* quietly replaying at the wrong density, which nothing else would catch. The
|
|
231
|
+
* dates are settled history and do not move; a NEW entry is the only edit this
|
|
232
|
+
* should ever need, and it has to be made in both places on the same day.
|
|
233
|
+
*
|
|
234
|
+
* `from` is the first day the entry applies to. The collector dates a change to
|
|
235
|
+
* the day AFTER it was deployed, because the deploy day is mixed and claiming
|
|
236
|
+
* the finer cadence for it would promise more than the archive holds.
|
|
237
|
+
*/
|
|
238
|
+
const BOOK_CAPTURE = Object.freeze({
|
|
239
|
+
polymarket: Object.freeze([
|
|
240
|
+
Object.freeze({ from: '2026-06-06', defaultMs: 500, perAsset: Object.freeze({}) }),
|
|
241
|
+
Object.freeze({ from: '2026-08-25', defaultMs: 500, perAsset: Object.freeze({ BTC: 20, ETH: 100 }) }),
|
|
242
|
+
]),
|
|
243
|
+
predict: Object.freeze([
|
|
244
|
+
Object.freeze({ from: '2026-06-12', defaultMs: 1000, perAsset: Object.freeze({}) }),
|
|
245
|
+
// 0 means every upstream frame was archived.
|
|
246
|
+
Object.freeze({ from: '2026-08-25', defaultMs: 0, perAsset: Object.freeze({}) }),
|
|
247
|
+
]),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The cadence ONE ASSET replays at within a run: the COARSEST its date range
|
|
252
|
+
* contains. Ask per asset; there is no run-wide answer by design.
|
|
253
|
+
*
|
|
254
|
+
* A range that crosses a change gets the older, sparser setting for all of it.
|
|
255
|
+
* That is the only choice that makes a single report self-consistent without
|
|
256
|
+
* making the reader aware of anything: fine days are thinned to match coarse
|
|
257
|
+
* ones, rather than a report where the same strategy fills differently in its
|
|
258
|
+
* first month than its last and nothing says why.
|
|
259
|
+
*
|
|
260
|
+
* Returns 0 when nothing in the range was throttled, which means "replay every
|
|
261
|
+
* row" and is the case the whole mechanism disappears in.
|
|
262
|
+
*/
|
|
263
|
+
export function bookThrottleMs({ venue, asset, from, to }) {
|
|
264
|
+
const history = BOOK_CAPTURE[venue];
|
|
265
|
+
if (!history) return 0;
|
|
266
|
+
const a = String(asset ?? '').toUpperCase();
|
|
267
|
+
let coarsest = 0;
|
|
268
|
+
for (let i = 0; i < history.length; i += 1) {
|
|
269
|
+
const entry = history[i];
|
|
270
|
+
const next = history[i + 1];
|
|
271
|
+
// Does [from, to] overlap the window this entry governs?
|
|
272
|
+
if (to < entry.from) continue;
|
|
273
|
+
if (next && from >= next.from) continue;
|
|
274
|
+
// `'*'` asks for the coarsest window ANY asset had — the answer for a row
|
|
275
|
+
// whose asset could not be determined, which must never be thinned less
|
|
276
|
+
// than the rows that could be.
|
|
277
|
+
const ms = a === '*'
|
|
278
|
+
? Math.max(entry.defaultMs, ...Object.values(entry.perAsset))
|
|
279
|
+
: (entry.perAsset[a] ?? entry.defaultMs);
|
|
280
|
+
if (ms > coarsest) coarsest = ms;
|
|
281
|
+
}
|
|
282
|
+
return coarsest;
|
|
283
|
+
}
|
|
284
|
+
|
|
165
285
|
// ---------------------------------------------------------------------------
|
|
166
286
|
// Reference feeds (external data, resolved before the run)
|
|
167
287
|
// ---------------------------------------------------------------------------
|
|
@@ -172,18 +292,34 @@ export const CAPTURE_WINDOWS = Object.freeze({
|
|
|
172
292
|
* reports on different days. Feeds are resolved into a dataset ahead of the run
|
|
173
293
|
* and replayed on the same clock as everything else.
|
|
174
294
|
*
|
|
175
|
-
* Binance
|
|
176
|
-
*
|
|
295
|
+
* Binance publishes these at data.binance.vision; scripts/fetch-binance.mjs
|
|
296
|
+
* pulls them onto the worker ahead of time.
|
|
297
|
+
*
|
|
298
|
+
* EVERY ENTRY HERE IS ONE BINANCE ACTUALLY PUBLISHES. That is not a truism: the
|
|
299
|
+
* first version of this table offered `spot:100ms` and `perp:1s`, and Binance
|
|
300
|
+
* publishes neither — no 100ms klines at all, and futures klines stop at 1m. A
|
|
301
|
+
* strategy declaring one would have passed validation, queued, been billed, and
|
|
302
|
+
* received an empty feed with no error. Checked against the archive, not
|
|
303
|
+
* assumed; `scripts/fetch-binance.mjs --check` re-checks.
|
|
177
304
|
*/
|
|
178
305
|
export const REFERENCE_FEEDS = Object.freeze({
|
|
306
|
+
// spot/daily/klines/<SYM>/1s/ and /1m/.
|
|
179
307
|
'binance:{symbol}:spot:1s': Object.freeze({ kind: 'klines', market: 'spot', interval: '1s' }),
|
|
180
|
-
'binance:{symbol}:spot:
|
|
181
|
-
'binance:{symbol}:perp:1s': Object.freeze({ kind: 'klines', market: 'perp', interval: '1s' }),
|
|
182
|
-
'binance:{symbol}:funding': Object.freeze({ kind: 'funding', market: 'perp', interval: null }),
|
|
308
|
+
'binance:{symbol}:spot:1m': Object.freeze({ kind: 'klines', market: 'spot', interval: '1m' }),
|
|
183
309
|
});
|
|
184
310
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Symbols we carry a reference feed for — the assets we sell backtests on that
|
|
313
|
+
* Binance also lists ON SPOT.
|
|
314
|
+
*
|
|
315
|
+
* No HYPE: Binance has no HYPEUSDT spot pair. It has a perp one, and an earlier
|
|
316
|
+
* version of this table offered perp feeds for that reason — dropped, because
|
|
317
|
+
* spot is what these strategies are pricing against and a perp mark is a
|
|
318
|
+
* different number wearing the same name.
|
|
319
|
+
*/
|
|
320
|
+
export const REFERENCE_SYMBOLS = Object.freeze([
|
|
321
|
+
'btcusdt', 'ethusdt', 'solusdt', 'xrpusdt', 'bnbusdt', 'dogeusdt',
|
|
322
|
+
]);
|
|
187
323
|
|
|
188
324
|
/** `binance:btcusdt:spot:1s` -> {feed, symbol} or null if it is not a feed we carry. */
|
|
189
325
|
export function parseReferenceFeed(name) {
|
|
@@ -209,9 +345,52 @@ export function parseReferenceFeed(name) {
|
|
|
209
345
|
* single ordered stream — it cannot be sharded, so it runs slower and bills at
|
|
210
346
|
* a multiple.
|
|
211
347
|
*/
|
|
348
|
+
/**
|
|
349
|
+
* The two ways a strategy sees the range. NEITHER COSTS MORE THAN THE OTHER.
|
|
350
|
+
*
|
|
351
|
+
* `session` used to bill at 3x, on the stated grounds that it "cannot be
|
|
352
|
+
* sharded, so it runs slower". That reason was not true: `shardable` is read
|
|
353
|
+
* nowhere outside this table and its tests — `market` mode has never actually
|
|
354
|
+
* been sharded, so both modes occupy one worker for the same time. Charging
|
|
355
|
+
* three times for a cost difference that does not exist is the one thing a
|
|
356
|
+
* product sold on honest reporting cannot do. Owner's call, 2026-08-25: the
|
|
357
|
+
* multiplier is gone. A market-day costs a credit, whichever mode reads it.
|
|
358
|
+
*
|
|
359
|
+
* If sharding is ever built, price it then — from the difference it actually
|
|
360
|
+
* makes, not from the difference it was supposed to make.
|
|
361
|
+
*/
|
|
362
|
+
/**
|
|
363
|
+
* The market intervals a backtest can ask for.
|
|
364
|
+
*
|
|
365
|
+
* A CLOSED set, and short on purpose: the archive contains prediction markets
|
|
366
|
+
* at 5m and 15m and at no other length. Everything else the catalog carries at
|
|
367
|
+
* other intervals -- 1s through 1mo -- is klines, which is Binance spot price
|
|
368
|
+
* data, not a market anyone can take a position in.
|
|
369
|
+
*
|
|
370
|
+
* The default is 5m alone rather than both, because the two settle on the same
|
|
371
|
+
* stream but behave nothing alike, and a run that quietly mixed them was
|
|
372
|
+
* answering a question nobody asked.
|
|
373
|
+
*/
|
|
374
|
+
/**
|
|
375
|
+
* The fill delay a run may ask the latency panel to re-price at.
|
|
376
|
+
*
|
|
377
|
+
* ONE delay, and OFF BY DEFAULT. Each one is another full replay of the range:
|
|
378
|
+
* with the five that used to be built in, five sixths of a run's wall clock
|
|
379
|
+
* went on a six-row table nobody had asked for, and a sixteen market-day run
|
|
380
|
+
* could not finish inside its twenty-minute budget at all.
|
|
381
|
+
*
|
|
382
|
+
* The panel is worth having -- a strategy that only makes money at zero
|
|
383
|
+
* latency loses it in production -- but it is a question the submitter asks,
|
|
384
|
+
* one delay at a time, rather than one every run answers by default.
|
|
385
|
+
*/
|
|
386
|
+
export const MAX_LATENCY_MS = 10_000;
|
|
387
|
+
|
|
388
|
+
export const MARKET_INTERVALS = Object.freeze(['5m', '15m']);
|
|
389
|
+
export const DEFAULT_INTERVALS = Object.freeze(['5m']);
|
|
390
|
+
|
|
212
391
|
export const MODES = Object.freeze({
|
|
213
|
-
market: Object.freeze({ shardable: true
|
|
214
|
-
session: Object.freeze({ shardable: false
|
|
392
|
+
market: Object.freeze({ shardable: true }),
|
|
393
|
+
session: Object.freeze({ shardable: false }),
|
|
215
394
|
});
|
|
216
395
|
|
|
217
396
|
export const KNOWN_MODES = Object.freeze(Object.keys(MODES));
|
|
@@ -229,18 +408,152 @@ export const LIMITS = Object.freeze({
|
|
|
229
408
|
maxFiles: 6,
|
|
230
409
|
maxTotalSourceBytes: 256 * 1024,
|
|
231
410
|
maxFileNameLength: 96,
|
|
232
|
-
|
|
411
|
+
// A series is the submitter's own CSV, and it rides in with the source — so
|
|
412
|
+
// its budget has to be its own. It used to be 32MB while the whole submission
|
|
413
|
+
// was capped at 256KB, which meant the advertised number was thirty times
|
|
414
|
+
// what could actually be sent: a 90-day minute series is ~2.7MB and 256KB is
|
|
415
|
+
// eight days of one.
|
|
416
|
+
//
|
|
417
|
+
// 8MB is 90 days of minute data on four series with room over. Kept well
|
|
418
|
+
// under a body a JSON parse can hold comfortably, because these arrive as
|
|
419
|
+
// strings in the submit payload.
|
|
420
|
+
// 4MB, sized on the longest range we actually sell: 90 days of minute data is
|
|
421
|
+
// ~2.7MB. 8MB was room for nothing anyone can ask for, and it doubled the
|
|
422
|
+
// request body the API has to hold — on a box with 800MB free that also
|
|
423
|
+
// carries live Stripe keys and the production database tunnel.
|
|
424
|
+
//
|
|
425
|
+
// This is the interim number. A series belongs in R2 by a presigned PUT from
|
|
426
|
+
// the browser, not in a JSON body routed through this machine; when that
|
|
427
|
+
// lands, the body limit goes back to 16kb and this cap stops mattering.
|
|
428
|
+
maxSeriesBytes: 4 * 1024 * 1024,
|
|
233
429
|
maxSeriesCount: 4,
|
|
234
430
|
perEventBudgetMicros: 400,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
431
|
+
/**
|
|
432
|
+
* What `ctx.log` may emit, per line and per run.
|
|
433
|
+
*
|
|
434
|
+
* ctx.log is for reading, not for exporting. The limit used to be 10,000
|
|
435
|
+
* lines PER MARKET with no length cap at all — and polymarket has ~386
|
|
436
|
+
* markets a day, so a run could emit millions of arbitrarily long lines into
|
|
437
|
+
* logs.txt, which the customer then downloads. That is the archive itself
|
|
438
|
+
* leaving through a channel priced at nothing.
|
|
439
|
+
*
|
|
440
|
+
* The numbers are chosen against the subscription, which is what the same
|
|
441
|
+
* bytes cost through the front door: $5/month for 30 rolling days across
|
|
442
|
+
* seven assets is $0.0238 per market-day. A decoded market-day is 573 MB, so
|
|
443
|
+
* at a 2 MB budget, exporting one through the log channel takes 287 runs —
|
|
444
|
+
* about $2.87 of credits, or 121x what it costs to simply buy it. And 287
|
|
445
|
+
* repeat purchases of the same market-day by one subject is the loudest
|
|
446
|
+
* pattern in the ledger.
|
|
447
|
+
*
|
|
448
|
+
* Legitimate use is nowhere near it: sixteen market-days logging one line per
|
|
449
|
+
* market is 0.59 MB, which leaves more than triple the headroom.
|
|
450
|
+
*/
|
|
451
|
+
logLineChars: 512,
|
|
452
|
+
logBytesPerRun: 2 * 1024 * 1024,
|
|
453
|
+
// What the worker box actually has, not what sounds generous. It is a 2 vCPU
|
|
454
|
+
// / 4 GB VPS: `--cpus=4` is refused outright by the daemon ("range of CPUs is
|
|
455
|
+
// from 0.01 to 2.00"), so the advertised 4 vCPU / 8 GB could never have run a
|
|
456
|
+
// single container. Nobody bought under those numbers — credits have no
|
|
457
|
+
// Stripe price yet — but /docs/sdk was printing them.
|
|
458
|
+
//
|
|
459
|
+
// 1 vCPU is not a cut, it is the truth: a strategy is single-threaded by
|
|
460
|
+
// construction (the analysers reject threading and Worker, and the images
|
|
461
|
+
// pin OMP_NUM_THREADS and friends to 1), so the second core was never
|
|
462
|
+
// reachable from inside. It stays with the worker, which has to keep feeding
|
|
463
|
+
// events down stdin while the sandbox runs. 2 GB leaves room for the worker,
|
|
464
|
+
// the docker daemon and gVisor's own footprint.
|
|
465
|
+
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
466
|
+
vcpu: 1,
|
|
467
|
+
// The REPLAY budget: it starts when the strategy does, not when the run is
|
|
468
|
+
// leased. Downloading the archive is our pipe being slow, not the customer's
|
|
469
|
+
// strategy being slow, and charging their execution budget for our network
|
|
470
|
+
// is backwards — a 30-day run spent all twenty minutes fetching and was
|
|
471
|
+
// killed without replaying an event.
|
|
472
|
+
//
|
|
473
|
+
// SIZED PER RUN, not a constant — see wallClockMsFor below. A flat twenty
|
|
474
|
+
// minutes was a limit on the SMALLEST run that could not finish: replay costs
|
|
475
|
+
// ~24s per market-day (measured, warm cache, 289 markets and ~1.04M events in
|
|
476
|
+
// a polymarket BTC day), so twenty minutes covers about fifty of them while
|
|
477
|
+
// the page was selling a ninety-day chip. The customer paid, watched it work
|
|
478
|
+
// for twenty minutes, and got a refund and no report.
|
|
479
|
+
//
|
|
480
|
+
// These two are the inputs to that function and the only numbers to tune.
|
|
481
|
+
replayBaseMs: 3 * 60 * 1000,
|
|
482
|
+
replayPerMarketDayMs: 35 * 1000,
|
|
483
|
+
// The FETCH budget, separate and bounded. Not unbounded, because there is one
|
|
484
|
+
// worker and one slot: a stalled R2 read used to sit inside the fetch while
|
|
485
|
+
// the heartbeat kept renewing the lease, so nobody could reclaim the run and
|
|
486
|
+
// the customer's credits stayed held on a wedged machine. That incident is
|
|
487
|
+
// why the clock covers the fetch at all; this keeps the bound and stops it
|
|
488
|
+
// being taken out of the strategy's time.
|
|
489
|
+
//
|
|
490
|
+
// 60, MEASURED, and sized for the DEFAULT range rather than the longest one.
|
|
491
|
+
// A polymarket BTC market-day is ~112 MB and R2 to the worker runs 1.9–3.5
|
|
492
|
+
// MB/s, so cold: 30 days is 27–50 minutes, 60 days is 54–98, 90 days is
|
|
493
|
+
// 81–147. No budget covers 90 days cold without letting one run hold the
|
|
494
|
+
// only worker slot for over two hours, so the honest position is that long
|
|
495
|
+
// ranges depend on the cache being warm — which is what the prewarm is for.
|
|
496
|
+
// A cold long run fails inside its budget and is refunded in full, rather
|
|
497
|
+
// than being allowed to monopolise the queue.
|
|
498
|
+
fetchClockMs: 60 * 60 * 1000,
|
|
239
499
|
maxParams: 64,
|
|
240
500
|
maxSweepCells: 256,
|
|
241
|
-
archiveRetentionDays:
|
|
501
|
+
archiveRetentionDays: 7,
|
|
242
502
|
});
|
|
243
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The longest range one run may cover, in CALENDAR DAYS.
|
|
506
|
+
*
|
|
507
|
+
* The product limit. Checked against the days that actually exist in the
|
|
508
|
+
* archive — the range AFTER it is intersected — not against what was asked
|
|
509
|
+
* for: requesting more days than exist has always been fine and is billed for
|
|
510
|
+
* what was there, and moving the check earlier would hard-fail a page whose
|
|
511
|
+
* capacity figure is a few minutes stale.
|
|
512
|
+
*
|
|
513
|
+
* Enforced in the API and mirrored in the editor, so nobody can build a
|
|
514
|
+
* submission the queue will refuse.
|
|
515
|
+
*
|
|
516
|
+
* Raising it is a hardware decision, not a config one: there is one worker
|
|
517
|
+
* slot and a run holds it for its whole life.
|
|
518
|
+
*/
|
|
519
|
+
export const MAX_BACKTEST_DAYS = 90;
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The clamp on the REPLAY BUDGET's input — not a limit on what may be run.
|
|
523
|
+
*
|
|
524
|
+
* The budget below grows with market-days because that is what the machine
|
|
525
|
+
* spends time on, and market-days are days × assets × intervals: ninety days
|
|
526
|
+
* of one asset is 90, ninety days of seven assets over two intervals is 1,260.
|
|
527
|
+
* Without a clamp the second would be handed an eight-hour budget and would
|
|
528
|
+
* hold the only worker slot for a working day.
|
|
529
|
+
*
|
|
530
|
+
* So a run larger than this still RUNS — it simply is not given proportionally
|
|
531
|
+
* more time, and if it cannot finish it is refunded in full like any other
|
|
532
|
+
* overrun. That is the honest failure: bounded queue damage, money back.
|
|
533
|
+
*/
|
|
534
|
+
export const BUDGET_CLAMP_MARKET_DAYS = 180;
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* How long a run's REPLAY may take, given its size.
|
|
538
|
+
*
|
|
539
|
+
* Derived rather than declared so the limit and the thing it limits cannot
|
|
540
|
+
* drift: `MAX_BACKTEST_DAYS` decides how long a range can be, this decides how long
|
|
541
|
+
* that size is allowed to take, and both come from the two constants in LIMITS.
|
|
542
|
+
*
|
|
543
|
+
* Sized on the WARM path (~24s/market-day measured) plus margin, because the
|
|
544
|
+
* fetch has its own budget — `fetchClockMs` — and a slow archive read is our
|
|
545
|
+
* pipe being slow, not the strategy. A run whose days are cold spends that
|
|
546
|
+
* time under the fetch clock and arrives here with the same work to do.
|
|
547
|
+
*/
|
|
548
|
+
export function wallClockMsFor(marketDays) {
|
|
549
|
+
const n = Number.isFinite(marketDays) && marketDays > 0 ? Math.ceil(marketDays) : 1;
|
|
550
|
+
return LIMITS.replayBaseMs
|
|
551
|
+
+ LIMITS.replayPerMarketDayMs * Math.min(n, BUDGET_CLAMP_MARKET_DAYS);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The ceiling that follows from the numbers above. For copy and for docs. */
|
|
555
|
+
export const MAX_WALL_CLOCK_MS = wallClockMsFor(BUDGET_CLAMP_MARKET_DAYS);
|
|
556
|
+
|
|
244
557
|
// ---------------------------------------------------------------------------
|
|
245
558
|
// Rejection codes
|
|
246
559
|
// ---------------------------------------------------------------------------
|
|
@@ -313,6 +626,12 @@ export function contractDocument() {
|
|
|
313
626
|
referenceSymbols: [...REFERENCE_SYMBOLS],
|
|
314
627
|
modes: MODES,
|
|
315
628
|
limits: LIMITS,
|
|
629
|
+
// THE CEILINGS A CLIENT HAS TO KNOW BEFORE IT BUILDS A REQUEST. They are
|
|
630
|
+
// not in LIMITS because LIMITS describes the sandbox — what one strategy
|
|
631
|
+
// gets — and these describe what one RUN may ask for. A client that cannot
|
|
632
|
+
// read them discovers them as a 422 on the paid path.
|
|
633
|
+
maxBacktestDays: MAX_BACKTEST_DAYS,
|
|
634
|
+
maxMarketDays: BUDGET_CLAMP_MARKET_DAYS,
|
|
316
635
|
rejectionCodes: REJECTION_CODES,
|
|
317
636
|
};
|
|
318
637
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { classifyPath } from './data-taxonomy.mjs';
|
|
12
12
|
import {
|
|
13
13
|
CAPTURE_WINDOWS, DERIVED_DATASETS, KNOWN_DATASETS, BacktestRejection,
|
|
14
|
+
MARKET_INTERVALS, DEFAULT_INTERVALS, MAX_LATENCY_MS,
|
|
14
15
|
} from './backtest-contract.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -190,6 +191,56 @@ export function archiveDatasetsFor({ datasets, venue, from, to }) {
|
|
|
190
191
|
return [...out].sort();
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The settlement files a day's markets need, beyond what the strategy asked for.
|
|
196
|
+
*
|
|
197
|
+
* ALWAYS FED, like `markets`, and for the same reason: which stream a market
|
|
198
|
+
* settled on is a property of that market, so it cannot be known before the
|
|
199
|
+
* markets are read — and a run without it has no ticks at all. A strategy
|
|
200
|
+
* declaring `datasets: ["prices"]` was handed the 1 Hz feed while every market
|
|
201
|
+
* settled on twap60s, so nothing reached the engine and the day came back
|
|
202
|
+
* empty.
|
|
203
|
+
*
|
|
204
|
+
* SHARED, because the worker and `ot run` must select the same files. They
|
|
205
|
+
* already share the decoder; a feed list computed twice is the same drift in a
|
|
206
|
+
* different place, and it shows up as "it works locally but not in the queue"
|
|
207
|
+
* — or worse, the reverse.
|
|
208
|
+
*
|
|
209
|
+
* @param {Iterable} markets normalised records, from indexMarkets
|
|
210
|
+
* @param {string[]} paths every archive path available for the day
|
|
211
|
+
* @param {object} opts venue, the assets in scope, and the paths already chosen
|
|
212
|
+
*/
|
|
213
|
+
export function settlementPathsFor(markets, paths, { venue, assets, already = [] }) {
|
|
214
|
+
const need = new Set();
|
|
215
|
+
for (const m of markets) if (m?.stream) need.add(m.stream);
|
|
216
|
+
if (need.size === 0) return [];
|
|
217
|
+
const have = new Set(already);
|
|
218
|
+
const inScope = new Set((assets ?? []).map((a) => String(a).toUpperCase()));
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const p of paths) {
|
|
221
|
+
if (have.has(p)) continue;
|
|
222
|
+
const c = classifyPath(p);
|
|
223
|
+
if (c.venue !== venue || !need.has(c.dataset)) continue;
|
|
224
|
+
if (c.asset && inScope.size && !inScope.has(String(c.asset).toUpperCase())) continue;
|
|
225
|
+
out.push(p);
|
|
226
|
+
}
|
|
227
|
+
return out.sort();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The order a day's archive files are read in, fixed by path.
|
|
232
|
+
*
|
|
233
|
+
* NOT cosmetic. Events stamped the same millisecond are ordered by kind, and
|
|
234
|
+
* ties beyond that fall back to the order they were read — which was the
|
|
235
|
+
* catalog's order for the queue and the directory walk's for `ot run`. Two
|
|
236
|
+
* readers, two orders, one archive, two different reports. Sorting by path
|
|
237
|
+
* makes the input order a property of the archive rather than of whoever is
|
|
238
|
+
* reading it.
|
|
239
|
+
*/
|
|
240
|
+
export function orderedFeed(paths) {
|
|
241
|
+
return [...paths].sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
193
244
|
/**
|
|
194
245
|
* Does an archived file belong to this run's scope?
|
|
195
246
|
*
|
|
@@ -198,15 +249,72 @@ export function archiveDatasetsFor({ datasets, venue, from, to }) {
|
|
|
198
249
|
* A backtest reads files the submitter has not bought, which is the product —
|
|
199
250
|
* so this filter must never be mistaken for an authorisation check.
|
|
200
251
|
*/
|
|
201
|
-
export function fileMatchesRun(filePath, { venue, assets, archiveDatasets }) {
|
|
252
|
+
export function fileMatchesRun(filePath, { venue, assets, archiveDatasets, intervals }) {
|
|
202
253
|
const meta = classifyPath(filePath);
|
|
203
254
|
if (meta.venue !== venue) return false;
|
|
204
255
|
if (!archiveDatasets.includes(meta.dataset)) return false;
|
|
205
256
|
// Venue-wide datasets (markets) carry no asset and are always in scope.
|
|
206
257
|
if (meta.asset && assets?.length && !assets.includes(meta.asset)) return false;
|
|
258
|
+
// The interval narrowing applies ONLY to files that have an interval.
|
|
259
|
+
//
|
|
260
|
+
// The settlement streams -- prices, twap30s, twap60s -- are interval:null,
|
|
261
|
+
// and they are not a choice: without one, every market in the day fails to
|
|
262
|
+
// settle, every market is unusable, and the whole run is dropped and
|
|
263
|
+
// refunded. Silently, because "no usable market" is indistinguishable from
|
|
264
|
+
// "the archive had nothing". So a null interval is always in scope, and only
|
|
265
|
+
// a file that actually declares one has to match.
|
|
266
|
+
if (meta.interval && intervals?.length && !intervals.includes(meta.interval)) return false;
|
|
207
267
|
return true;
|
|
208
268
|
}
|
|
209
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Validate the declared latency comparison, returning it normalised.
|
|
272
|
+
*
|
|
273
|
+
* ONE delay in milliseconds, or null for none — which is the default. Each
|
|
274
|
+
* delay replays the whole range again, so this is deliberately not a list:
|
|
275
|
+
* see MAX_LATENCY_MS.
|
|
276
|
+
*/
|
|
277
|
+
export function normalizeLatency(value) {
|
|
278
|
+
if (value == null) return null;
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
// Said plainly rather than by silently taking the first: a manifest that
|
|
281
|
+
// asked for three delays and got one would be a run priced and timed for
|
|
282
|
+
// something the submitter did not write.
|
|
283
|
+
throw new BacktestRejection('E_MANIFEST',
|
|
284
|
+
'latency is a single delay in milliseconds, not a list — each delay replays the whole range again');
|
|
285
|
+
}
|
|
286
|
+
const ms = Number(value);
|
|
287
|
+
if (!Number.isInteger(ms) || ms <= 0 || ms > MAX_LATENCY_MS) {
|
|
288
|
+
throw new BacktestRejection('E_MANIFEST',
|
|
289
|
+
`latency must be a whole number of milliseconds between 1 and ${MAX_LATENCY_MS}, got ${JSON.stringify(value)}`);
|
|
290
|
+
}
|
|
291
|
+
return ms;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Validate a declared interval list, returning it normalised.
|
|
296
|
+
*
|
|
297
|
+
* Absent means the default (5m), not "everything": see MARKET_INTERVALS.
|
|
298
|
+
*/
|
|
299
|
+
export function normalizeIntervals(list) {
|
|
300
|
+
if (list == null) return [...DEFAULT_INTERVALS];
|
|
301
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
302
|
+
throw new BacktestRejection('E_MANIFEST',
|
|
303
|
+
`intervals must be a non-empty array; known: ${MARKET_INTERVALS.join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const raw of list) {
|
|
307
|
+
const iv = String(raw ?? '').trim();
|
|
308
|
+
if (!MARKET_INTERVALS.includes(iv)) {
|
|
309
|
+
throw new BacktestRejection('E_MANIFEST',
|
|
310
|
+
`unknown interval ${JSON.stringify(iv)}; known: ${MARKET_INTERVALS.join(', ')}`);
|
|
311
|
+
}
|
|
312
|
+
if (!out.includes(iv)) out.push(iv);
|
|
313
|
+
}
|
|
314
|
+
// Sorted so the same request always produces the same decoded-cache identity.
|
|
315
|
+
return out.sort();
|
|
316
|
+
}
|
|
317
|
+
|
|
210
318
|
/** Validate a declared dataset list, returning it normalised. */
|
|
211
319
|
export function normalizeDatasets(list) {
|
|
212
320
|
if (!Array.isArray(list) || list.length === 0) {
|
|
@@ -223,3 +331,19 @@ export function normalizeDatasets(list) {
|
|
|
223
331
|
}
|
|
224
332
|
return out;
|
|
225
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The dataset list the prewarm decodes for.
|
|
337
|
+
*
|
|
338
|
+
* A decoded day is cached per dataset SHAPE, so warming a shape nobody submits
|
|
339
|
+
* warms nothing at all — the paid run still pays the full decode. This is the
|
|
340
|
+
* shape the editor submits: every dataset it offers as a chip, which is what
|
|
341
|
+
* almost every run declares.
|
|
342
|
+
*
|
|
343
|
+
* Derived from the contract, not written out, so a dataset added to the
|
|
344
|
+
* product is warmed without anyone remembering to come here.
|
|
345
|
+
*/
|
|
346
|
+
const SETTLEMENT_STREAMS = new Set(['prices', 'twap30s', 'twap60s']);
|
|
347
|
+
export const PREWARM_DATASETS = Object.freeze(
|
|
348
|
+
KNOWN_DATASETS.filter((d) => !SETTLEMENT_STREAMS.has(d)),
|
|
349
|
+
);
|
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
LANGUAGES, KNOWN_LANGUAGES, HOOKS, KNOWN_HOOKS, HOOK_NAMES, LIMITS, MODES,
|
|
14
14
|
KNOWN_MODES, SCHEMA_VERSION, BacktestRejection, parseReferenceFeed,
|
|
15
15
|
} from './backtest-contract.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
normalizeDatasets, normalizeIntervals, normalizeLatency, assertCoverage,
|
|
18
|
+
} from './backtest-datasets.mjs';
|
|
19
|
+
// The series parser, so the FREE check rejects a malformed CSV instead of the
|
|
20
|
+
// worker rejecting it after credits are held. One implementation, shared: the
|
|
21
|
+
// docs promise a local pass is not rejected on submit.
|
|
22
|
+
import { parseSeries } from '../../runner/series-data.mjs';
|
|
17
23
|
|
|
18
24
|
/** The one file name that is not the submitter's to choose. */
|
|
19
25
|
export const MANIFEST_NAME = 'outcometick.json';
|
|
@@ -116,6 +122,15 @@ export function validateManifest(doc) {
|
|
|
116
122
|
|
|
117
123
|
const datasets = normalizeDatasets(doc.datasets);
|
|
118
124
|
|
|
125
|
+
// Which market lengths to replay. Absent means 5m — see MARKET_INTERVALS for
|
|
126
|
+
// why the set is closed and why the default is not "both".
|
|
127
|
+
const intervals = normalizeIntervals(doc.intervals);
|
|
128
|
+
|
|
129
|
+
// Which fill delays to re-price at, for the latency panel. Empty by default:
|
|
130
|
+
// every delay is another replay of the range, and it used to be five of them
|
|
131
|
+
// whether or not anyone wanted the table.
|
|
132
|
+
const latency = normalizeLatency(doc.latency);
|
|
133
|
+
|
|
119
134
|
// A hook that needs a dataset it was not given would simply never fire, and
|
|
120
135
|
// a strategy that silently never trades looks like a bad strategy rather
|
|
121
136
|
// than a bad manifest. Say so instead.
|
|
@@ -231,6 +246,8 @@ export function validateManifest(doc) {
|
|
|
231
246
|
entry: { file: entryFile, className: entryClass },
|
|
232
247
|
hooks,
|
|
233
248
|
datasets,
|
|
249
|
+
intervals,
|
|
250
|
+
latency,
|
|
234
251
|
mode,
|
|
235
252
|
deps,
|
|
236
253
|
reference,
|
|
@@ -272,13 +289,45 @@ export function validateFiles(files, manifest) {
|
|
|
272
289
|
`file ${name} contains a NUL byte — submissions are text only, no archives`);
|
|
273
290
|
}
|
|
274
291
|
const bytes = Buffer.byteLength(content, 'utf8');
|
|
275
|
-
total += bytes;
|
|
276
292
|
seen.set(name, { name, bytes, content });
|
|
277
293
|
}
|
|
278
294
|
|
|
295
|
+
// SOURCE and DATA have separate budgets.
|
|
296
|
+
//
|
|
297
|
+
// One shared 256KB cap meant a declared series ate the code's allowance, and
|
|
298
|
+
// the advertised 32MB series could never be sent at all — the two limits
|
|
299
|
+
// contradicted each other and the smaller one won silently. Code is small and
|
|
300
|
+
// is read by a human during an abuse review; a series is bulk and is read by
|
|
301
|
+
// nothing.
|
|
302
|
+
const seriesFiles = new Set(manifest.series.map((s) => s.file));
|
|
303
|
+
for (const f of seen.values()) {
|
|
304
|
+
if (seriesFiles.has(f.name)) continue;
|
|
305
|
+
total += f.bytes;
|
|
306
|
+
}
|
|
279
307
|
if (total > LIMITS.maxTotalSourceBytes) {
|
|
280
308
|
throw new BacktestRejection('E_LIMIT',
|
|
281
|
-
`total source is ${total} bytes, over the ${LIMITS.maxTotalSourceBytes} byte limit`
|
|
309
|
+
`total source is ${total} bytes, over the ${LIMITS.maxTotalSourceBytes} byte limit`
|
|
310
|
+
+ ' (series files have their own budget and are not counted here)');
|
|
311
|
+
}
|
|
312
|
+
for (const s of manifest.series) {
|
|
313
|
+
const f = seen.get(s.file);
|
|
314
|
+
if (f && f.bytes > LIMITS.maxSeriesBytes) {
|
|
315
|
+
throw new BacktestRejection('E_LIMIT',
|
|
316
|
+
`series ${s.name} is ${f.bytes} bytes, over the ${LIMITS.maxSeriesBytes} byte limit`);
|
|
317
|
+
}
|
|
318
|
+
// PARSED HERE, in the free check — not later, in the worker.
|
|
319
|
+
//
|
|
320
|
+
// The docs promise that a local pass is not rejected on submit, and this is
|
|
321
|
+
// the same validator `ot check` runs. Leaving it to the worker meant a CSV
|
|
322
|
+
// with no readable timestamp got as far as holding credits and queueing,
|
|
323
|
+
// then failed and refunded: technically correct, and a terrible way to
|
|
324
|
+
// learn your header was wrong.
|
|
325
|
+
if (f) {
|
|
326
|
+
const out = parseSeries(f.content);
|
|
327
|
+
if (out.rows.length === 0) {
|
|
328
|
+
throw new BacktestRejection('E_MANIFEST', `series ${s.name} (${s.file}): ${out.problem}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
282
331
|
}
|
|
283
332
|
if (!seen.has(MANIFEST_NAME)) {
|
|
284
333
|
throw new BacktestRejection('E_MANIFEST', `${MANIFEST_NAME} is required`);
|
|
@@ -340,6 +389,5 @@ export function checkSubmission({ manifestText, manifest: manifestDoc, files, sc
|
|
|
340
389
|
// so neither the runner nor the CLI re-implements the parity table.
|
|
341
390
|
hookNames: Object.fromEntries(manifest.hooks.map((h) => [h, HOOK_NAMES[manifest.languageId][h]])),
|
|
342
391
|
shardable: MODES[manifest.mode].shardable,
|
|
343
|
-
rateMultiplier: MODES[manifest.mode].rateMultiplier,
|
|
344
392
|
};
|
|
345
393
|
}
|