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
@@ -0,0 +1,345 @@
1
+ // Validating `outcometick.json` and the files that come with it.
2
+ //
3
+ // This is the validator the docs promise `ot check` runs: "if it passes locally
4
+ // it will not be rejected on submit". That promise only survives if there is
5
+ // exactly ONE implementation — so the CLI, the API and the runner all call
6
+ // this, and none of them re-derive a rule of their own.
7
+ //
8
+ // Everything here is free and happens before a credit is held. A rejection
9
+ // costs the submitter nothing, so the checks lean strict: a manifest that is
10
+ // ambiguous is rejected rather than interpreted.
11
+
12
+ import {
13
+ LANGUAGES, KNOWN_LANGUAGES, HOOKS, KNOWN_HOOKS, HOOK_NAMES, LIMITS, MODES,
14
+ KNOWN_MODES, SCHEMA_VERSION, BacktestRejection, parseReferenceFeed,
15
+ } from './backtest-contract.mjs';
16
+ import { normalizeDatasets, assertCoverage } from './backtest-datasets.mjs';
17
+
18
+ /** The one file name that is not the submitter's to choose. */
19
+ export const MANIFEST_NAME = 'outcometick.json';
20
+
21
+ /**
22
+ * File names must be plain, relative and flat-ish. A submission is not unpacked
23
+ * — the files arrive as text with names attached — but the runner does write
24
+ * them to a scratch directory, and a name is the one field that reaches a
25
+ * filesystem call. `..`, absolute paths, backslashes and control characters are
26
+ * all rejected rather than sanitised: a name we had to repair is a name the
27
+ * submitter did not mean.
28
+ */
29
+ const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9._/-]*$/;
30
+
31
+ function assertSafeName(name, what = 'file') {
32
+ const n = String(name ?? '');
33
+ if (!n) throw new BacktestRejection('E_MANIFEST', `${what} name is empty`);
34
+ if (n.length > LIMITS.maxFileNameLength) {
35
+ throw new BacktestRejection('E_MANIFEST', `${what} name longer than ${LIMITS.maxFileNameLength} characters: ${n.slice(0, 32)}…`);
36
+ }
37
+ if (!SAFE_NAME.test(n) || n.includes('..') || n.includes('//')) {
38
+ throw new BacktestRejection('E_MANIFEST', `${what} name is not a plain relative path: ${JSON.stringify(n)}`);
39
+ }
40
+ return n;
41
+ }
42
+
43
+ /**
44
+ * Parse the manifest text.
45
+ *
46
+ * A manifest that is not JSON, or is JSON but not an object, is E_MANIFEST and
47
+ * not E_ENTRY — the distinction matters because the codes drive what the CLI
48
+ * tells someone to go and look at.
49
+ */
50
+ export function parseManifest(text) {
51
+ let doc;
52
+ try {
53
+ doc = JSON.parse(String(text));
54
+ } catch (err) {
55
+ throw new BacktestRejection('E_MANIFEST', `${MANIFEST_NAME} is not valid JSON: ${err.message}`);
56
+ }
57
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
58
+ throw new BacktestRejection('E_MANIFEST', `${MANIFEST_NAME} must be a JSON object`);
59
+ }
60
+ return doc;
61
+ }
62
+
63
+ /**
64
+ * Validate the manifest on its own, without the files.
65
+ *
66
+ * Returns a normalised manifest: every optional field present with its default,
67
+ * every list de-duplicated and in a stable order. Downstream code reads the
68
+ * normalised form only, so no consumer has to repeat "or the default".
69
+ */
70
+ export function validateManifest(doc) {
71
+ if (doc.schema !== SCHEMA_VERSION) {
72
+ throw new BacktestRejection('E_MANIFEST',
73
+ `unsupported schema ${JSON.stringify(doc.schema)}; this runner speaks schema ${SCHEMA_VERSION}`);
74
+ }
75
+
76
+ const language = String(doc.language ?? '');
77
+ if (!KNOWN_LANGUAGES.includes(language)) {
78
+ throw new BacktestRejection('E_MANIFEST',
79
+ `unsupported language ${JSON.stringify(doc.language)}; supported: ${KNOWN_LANGUAGES.join(', ')}`);
80
+ }
81
+ const lang = LANGUAGES[language];
82
+
83
+ // entry is "file:ClassName" — resolved by exact name, no discovery.
84
+ const entryRaw = String(doc.entry ?? '');
85
+ const sep = entryRaw.lastIndexOf(':');
86
+ if (sep <= 0 || sep === entryRaw.length - 1) {
87
+ throw new BacktestRejection('E_ENTRY',
88
+ `entry must be "file:ClassName", got ${JSON.stringify(doc.entry)}`);
89
+ }
90
+ const entryFile = assertSafeName(entryRaw.slice(0, sep), 'entry file');
91
+ const entryClass = entryRaw.slice(sep + 1);
92
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entryClass)) {
93
+ throw new BacktestRejection('E_ENTRY', `entry class name is not an identifier: ${JSON.stringify(entryClass)}`);
94
+ }
95
+
96
+ // hooks
97
+ if (!Array.isArray(doc.hooks) || doc.hooks.length === 0) {
98
+ throw new BacktestRejection('E_MANIFEST', 'hooks must be a non-empty array');
99
+ }
100
+ const hooks = [];
101
+ for (const raw of doc.hooks) {
102
+ const h = String(raw ?? '');
103
+ if (!KNOWN_HOOKS.includes(h)) {
104
+ throw new BacktestRejection('E_MANIFEST',
105
+ `unknown hook ${JSON.stringify(h)}; known: ${KNOWN_HOOKS.join(', ')}`);
106
+ }
107
+ if (!hooks.includes(h)) hooks.push(h);
108
+ }
109
+ // At least one hook must be able to return an Order, or the run cannot
110
+ // produce a trade and the report would be an empty equity curve the
111
+ // submitter paid for.
112
+ if (!hooks.some((h) => HOOKS[h].emitsOrders)) {
113
+ throw new BacktestRejection('E_MANIFEST',
114
+ `no hook that can return an Order was declared; one of ${KNOWN_HOOKS.filter((h) => HOOKS[h].emitsOrders).join(', ')} is required`);
115
+ }
116
+
117
+ const datasets = normalizeDatasets(doc.datasets);
118
+
119
+ // A hook that needs a dataset it was not given would simply never fire, and
120
+ // a strategy that silently never trades looks like a bad strategy rather
121
+ // than a bad manifest. Say so instead.
122
+ for (const h of hooks) {
123
+ const need = HOOKS[h].requiresDataset;
124
+ if (!need) continue;
125
+ const satisfied = need === 'settlement'
126
+ ? datasets.some((d) => d === 'settlement' || d === 'prices' || d.startsWith('twap'))
127
+ : datasets.includes(need);
128
+ if (!satisfied) {
129
+ throw new BacktestRejection('E_MANIFEST',
130
+ `hook ${h} needs the ${need} dataset, which is not declared`);
131
+ }
132
+ }
133
+
134
+ // mode
135
+ const mode = doc.mode == null ? 'market' : String(doc.mode);
136
+ if (!KNOWN_MODES.includes(mode)) {
137
+ throw new BacktestRejection('E_MANIFEST',
138
+ `unknown mode ${JSON.stringify(doc.mode)}; known: ${KNOWN_MODES.join(', ')}`);
139
+ }
140
+
141
+ // deps — names only, from the per-language allowlist. Versions are ours.
142
+ const deps = [];
143
+ if (doc.deps != null) {
144
+ if (!Array.isArray(doc.deps)) throw new BacktestRejection('E_MANIFEST', 'deps must be an array');
145
+ for (const raw of doc.deps) {
146
+ const d = String(raw ?? '').trim();
147
+ if (/[@=<>~^ ]/.test(d)) {
148
+ throw new BacktestRejection('E_MANIFEST',
149
+ `deps take names only, not versions: ${JSON.stringify(d)}. The runner pins them.`);
150
+ }
151
+ if (!lang.deps.includes(d)) {
152
+ throw new BacktestRejection('E_IMPORT',
153
+ `${d} is not on the ${lang.label} allowlist; available: ${lang.deps.join(', ')}`);
154
+ }
155
+ if (!deps.includes(d)) deps.push(d);
156
+ }
157
+ }
158
+
159
+ // reference feeds
160
+ const reference = [];
161
+ if (doc.reference != null) {
162
+ if (!Array.isArray(doc.reference)) throw new BacktestRejection('E_MANIFEST', 'reference must be an array');
163
+ for (const raw of doc.reference) {
164
+ const feed = parseReferenceFeed(raw);
165
+ if (!feed) {
166
+ throw new BacktestRejection('E_MANIFEST',
167
+ `unknown reference feed ${JSON.stringify(raw)}. Ask us and we will add it — adding a feed is cheap.`);
168
+ }
169
+ if (!reference.includes(feed.canonical)) reference.push(feed.canonical);
170
+ }
171
+ }
172
+
173
+ // series — the submitter's own CSV/Parquet, aligned to event time
174
+ const series = [];
175
+ if (doc.series != null) {
176
+ if (!Array.isArray(doc.series)) throw new BacktestRejection('E_MANIFEST', 'series must be an array');
177
+ if (doc.series.length > LIMITS.maxSeriesCount) {
178
+ throw new BacktestRejection('E_LIMIT',
179
+ `at most ${LIMITS.maxSeriesCount} series, got ${doc.series.length}`);
180
+ }
181
+ for (const raw of doc.series) {
182
+ if (!raw || typeof raw !== 'object') throw new BacktestRejection('E_MANIFEST', 'each series must be an object');
183
+ const name = String(raw.name ?? '');
184
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
185
+ throw new BacktestRejection('E_MANIFEST', `series name is not an identifier: ${JSON.stringify(raw.name)}`);
186
+ }
187
+ if (series.some((s) => s.name === name)) {
188
+ throw new BacktestRejection('E_MANIFEST', `duplicate series name ${JSON.stringify(name)}`);
189
+ }
190
+ const file = assertSafeName(raw.file, 'series file');
191
+ let lagMs = 0;
192
+ if (raw.lag_ms != null) {
193
+ lagMs = Number(raw.lag_ms);
194
+ if (!Number.isInteger(lagMs) || lagMs < 0) {
195
+ throw new BacktestRejection('E_MANIFEST', `series ${name}: lag_ms must be a non-negative integer`);
196
+ }
197
+ }
198
+ series.push({ name, file, lag_ms: lagMs });
199
+ }
200
+ }
201
+
202
+ // params — defaults only, reachable as ctx.p
203
+ const params = {};
204
+ if (doc.params != null) {
205
+ if (typeof doc.params !== 'object' || Array.isArray(doc.params)) {
206
+ throw new BacktestRejection('E_MANIFEST', 'params must be an object');
207
+ }
208
+ const keys = Object.keys(doc.params);
209
+ if (keys.length > LIMITS.maxParams) {
210
+ throw new BacktestRejection('E_LIMIT', `at most ${LIMITS.maxParams} params, got ${keys.length}`);
211
+ }
212
+ for (const k of keys) {
213
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
214
+ throw new BacktestRejection('E_MANIFEST', `param name is not an identifier: ${JSON.stringify(k)}`);
215
+ }
216
+ const v = doc.params[k];
217
+ const ok = typeof v === 'number' ? Number.isFinite(v)
218
+ : (typeof v === 'string' || typeof v === 'boolean');
219
+ if (!ok) {
220
+ throw new BacktestRejection('E_MANIFEST',
221
+ `param ${k} must be a finite number, string or boolean — a sweep has to be able to vary it`);
222
+ }
223
+ params[k] = v;
224
+ }
225
+ }
226
+
227
+ return {
228
+ schema: SCHEMA_VERSION,
229
+ language,
230
+ languageId: lang.id,
231
+ entry: { file: entryFile, className: entryClass },
232
+ hooks,
233
+ datasets,
234
+ mode,
235
+ deps,
236
+ reference,
237
+ series,
238
+ params,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Validate the submitted files against the manifest and the hard limits.
244
+ *
245
+ * `files` is [{name, content}] as submitted — text only. The manifest itself
246
+ * counts toward both the file count and the size budget, because it is one of
247
+ * the things the submitter has to fit in.
248
+ */
249
+ export function validateFiles(files, manifest) {
250
+ if (!Array.isArray(files) || files.length === 0) {
251
+ throw new BacktestRejection('E_MANIFEST', 'no files submitted');
252
+ }
253
+ if (files.length > LIMITS.maxFiles) {
254
+ throw new BacktestRejection('E_LIMIT',
255
+ `at most ${LIMITS.maxFiles} files, got ${files.length}. Archives and repository URLs are not accepted.`);
256
+ }
257
+
258
+ const seen = new Map();
259
+ let total = 0;
260
+ for (const f of files) {
261
+ const name = assertSafeName(f?.name);
262
+ if (seen.has(name)) throw new BacktestRejection('E_MANIFEST', `duplicate file ${name}`);
263
+ const content = f?.content;
264
+ if (typeof content !== 'string') {
265
+ throw new BacktestRejection('E_MANIFEST', `file ${name} must be submitted as text`);
266
+ }
267
+ // A NUL byte means this is not the text file it claims to be. We never
268
+ // unpack anything, so this is the whole of the "no archives" enforcement:
269
+ // a zip cannot survive the trip as a JSON string without one.
270
+ if (content.includes('\0')) {
271
+ throw new BacktestRejection('E_MANIFEST',
272
+ `file ${name} contains a NUL byte — submissions are text only, no archives`);
273
+ }
274
+ const bytes = Buffer.byteLength(content, 'utf8');
275
+ total += bytes;
276
+ seen.set(name, { name, bytes, content });
277
+ }
278
+
279
+ if (total > LIMITS.maxTotalSourceBytes) {
280
+ throw new BacktestRejection('E_LIMIT',
281
+ `total source is ${total} bytes, over the ${LIMITS.maxTotalSourceBytes} byte limit`);
282
+ }
283
+ if (!seen.has(MANIFEST_NAME)) {
284
+ throw new BacktestRejection('E_MANIFEST', `${MANIFEST_NAME} is required`);
285
+ }
286
+ if (!seen.has(manifest.entry.file)) {
287
+ throw new BacktestRejection('E_ENTRY',
288
+ `entry names ${manifest.entry.file}, which was not submitted`);
289
+ }
290
+ for (const s of manifest.series) {
291
+ if (!seen.has(s.file)) {
292
+ throw new BacktestRejection('E_MANIFEST',
293
+ `series ${s.name} names ${s.file}, which was not submitted`);
294
+ }
295
+ }
296
+
297
+ const lang = LANGUAGES[manifest.language];
298
+ for (const { name } of seen.values()) {
299
+ if (name === MANIFEST_NAME) continue;
300
+ if (manifest.series.some((s) => s.file === name)) continue; // data files, any extension
301
+ const ext = name.slice(name.lastIndexOf('.'));
302
+ if (!lang.sourceExtensions.includes(ext)) {
303
+ throw new BacktestRejection('E_MANIFEST',
304
+ `${name} is not a ${lang.label} source file; expected one of ${lang.sourceExtensions.join(', ')}`);
305
+ }
306
+ }
307
+
308
+ return { files: [...seen.values()], totalBytes: total };
309
+ }
310
+
311
+ /**
312
+ * The full pre-billing check: manifest, files, and the scope it will run over.
313
+ *
314
+ * Scope is checked here rather than at submission time because coverage is a
315
+ * property of the manifest's declared datasets crossed with the requested
316
+ * range, and both come from the submitter. Getting E_COVERAGE from `ot check`
317
+ * is the difference between fixing a manifest and buying a useless run.
318
+ *
319
+ * @param {{manifestText?:string, manifest?:object, files:Array, scope:object}} input
320
+ */
321
+ export function checkSubmission({ manifestText, manifest: manifestDoc, files, scope }) {
322
+ const doc = manifestDoc ?? parseManifest(
323
+ manifestText ?? files?.find((f) => f?.name === MANIFEST_NAME)?.content,
324
+ );
325
+ const manifest = validateManifest(doc);
326
+ const checked = validateFiles(files, manifest);
327
+ if (scope) {
328
+ assertCoverage({
329
+ datasets: manifest.datasets,
330
+ venue: scope.venue,
331
+ from: scope.from,
332
+ to: scope.to,
333
+ });
334
+ }
335
+ return {
336
+ manifest,
337
+ files: checked.files,
338
+ totalBytes: checked.totalBytes,
339
+ // What the runner has to call, in this language's spelling. Resolved here
340
+ // so neither the runner nor the CLI re-implements the parity table.
341
+ hookNames: Object.fromEntries(manifest.hooks.map((h) => [h, HOOK_NAMES[manifest.languageId][h]])),
342
+ shardable: MODES[manifest.mode].shardable,
343
+ rateMultiplier: MODES[manifest.mode].rateMultiplier,
344
+ };
345
+ }
@@ -0,0 +1,42 @@
1
+ // Where the dataset starts, for two different audiences.
2
+ //
3
+ // The archive genuinely holds 2026-06-06 onward, and the API says so: a
4
+ // customer querying what exists must be told what exists. But collection began
5
+ // PART-WAY THROUGH 06-06, so that day and 06-07 are partial — and a marketing
6
+ // line that says "since 06-06" invites a buyer to check, find two thin days and
7
+ // conclude the coverage claims cannot be trusted. The two dates answer
8
+ // different questions, so both are published rather than one being bent:
9
+ //
10
+ // firstDay what the archive contains (API contract)
11
+ // firstCompleteDay the first full UTC day (what we advertise)
12
+ //
13
+ // Same convention as the public sample repos, which have always started their
14
+ // range at the first complete day.
15
+
16
+ /** First complete UTC day per venue. Collection started mid-day before these. */
17
+ export const FIRST_COMPLETE_DAY = Object.freeze({
18
+ polymarket: '2026-06-08', // 06-06 and 06-07 are partial
19
+ predict: '2026-06-13', // 06-12 is ~66% of the day
20
+ });
21
+
22
+ /** The product as a whole starts when its earliest venue is complete. */
23
+ export const PRODUCT_FIRST_COMPLETE_DAY = FIRST_COMPLETE_DAY.polymarket;
24
+
25
+ /**
26
+ * The advertised window over a sorted list of archived days.
27
+ *
28
+ * Counts from the first complete day, never from the first day held — quoting
29
+ * "69 days since 06-06" and "since 06-08" in the same breath is the kind of
30
+ * inconsistency a buyer notices before anything else.
31
+ *
32
+ * @param {string[]} days sorted ascending
33
+ */
34
+ export function completeWindow(days, firstComplete = PRODUCT_FIRST_COMPLETE_DAY) {
35
+ const full = (days ?? []).filter((d) => d >= firstComplete);
36
+ return {
37
+ firstCompleteDay: full[0] ?? null,
38
+ // Length of the list, not a date subtraction: a gap in the archive must
39
+ // reduce this, and calendar arithmetic would paper over it.
40
+ completeDays: full.length,
41
+ };
42
+ }
@@ -0,0 +1,175 @@
1
+ // Turn an archive path into the dimensions a customer actually thinks in:
2
+ // venue, dataset, asset, interval. The archive's own layout grew organically
3
+ // (three settlement streams, two venues, derived klines) and is not something a
4
+ // buyer should have to learn.
5
+ //
6
+ // Pure and total: every mirrored path must classify, because the API lists
7
+ // whatever the catalog holds. Anything unrecognised comes back with
8
+ // dataset:'other' and null dimensions rather than being dropped — a file that
9
+ // silently disappears from listings is worse than one that is awkward to filter.
10
+
11
+ import { venueOfPath } from './venue-path.mjs';
12
+
13
+ /** Asset symbols we collect, longest-first so BNBUSDT matches before BNB. */
14
+ const ASSETS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE', 'BNB', 'HYPE', 'ZEC'];
15
+
16
+ /** Datasets, as a customer would name them. */
17
+ export const DATASETS = {
18
+ prices: 'Settlement feed, tick by tick (instantaneous Chainlink stream)',
19
+ twap30s: 'TWAP 30s settlement stream — settled 5-minute markets before they moved to the 60s lookback; still archived daily',
20
+ twap60s: 'TWAP 60s settlement stream — settles both 5-minute and 15-minute markets',
21
+ book: 'Full-depth order-book snapshots',
22
+ price_change: 'Order-book deltas with best bid/ask',
23
+ last_trade_price: 'Every trade print',
24
+ markets: 'Per-market metadata, strike and settlement outcome',
25
+ tick_size_change: 'Tick-size changes',
26
+ orderbook: 'Order-book snapshots (Predict.fun)',
27
+ klines: 'OHLCV candles derived from the settlement feed',
28
+ other: 'Uncategorised',
29
+ };
30
+
31
+ const num = (s) => (s == null ? null : s);
32
+
33
+ /**
34
+ * @returns {{venue:'polymarket'|'predict', dataset:string, asset:string|null,
35
+ * interval:string|null, ext:string}}
36
+ */
37
+ export function classifyPath(filePath) {
38
+ const p = String(filePath);
39
+ const segs = p.split('/');
40
+ const name = segs[segs.length - 1] ?? '';
41
+ const venue = venueOfPath(p);
42
+ const ext = name.endsWith('.csv.gz') ? 'csv.gz' : name.endsWith('.jsonl.gz') ? 'jsonl.gz' : '';
43
+
44
+ const assetOf = (s) => {
45
+ if (!s) return null;
46
+ const up = s.toUpperCase();
47
+ return ASSETS.find((a) => up.startsWith(a)) ?? null;
48
+ };
49
+
50
+ // derived/klines/<source>/<ASSET>/<interval>/<file>
51
+ if (segs[0] === 'derived' && segs[1] === 'klines') {
52
+ return { venue, dataset: 'klines', asset: assetOf(segs[3]), interval: num(segs[4]), ext };
53
+ }
54
+
55
+ // data/predict-fun/<dataset>/...
56
+ if (segs[1] === 'predict-fun') {
57
+ const ds = segs[2];
58
+ if (ds === 'klines') {
59
+ return { venue, dataset: 'klines', asset: assetOf(segs[3]), interval: num(segs[4]), ext };
60
+ }
61
+ if (ds === 'orderbook') {
62
+ // BTC-5M / BTC-15M / MARKET-<id>
63
+ const m = /^([A-Za-z]+)-(\d+[mMhHdD]|DAILY)$/.exec(segs[3] ?? '');
64
+ return {
65
+ venue,
66
+ dataset: 'orderbook',
67
+ asset: assetOf(m?.[1] ?? segs[3]),
68
+ interval: m ? m[2].toLowerCase() : null,
69
+ ext,
70
+ };
71
+ }
72
+ if (ds === 'prices') return { venue, dataset: 'prices', asset: assetOf(segs[3]), interval: null, ext };
73
+ if (ds === 'markets') return { venue, dataset: 'markets', asset: null, interval: null, ext };
74
+ return { venue, dataset: 'other', asset: null, interval: null, ext };
75
+ }
76
+
77
+ // data/chainlink[-twap-30s|-60s]/daily/prices/<ASSETUSD>/<file>
78
+ if (segs[1]?.startsWith('chainlink')) {
79
+ const dataset = segs[1] === 'chainlink-twap-30s' ? 'twap30s'
80
+ : segs[1] === 'chainlink-twap-60s' ? 'twap60s'
81
+ : 'prices';
82
+ return { venue, dataset, asset: assetOf(segs[4]), interval: null, ext };
83
+ }
84
+
85
+ // data/polymarket/daily/<dataset>/<ASSET-interval>/<file>
86
+ if (segs[1] === 'polymarket') {
87
+ const dataset = DATASETS[segs[3]] ? segs[3] : 'other';
88
+ const m = /^([A-Za-z]+)-(\d+[mMhHdD])$/.exec(segs[4] ?? '');
89
+ return {
90
+ venue,
91
+ dataset,
92
+ asset: assetOf(m?.[1] ?? segs[4]),
93
+ interval: m ? m[2].toLowerCase() : null,
94
+ ext,
95
+ };
96
+ }
97
+
98
+ return { venue, dataset: 'other', asset: null, interval: null, ext };
99
+ }
100
+
101
+ /**
102
+ * The token that names "this dimension does not apply to the file".
103
+ *
104
+ * `interval` is only meaningful for datasets that are sliced by market period
105
+ * (book, price_change, klines, …); the settlement streams (prices, twap30s,
106
+ * twap60s) are continuous and classify to interval:null. Without a way to name
107
+ * that, `interval=5m` drops them — the SQL `WHERE interval='5m'` vs NULL trap,
108
+ * where "not applicable" reads as "does not match". A customer wanting "5m
109
+ * market data plus every period-less dataset" then cannot express it at all.
110
+ *
111
+ * Spelling it as a value rather than widening `interval=5m` implicitly keeps
112
+ * the filter-never-widens rule: only a query that asks for it gets it, so
113
+ * someone pulling just 5m klines is not handed the settlement streams too.
114
+ * No real dimension value is 'none' (intervals are 1s…1mo, assets are BTC…ZEC),
115
+ * so the token cannot collide with data.
116
+ */
117
+ export const NO_VALUE = 'none';
118
+
119
+ /**
120
+ * Does a classified file match a structured query? Absent filters match
121
+ * everything; every supplied filter must match (AND), and each may be a
122
+ * comma-separated list (OR within it). Within that list, NO_VALUE matches a
123
+ * file whose dimension is null — `interval=5m,none`.
124
+ */
125
+ export function matchesQuery(meta, q) {
126
+ const hit = (want, got) => {
127
+ if (!want) return true;
128
+ const alts = String(want).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
129
+ if (alts.length === 0) return true;
130
+ if (got == null) return alts.includes(NO_VALUE);
131
+ return alts.includes(String(got).toLowerCase());
132
+ };
133
+ return hit(q.venue, meta.venue)
134
+ && hit(q.dataset, meta.dataset)
135
+ && hit(q.asset, meta.asset)
136
+ && hit(q.interval, meta.interval);
137
+ }
138
+
139
+ /**
140
+ * Distinct dimension values across a set of classified files (for /v1/meta).
141
+ *
142
+ * `nullable` names the dimensions some file leaves empty, so a caller can find
143
+ * NO_VALUE without reading the docs — undiscoverable is how the interval=5m
144
+ * complaint started. It is deliberately NOT folded into `assets`/`intervals`:
145
+ * those have always held real symbols and real durations, and a client that
146
+ * builds an enum from them, parses them as durations, or loops over every
147
+ * interval to fetch data would break on a token — or quietly start pulling the
148
+ * period-less files it never asked for.
149
+ */
150
+ export function summarise(metas) {
151
+ const s = { venues: new Set(), datasets: new Set(), assets: new Set(), intervals: new Set() };
152
+ let assetless = false;
153
+ let intervalless = false;
154
+ for (const m of metas) {
155
+ s.venues.add(m.venue);
156
+ s.datasets.add(m.dataset);
157
+ if (m.asset) s.assets.add(m.asset); else assetless = true;
158
+ if (m.interval) s.intervals.add(m.interval); else intervalless = true;
159
+ }
160
+ const sortIntervals = (a, b) => {
161
+ const unit = { m: 1, h: 60, d: 1440, w: 10080, mo: 43200 };
162
+ const parse = (x) => {
163
+ const mm = /^(\d+)(mo|[mhdw])$/.exec(x);
164
+ return mm ? Number(mm[1]) * (unit[mm[2]] ?? 1) : Number.MAX_SAFE_INTEGER;
165
+ };
166
+ return parse(a) - parse(b);
167
+ };
168
+ return {
169
+ venues: [...s.venues].sort(),
170
+ datasets: [...s.datasets].sort(),
171
+ assets: [...s.assets].sort(),
172
+ intervals: [...s.intervals].sort(sortIntervals),
173
+ nullable: [assetless ? 'asset' : null, intervalless ? 'interval' : null].filter(Boolean),
174
+ };
175
+ }
@@ -0,0 +1,16 @@
1
+ // Which venue an archive path belongs to.
2
+ //
3
+ // Split out of scope.mjs so that the parts of the codebase that only need to
4
+ // CLASSIFY a path do not have to import the file that decides who may DOWNLOAD
5
+ // one. data-taxonomy.mjs needs this function, and data-taxonomy.mjs is reachable
6
+ // from the published `outcometick` package — which would otherwise have dragged
7
+ // the whole entitlement gate into a public repo along with it.
8
+ //
9
+ // scope.mjs still re-exports this so its own export surface is unchanged; the
10
+ // copy of scope.mjs that chainlink-data keeps in sync is unaffected.
11
+
12
+ /** The venue a given archive path belongs to. */
13
+ export function venueOfPath(filePath) {
14
+ return String(filePath).toLowerCase().split('/').includes('predict-fun')
15
+ ? 'predict' : 'polymarket';
16
+ }
package/bin/ot.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim so `ot` is a stable entry point regardless of the layout.
3
+ import { main } from '../cli/ot.mjs';
4
+ main(process.argv.slice(2)).then((code) => process.exit(code));
@@ -0,0 +1,71 @@
1
+ // The HTTP side of the CLI, in one place.
2
+ //
3
+ // Shared so that the key is read the same way everywhere: from the environment,
4
+ // never from a flag. A flag lands in shell history and in the process list, and
5
+ // this key spends money.
6
+
7
+ export const DEFAULT_API = 'https://outcometick.com';
8
+
9
+ /** The key, from the environment. */
10
+ export function readKey() {
11
+ const key = process.env.OT_BACKTEST_KEY;
12
+ if (!key) {
13
+ throw new Error('OT_BACKTEST_KEY is not set.\n'
14
+ + ' It is the key you were emailed when you bought credits.\n'
15
+ + ' export OT_BACKTEST_KEY="bt_…"');
16
+ }
17
+ return key;
18
+ }
19
+
20
+ async function request(api, path, { method = 'GET', body, key, redirect } = {}) {
21
+ let res;
22
+ try {
23
+ res = await fetch(`${api}${path}`, {
24
+ method,
25
+ ...(redirect ? { redirect } : {}),
26
+ headers: {
27
+ ...(body ? { 'content-type': 'application/json' } : {}),
28
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
29
+ },
30
+ ...(body ? { body: JSON.stringify(body) } : {}),
31
+ });
32
+ } catch (err) {
33
+ throw new Error(`could not reach ${api}: ${err.message}`);
34
+ }
35
+ return res;
36
+ }
37
+
38
+ /** GET returning parsed JSON, without throwing on a non-2xx. */
39
+ export async function get(api, path, key) {
40
+ const res = await request(api, path, { key });
41
+ const text = await res.text();
42
+ let json;
43
+ try { json = JSON.parse(text); } catch { json = null; }
44
+ return { status: res.status, json, text };
45
+ }
46
+
47
+ /** POST returning parsed JSON, without throwing on a non-2xx. */
48
+ export async function post(api, path, body, key) {
49
+ const res = await request(api, path, { method: 'POST', body, key });
50
+ const text = await res.text();
51
+ let json;
52
+ try { json = JSON.parse(text); } catch { json = null; }
53
+ return { status: res.status, json, text };
54
+ }
55
+
56
+ /**
57
+ * GET a binary body, following the archive endpoint's redirect to R2.
58
+ *
59
+ * The redirect target is presigned and short-lived, so it is followed
60
+ * immediately rather than handed back to the caller to use later.
61
+ */
62
+ export async function getBinary(api, path, key) {
63
+ const res = await request(api, path, { key });
64
+ if (!res.ok) {
65
+ const text = await res.text();
66
+ let json;
67
+ try { json = JSON.parse(text); } catch { json = null; }
68
+ return { status: res.status, json, text, body: null };
69
+ }
70
+ return { status: res.status, json: null, text: '', body: Buffer.from(await res.arrayBuffer()) };
71
+ }