outcometick 1.4.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/api/lib/backtest-contract.mjs +318 -0
  4. package/api/lib/backtest-datasets.mjs +225 -0
  5. package/api/lib/backtest-manifest.mjs +345 -0
  6. package/api/lib/coverage-window.mjs +42 -0
  7. package/api/lib/data-taxonomy.mjs +175 -0
  8. package/api/lib/venue-path.mjs +16 -0
  9. package/bin/ot.mjs +4 -0
  10. package/cli/api-client.mjs +71 -0
  11. package/cli/commands/fetch.mjs +43 -0
  12. package/cli/commands/run.mjs +269 -0
  13. package/cli/commands/status.mjs +102 -0
  14. package/cli/commands/submit.mjs +77 -0
  15. package/cli/local-data.mjs +177 -0
  16. package/cli/ot.mjs +223 -0
  17. package/index.d.ts +195 -0
  18. package/index.mjs +2 -0
  19. package/package.json +58 -0
  20. package/runner/analyze/index.mjs +40 -0
  21. package/runner/analyze/javascript.mjs +380 -0
  22. package/runner/analyze/python.mjs +85 -0
  23. package/runner/analyze/python_analyze.py +320 -0
  24. package/runner/archive.mjs +185 -0
  25. package/runner/engine/book.mjs +226 -0
  26. package/runner/engine/portfolio.mjs +292 -0
  27. package/runner/engine/replay.mjs +496 -0
  28. package/runner/engine/report.mjs +417 -0
  29. package/runner/events.mjs +190 -0
  30. package/runner/harness/node/harness.mjs +467 -0
  31. package/runner/harness/node/sdk/index.d.ts +195 -0
  32. package/runner/harness/node/sdk/index.mjs +71 -0
  33. package/runner/harness/node/sdk/package.json +8 -0
  34. package/runner/harness/protocol.mjs +255 -0
  35. package/runner/harness/python/harness.py +374 -0
  36. package/runner/harness/python/otengine.py +523 -0
  37. package/runner/harness/python/otreplay.py +409 -0
  38. package/runner/harness/python/outcometick.py +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ligengxin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ <!--
