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 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 6044c75a16de3ee60870d59cb44a25096bcc7258.
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.5.2';
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 klines are pre-downloaded from data.binance.vision, which publishes
176
- * one zip per symbol per day; see scripts/fetch-binance-reference.mjs.
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:100ms': Object.freeze({ kind: 'klines', market: 'spot', interval: '100ms', assets: Object.freeze(['BTC', 'ETH']) }),
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
- /** Symbols we carry a reference feed for. */
186
- export const REFERENCE_SYMBOLS = Object.freeze(['btcusdt', 'ethusdt', 'solusdt', 'xrpusdt']);
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, rateMultiplier: 1 }),
214
- session: Object.freeze({ shardable: false, rateMultiplier: 3 }),
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
- maxSeriesBytes: 32 * 1024 * 1024,
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
- memoryBytes: 8 * 1024 * 1024 * 1024,
236
- vcpu: 4,
237
- wallClockMs: 20 * 60 * 1000,
238
- logLinesPerMarketDay: 10_000,
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: 90,
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 { normalizeDatasets, assertCoverage } from './backtest-datasets.mjs';
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
  }