2
+ GENERATED — do not edit this repository directly.
3
+
4
+ Every file here is built from the outcometick monorepo by
5
+ scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
6
+ An edit made here survives until the next publish and then disappears.
7
+
8
+ Generated from monorepo revision 532aa834e8f0f07b21958b0d3c30e7332fbbc34e.
9
+ -->
10
+
11
+ # outcometick
12
+
13
+ The TypeScript / JavaScript strategy SDK and the `ot` command line for
14
+ [outcometick.com](https://outcometick.com) — tick-level data for Polymarket and
15
+ Predict.fun crypto Up/Down markets.
16
+
17
+ ```
18
+ npm i -g outcometick
19
+
20
+ ot check . # validate, free, no data
21
+ ot run . --data ./polymarket-data-samples --date … # replay locally
22
+ ot submit . --assets btc,eth --from … --to … # send it to the queue
23
+ ot status <run_id> # where it got to
24
+ ot fetch <run_id> # download the report
25
+ ```
26
+
27
+ ## Writing a strategy
28
+
29
+ Fully typed — `ot` ships `.d.ts` declarations, so a strategy that type-checks
30
+ is one the queue will accept.
31
+
32
+ ```ts
33
+ import { Strategy, Order, type Ctx, type Tick } from "outcometick";
34
+
35
+ export default class MeanReversion extends Strategy<{ entry_z: number; size: number }> {
36
+ private entered = false;
37
+
38
+ onMarketOpen() { this.entered = false; }
39
+
40
+ onTick(ctx: Ctx<{ entry_z: number; size: number }>, tick: Tick): Order | null {
41
+ const z = ctx.zscore(tick.value, { window: 180 });
42
+ if (this.entered || Math.abs(z) < ctx.p.entry_z) return null;
43
+ const side = z > 0 ? "DOWN" : "UP";
44
+ const limit = ctx.book().best(side);
45
+ if (limit === null) return null;
46
+ this.entered = true;
47
+ return new Order({ side, size: ctx.p.size, limit });
48
+ }
49
+ }
50
+ ```
51
+
52
+ Plain JavaScript works too — the runtime is ESM with no build step.
53
+
54
+ ## The point of `ot check`
55
+
56
+ It runs the **same validator the queue runs** — the same module, not a copy. If
57
+ it passes locally it will not be rejected on submit. That is only true because
58
+ there is one implementation, which is why the validator and the static analysers
59
+ ship in this package rather than being reimplemented client-side.
60
+
61
+ ## What `ot run` is and is not
62
+
63
+ It is the same engine, the same report and the same archive format the queue
64
+ uses, against a local copy of the archive:
65
+
66
+ git clone https://github.com/Ligengxin96/polymarket-data-samples
67
+
68
+ It is **not** the sandbox. Locally your strategy runs as you, with your
69
+ privileges, on your machine — which is fine, because it is your code. On our
70
+ machines it runs in a container with no network, no writable filesystem and a
71
+ hard CPU and wall-clock budget.
72
+
73
+ ## Testing
74
+
75
+ ```
76
+ npm install && npm test
77
+ ```
78
+
79
+ Requires `python3` on PATH: one of the two static analysers is written in
80
+ Python, and the CLI drives it the same way the API does.
81
+
82
+ ---
83
+
84
+ Writing your strategy in Python instead? The SDK for it is
85
+ [`pip install outcometick`](https://pypi.org/project/outcometick/). `ot` runs
86
+ those too — it is the one CLI for both languages.
87
+
88
+ Full reference: https://outcometick.com/docs/sdk
@@ -0,0 +1,318 @@
1
+ // The backtest contract: what a submission may declare, and what the runner
2
+ // promises to honour. This module is the single source of truth for both ends
3
+ // — the API validates against it, the runner builds its sandbox from it, and
4
+ // /v1/backtest/contract serves it to the SDK and the docs page.
5
+ //
6
+ // Everything here is a closed set on purpose. An unknown language, dataset,
7
+ // hook or reference feed is a rejection, never a pass-through: the whole
8
+ // premise is a sealed, deterministic replay, and "we did not recognise it so we
9
+ // ignored it" is how a strategy silently gets fed something other than what it
10
+ // asked for.
11
+
12
+ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
13
+
14
+ /** Manifest schema version. Field meanings never change within a version. */
15
+ export const SCHEMA_VERSION = 1;
16
+
17
+ /** SDK version reported by the docs page and stamped into every report. */
18
+ export const SDK_VERSION = '1.4.0';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Languages
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Runtimes we execute, pinned exactly. A range is not accepted: two runs of the
26
+ * same source on different patch releases can differ in float formatting or
27
+ * dict ordering, and the product claims byte-identical reports.
28
+ *
29
+ * `compiled` languages are deliberately absent for now. Compiling untrusted
30
+ * source IS untrusted execution (build.rs, proc macros, go generate), needs a
31
+ * vendored offline module cache, and its own resource envelope — a different
32
+ * security problem from importing a module, not a bigger version of the same
33
+ * one. Adding one means adding an entry here plus a runner plugin; nothing in
34
+ * the API or the schema has to move.
35
+ */
36
+ export const LANGUAGES = Object.freeze({
37
+ 'python@3.14': Object.freeze({
38
+ id: 'python',
39
+ label: 'python',
40
+ runtime: 'python 3.14 · numpy, pandas, polars, scipy',
41
+ entrySignature: 'on_tick(ctx, tick) -> Order | None',
42
+ // Names only. Versions are ours and there is no install step inside the
43
+ // sandbox — the image already holds them.
44
+ deps: Object.freeze(['numpy', 'pandas', 'polars', 'scipy']),
45
+ sourceExtensions: Object.freeze(['.py', '.json']),
46
+ }),
47
+ 'nodejs@24': Object.freeze({
48
+ id: 'nodejs',
49
+ label: 'node.js',
50
+ runtime: 'node 24 · danfo, mathjs, decimal.js',
51
+ entrySignature: 'onTick(ctx, tick) => Order | null',
52
+ deps: Object.freeze(['danfojs-node', 'mathjs', 'decimal.js']),
53
+ sourceExtensions: Object.freeze(['.mjs', '.js', '.json']),
54
+ }),
55
+ });
56
+
57
+ export const KNOWN_LANGUAGES = Object.freeze(Object.keys(LANGUAGES));
58
+
59
+ /** Hook names differ per language; the semantics do not. */
60
+ export const HOOK_NAMES = Object.freeze({
61
+ python: Object.freeze({
62
+ on_market_open: 'on_market_open',
63
+ on_tick: 'on_tick',
64
+ on_book: 'on_book',
65
+ on_trade: 'on_trade',
66
+ on_settle: 'on_settle',
67
+ }),
68
+ nodejs: Object.freeze({
69
+ on_market_open: 'onMarketOpen',
70
+ on_tick: 'onTick',
71
+ on_book: 'onBook',
72
+ on_trade: 'onTrade',
73
+ on_settle: 'onSettle',
74
+ }),
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Hooks
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * The five events the runner drives. `emits` says whether returning an Order
83
+ * from that hook is meaningful — returning one from a lifecycle hook is a
84
+ * signature error, not a silently dropped order.
85
+ */
86
+ export const HOOKS = Object.freeze({
87
+ on_market_open: Object.freeze({ arity: 3, emitsOrders: false, requiresDataset: null }),
88
+ on_tick: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'settlement' }),
89
+ on_book: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'book' }),
90
+ on_trade: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'trades' }),
91
+ on_settle: Object.freeze({ arity: 4, emitsOrders: false, requiresDataset: null }),
92
+ });
93
+
94
+ export const KNOWN_HOOKS = Object.freeze(Object.keys(HOOKS));
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Datasets
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Dataset names as the SDK sees them. These are NOT the archive's internal
102
+ * dataset names — `book` covers two different venue-specific trees, and
103
+ * `settlement` is not a stored stream at all but a per-market resolution. The
104
+ * mapping lives in backtest-datasets.mjs so this file stays a contract.
105
+ */
106
+ export const DATASETS = Object.freeze({
107
+ settlement: 'Resolves per market to the stream that market actually settled on.',
108
+ prices: 'The 1 Hz Chainlink report stream.',
109
+ twap30s: 'TWAP over a 30-second lookback.',
110
+ twap60s: 'TWAP over a 60-second lookback.',
111
+ book: 'Order-book snapshots and deltas.',
112
+ trades: 'Every trade print on the venue.',
113
+ markets: 'Per-market metadata, strike and settlement outcome.',
114
+ });
115
+
116
+ export const KNOWN_DATASETS = Object.freeze(Object.keys(DATASETS));
117
+
118
+ /**
119
+ * A derived stream is computed from one we hold rather than captured. It is
120
+ * always flagged as derived on every row, and may never be presented as the
121
+ * captured stream — the honesty of the archive is the product.
122
+ */
123
+ export const DERIVED_DATASETS = Object.freeze({
124
+ 'twap60s:derived': Object.freeze({
125
+ from: 'prices',
126
+ produces: 'twap60s',
127
+ lookbackSeconds: 60,
128
+ }),
129
+ 'twap30s:derived': Object.freeze({
130
+ from: 'prices',
131
+ produces: 'twap30s',
132
+ lookbackSeconds: 30,
133
+ }),
134
+ });
135
+
136
+ /**
137
+ * When each captured stream actually starts, per venue.
138
+ *
139
+ * Requesting a captured stream outside its window is E_COVERAGE — never a
140
+ * silent substitution, and never an approximation. A customer who asked for
141
+ * twap60s and got 1 Hz reports back would draw a conclusion about a settlement
142
+ * rule that did not exist yet.
143
+ *
144
+ * `null` end means "still being captured".
145
+ */
146
+ export const CAPTURE_WINDOWS = Object.freeze({
147
+ polymarket: Object.freeze({
148
+ prices: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
149
+ twap30s: Object.freeze({ from: '2026-08-07', to: null }),
150
+ twap60s: Object.freeze({ from: '2026-08-07', to: null }),
151
+ book: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
152
+ trades: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
153
+ markets: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
154
+ }),
155
+ predict: Object.freeze({
156
+ prices: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
157
+ twap30s: Object.freeze({ from: '2026-08-07', to: null }),
158
+ twap60s: Object.freeze({ from: '2026-08-07', to: null }),
159
+ book: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
160
+ trades: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
161
+ markets: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
162
+ }),
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Reference feeds (external data, resolved before the run)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Outside data never arrives as a call from strategy code — the sandbox has no
171
+ * network, and a live fetch would make the same source produce different
172
+ * reports on different days. Feeds are resolved into a dataset ahead of the run
173
+ * and replayed on the same clock as everything else.
174
+ *
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.
177
+ */
178
+ export const REFERENCE_FEEDS = Object.freeze({
179
+ '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 }),
183
+ });
184
+
185
+ /** Symbols we carry a reference feed for. */
186
+ export const REFERENCE_SYMBOLS = Object.freeze(['btcusdt', 'ethusdt', 'solusdt', 'xrpusdt']);
187
+
188
+ /** `binance:btcusdt:spot:1s` -> {feed, symbol} or null if it is not a feed we carry. */
189
+ export function parseReferenceFeed(name) {
190
+ const s = String(name ?? '').trim().toLowerCase();
191
+ const parts = s.split(':');
192
+ if (parts[0] !== 'binance' || parts.length < 3) return null;
193
+ const symbol = parts[1];
194
+ if (!REFERENCE_SYMBOLS.includes(symbol)) return null;
195
+ const pattern = ['binance', '{symbol}', ...parts.slice(2)].join(':');
196
+ const feed = REFERENCE_FEEDS[pattern];
197
+ if (!feed) return null;
198
+ if (feed.assets && !feed.assets.includes(symbol.replace(/usdt$/, '').toUpperCase())) return null;
199
+ return { canonical: s, pattern, symbol, ...feed };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Run modes
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * `market` shards by market-day across workers, which is what makes a
208
+ * market-day cheap. `session` feeds one instance every market in the range as a
209
+ * single ordered stream — it cannot be sharded, so it runs slower and bills at
210
+ * a multiple.
211
+ */
212
+ export const MODES = Object.freeze({
213
+ market: Object.freeze({ shardable: true, rateMultiplier: 1 }),
214
+ session: Object.freeze({ shardable: false, rateMultiplier: 3 }),
215
+ });
216
+
217
+ export const KNOWN_MODES = Object.freeze(Object.keys(MODES));
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Limits
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Hard limits, enforced by the API on submission and by the sandbox at run
225
+ * time. The API copy of a limit is a fast rejection, not the security boundary
226
+ * — the sandbox enforces every one of these again.
227
+ */
228
+ export const LIMITS = Object.freeze({
229
+ maxFiles: 6,
230
+ maxTotalSourceBytes: 256 * 1024,
231
+ maxFileNameLength: 96,
232
+ maxSeriesBytes: 32 * 1024 * 1024,
233
+ maxSeriesCount: 4,
234
+ perEventBudgetMicros: 400,
235
+ memoryBytes: 8 * 1024 * 1024 * 1024,
236
+ vcpu: 4,
237
+ wallClockMs: 20 * 60 * 1000,
238
+ logLinesPerMarketDay: 10_000,
239
+ maxParams: 64,
240
+ maxSweepCells: 256,
241
+ archiveRetentionDays: 90,
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Rejection codes
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Every rejection a submission can earn before anything is billed. `ot check`
250
+ * runs the same validator and returns the same codes — the docs promise that a
251
+ * local pass is not rejected on submit, so these must stay in one place.
252
+ */
253
+ export const REJECTION_CODES = Object.freeze({
254
+ E_MANIFEST: 'Missing or malformed outcometick.json, or a schema version we do not know.',
255
+ E_ENTRY: 'entry does not resolve to a class in the named file, or the class does not implement the SDK base.',
256
+ E_HOOK_SIG: 'A declared hook has the wrong arity or returns a type that is not Order or nothing.',
257
+ E_IMPORT: 'An import outside the allowlist, transitive ones included. The offending chain is printed.',
258
+ E_FORBIDDEN: 'Threads, subprocess, eval, dynamic import, reflection or a native extension found at import time.',
259
+ E_NONDETERMINISM: 'Unseeded randomness or a wall-clock read. Use ctx.random and ctx.now.',
260
+ E_STATE: 'Instance state is not serialisable, so the market-day cannot be moved between workers.',
261
+ E_BUDGET: 'Per-event budget exceeded on the smoke run. Nothing was billed.',
262
+ E_COVERAGE: 'A captured stream was requested outside the window it was captured in.',
263
+ E_LIMIT: 'A submission limit was exceeded — file count, total source size or series size.',
264
+ E_SCOPE: 'The requested venue, asset or date range is not something we can serve.',
265
+ });
266
+
267
+ export const KNOWN_REJECTION_CODES = Object.freeze(Object.keys(REJECTION_CODES));
268
+
269
+ /**
270
+ * A rejection carries its code so the CLI, the API and the page all speak the
271
+ * same language. Thrown rather than returned wherever validation is deep enough
272
+ * that threading a result out would obscure the check.
273
+ */
274
+ export class BacktestRejection extends Error {
275
+ constructor(code, detail, extra = {}) {
276
+ if (!REJECTION_CODES[code]) throw new Error(`unknown rejection code ${code}`);
277
+ super(detail || REJECTION_CODES[code]);
278
+ this.name = 'BacktestRejection';
279
+ this.code = code;
280
+ this.detail = detail || REJECTION_CODES[code];
281
+ Object.assign(this, extra);
282
+ }
283
+
284
+ toJSON() {
285
+ const { code, detail, ...rest } = this;
286
+ return { code: this.code, detail: this.detail, ...stripNoise(rest) };
287
+ }
288
+ }
289
+
290
+ function stripNoise(o) {
291
+ const out = {};
292
+ for (const [k, v] of Object.entries(o)) {
293
+ if (k === 'name' || k === 'message' || k === 'stack') continue;
294
+ out[k] = v;
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /** The whole contract, in the shape /v1/backtest/contract serves it. */
300
+ export function contractDocument() {
301
+ return {
302
+ schema: SCHEMA_VERSION,
303
+ sdkVersion: SDK_VERSION,
304
+ languages: Object.fromEntries(Object.entries(LANGUAGES).map(([k, v]) => [k, {
305
+ label: v.label, runtime: v.runtime, entrySignature: v.entrySignature, deps: [...v.deps],
306
+ }])),
307
+ hooks: HOOKS,
308
+ hookNames: HOOK_NAMES,
309
+ datasets: DATASETS,
310
+ derivedDatasets: DERIVED_DATASETS,
311
+ captureWindows: CAPTURE_WINDOWS,
312
+ referenceFeeds: REFERENCE_FEEDS,
313
+ referenceSymbols: [...REFERENCE_SYMBOLS],
314
+ modes: MODES,
315
+ limits: LIMITS,
316
+ rejectionCodes: REJECTION_CODES,
317
+ };
318
+ }
@@ -0,0 +1,225 @@
1
+ // Translating between what a strategy asks for and what the archive holds.
2
+ //
3
+ // The SDK's dataset names are a product surface: `book`, `trades`, `settlement`.
4
+ // The archive's are a storage detail: `price_change`, `orderbook`,
5
+ // `last_trade_price`, `chainlink-twap-60s`. Keeping the two apart is what lets
6
+ // the archive be reorganised without breaking a manifest a customer wrote three
7
+ // months ago.
8
+ //
9
+ // The interesting one is `settlement`, which is not a stored stream at all.
10
+
11
+ import { classifyPath } from './data-taxonomy.mjs';
12
+ import {
13
+ CAPTURE_WINDOWS, DERIVED_DATASETS, KNOWN_DATASETS, BacktestRejection,
14
+ } from './backtest-contract.mjs';
15
+
16
+ /**
17
+ * SDK dataset name -> the archive dataset names that satisfy it, per venue.
18
+ *
19
+ * `book` and `trades` are one concept each to a strategy but two different
20
+ * trees per venue: Polymarket publishes full snapshots plus deltas, Predict.fun
21
+ * publishes a single `orderbook` tree. A strategy that declares `book` gets
22
+ * whatever that venue actually has, which is the point of naming it `book`.
23
+ */
24
+ const ARCHIVE_DATASETS = Object.freeze({
25
+ polymarket: Object.freeze({
26
+ prices: Object.freeze(['prices']),
27
+ twap30s: Object.freeze(['twap30s']),
28
+ twap60s: Object.freeze(['twap60s']),
29
+ book: Object.freeze(['book', 'price_change']),
30
+ trades: Object.freeze(['last_trade_price']),
31
+ markets: Object.freeze(['markets']),
32
+ }),
33
+ predict: Object.freeze({
34
+ prices: Object.freeze(['prices']),
35
+ twap30s: Object.freeze(['twap30s']),
36
+ twap60s: Object.freeze(['twap60s']),
37
+ book: Object.freeze(['orderbook']),
38
+ trades: Object.freeze(['last_trade_price']),
39
+ markets: Object.freeze(['markets']),
40
+ }),
41
+ });
42
+
43
+ /**
44
+ * `markets` is always fed, whether or not a strategy declares it.
45
+ *
46
+ * Not a convenience: on_market_open carries the strike and on_settle carries
47
+ * the official outcome, both of which live in the markets tree, and the
48
+ * settlement-stream resolution below reads twapLookbackSeconds from the same
49
+ * place. A run without it could not identify which stream a market settled on,
50
+ * which is the one thing this product exists to get right.
51
+ */
52
+ export const ALWAYS_FED = Object.freeze(['markets']);
53
+
54
+ /**
55
+ * Which captured stream a single market settled on.
56
+ *
57
+ * Read from the market's OWN config, never inferred from its date. The venues
58
+ * moved 5-minute markets onto a 30-second TWAP and then onto 60, while
59
+ * 15-minute markets went straight to 60 — so any date-based rule is wrong for
60
+ * whole classes of market inside the transition, and wrong quietly. The market
61
+ * record states its own lookback; that is the answer.
62
+ *
63
+ * Fail-closed on anything unrecognised. A market whose config we cannot read is
64
+ * not guessed at: the caller gets null and must reject the market-day rather
65
+ * than feed a strategy a stream the market did not settle on.
66
+ *
67
+ * @param {object} market a row from the markets dataset
68
+ * @returns {'prices'|'twap30s'|'twap60s'|null}
69
+ */
70
+ export function resolveSettlementStream(market) {
71
+ const cfg = market?.raw?.cryptoMarketConfig ?? market?.cryptoMarketConfig ?? null;
72
+ // No config object at all is a record we could not read, NOT evidence of the
73
+ // pre-TWAP regime. An earlier version returned 'prices' here and that was
74
+ // fail-OPEN in the worst possible place: every market whose metadata we
75
+ // failed to parse would have been fed the 1 Hz stream and silently reported
76
+ // as if that were what it settled on. A dropped market-day is visible in
77
+ // coverage and costs the customer nothing; a wrong settlement stream is
78
+ // invisible and makes the whole report a lie.
79
+ if (!cfg || typeof cfg !== 'object') return null;
80
+ if (!Object.prototype.hasOwnProperty.call(cfg, 'twapLookbackSeconds')) return null;
81
+
82
+ const lookback = cfg.twapLookbackSeconds;
83
+ // An explicit null or 0 IS a positive statement: this market settled on the
84
+ // instantaneous stream. That is different from the field being absent.
85
+ if (lookback === null || lookback === 0) return 'prices';
86
+ if (lookback === 30) return 'twap30s';
87
+ if (lookback === 60) return 'twap60s';
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Is a captured stream available for this venue on this day?
93
+ *
94
+ * Both ends are inclusive and `to: null` means "still capturing".
95
+ */
96
+ export function isCaptured(venue, dataset, day) {
97
+ const w = CAPTURE_WINDOWS[venue]?.[dataset];
98
+ if (!w) return false;
99
+ if (day < w.from) return false;
100
+ if (w.to && day > w.to) return false;
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Every day in [from, to] on which a captured stream is unavailable.
106
+ *
107
+ * Returned rather than counted so E_COVERAGE can name the range that failed —
108
+ * "twap60s is not captured before 2026-08-07" is actionable, "coverage error"
109
+ * is not.
110
+ */
111
+ export function uncapturedRange(venue, dataset, from, to) {
112
+ const w = CAPTURE_WINDOWS[venue]?.[dataset];
113
+ if (!w) return { from, to };
114
+ const badFrom = from < w.from ? from : null;
115
+ const badTo = badFrom ? (to < w.from ? to : prevDay(w.from)) : null;
116
+ if (badFrom) return { from: badFrom, to: badTo };
117
+ if (w.to && to > w.to) return { from: nextDay(w.to), to };
118
+ return null;
119
+ }
120
+
121
+ function shiftDay(day, delta) {
122
+ const d = new Date(`${day}T00:00:00Z`);
123
+ d.setUTCDate(d.getUTCDate() + delta);
124
+ return d.toISOString().slice(0, 10);
125
+ }
126
+ const prevDay = (d) => shiftDay(d, -1);
127
+ const nextDay = (d) => shiftDay(d, 1);
128
+
129
+ /**
130
+ * Check a manifest's declared datasets against a venue and date range.
131
+ *
132
+ * `settlement` is exempt: it resolves per market to whatever that market
133
+ * settled on, so it is available wherever the archive is — that is the entire
134
+ * reason to prefer it, and why the docs recommend it.
135
+ *
136
+ * Throws BacktestRejection(E_COVERAGE) on the first stream that is not
137
+ * available for the whole range, naming the gap.
138
+ */
139
+ export function assertCoverage({ datasets, venue, from, to }) {
140
+ for (const ds of datasets ?? []) {
141
+ if (ds === 'settlement') continue;
142
+ if (DERIVED_DATASETS[ds]) {
143
+ // A derived stream is a function of one we hold, so its availability is
144
+ // the SOURCE stream's availability, not its own.
145
+ const src = DERIVED_DATASETS[ds].from;
146
+ const gap = uncapturedRange(venue, src, from, to);
147
+ if (gap) {
148
+ throw new BacktestRejection('E_COVERAGE',
149
+ `${ds} is derived from ${src}, which is not captured for ${gap.from}..${gap.to ?? gap.from} on ${venue}`,
150
+ { dataset: ds, derivedFrom: src, venue, gap });
151
+ }
152
+ continue;
153
+ }
154
+ const gap = uncapturedRange(venue, ds, from, to);
155
+ if (gap) {
156
+ const w = CAPTURE_WINDOWS[venue]?.[ds];
157
+ throw new BacktestRejection('E_COVERAGE',
158
+ `${ds} is not captured for ${gap.from}..${gap.to ?? gap.from} on ${venue}`
159
+ + (w ? ` (captured from ${w.from}${w.to ? ` to ${w.to}` : ''})` : '')
160
+ + '. Use settlement, or a :derived stream if you specifically want today\'s rules on older dates.',
161
+ { dataset: ds, venue, gap, capturedFrom: w?.from ?? null, capturedTo: w?.to ?? null });
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The archive dataset names a run must fetch for a given manifest.
168
+ *
169
+ * `settlement` expands to every stream that could be authoritative for some
170
+ * market in the range: the resolution is per market and is not known until the
171
+ * markets metadata has been read, so the worker fetches the union and the
172
+ * engine picks per market. Streams outside their capture window are dropped
173
+ * rather than requested — no market in that window can have settled on them.
174
+ */
175
+ export function archiveDatasetsFor({ datasets, venue, from, to }) {
176
+ const wanted = new Set();
177
+ for (const ds of [...(datasets ?? []), ...ALWAYS_FED]) {
178
+ if (ds === 'settlement') {
179
+ for (const s of ['prices', 'twap30s', 'twap60s']) {
180
+ if (!uncapturedRange(venue, s, from, to) || isCaptured(venue, s, to)) wanted.add(s);
181
+ }
182
+ continue;
183
+ }
184
+ const base = DERIVED_DATASETS[ds]?.from ?? ds;
185
+ wanted.add(base);
186
+ }
187
+ const out = new Set();
188
+ const map = ARCHIVE_DATASETS[venue] ?? {};
189
+ for (const w of wanted) for (const a of map[w] ?? []) out.add(a);
190
+ return [...out].sort();
191
+ }
192
+
193
+ /**
194
+ * Does an archived file belong to this run's scope?
195
+ *
196
+ * Runs on top of the entitlement gate, never instead of it: this decides what a
197
+ * paid-for run is FED, while scope.mjs decides what a customer may DOWNLOAD.
198
+ * A backtest reads files the submitter has not bought, which is the product —
199
+ * so this filter must never be mistaken for an authorisation check.
200
+ */
201
+ export function fileMatchesRun(filePath, { venue, assets, archiveDatasets }) {
202
+ const meta = classifyPath(filePath);
203
+ if (meta.venue !== venue) return false;
204
+ if (!archiveDatasets.includes(meta.dataset)) return false;
205
+ // Venue-wide datasets (markets) carry no asset and are always in scope.
206
+ if (meta.asset && assets?.length && !assets.includes(meta.asset)) return false;
207
+ return true;
208
+ }
209
+
210
+ /** Validate a declared dataset list, returning it normalised. */
211
+ export function normalizeDatasets(list) {
212
+ if (!Array.isArray(list) || list.length === 0) {
213
+ throw new BacktestRejection('E_MANIFEST', 'datasets must be a non-empty array');
214
+ }
215
+ const out = [];
216
+ for (const raw of list) {
217
+ const ds = String(raw ?? '').trim();
218
+ if (!KNOWN_DATASETS.includes(ds) && !DERIVED_DATASETS[ds]) {
219
+ throw new BacktestRejection('E_MANIFEST',
220
+ `unknown dataset ${JSON.stringify(ds)}; known: ${[...KNOWN_DATASETS, ...Object.keys(DERIVED_DATASETS)].join(', ')}`);
221
+ }
222
+ if (!out.includes(ds)) out.push(ds);
223
+ }
224
+ return out;
225
+ }