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,43 @@
1
+ // `ot fetch <run_id>` — download a finished run's archive.
2
+ //
3
+ // Referenced by `ot status`, which is why it exists: the same rule as `ot
4
+ // status` itself — do not print a command that is not real.
5
+ //
6
+ // The archive URL is presigned for 15 minutes and is a bearer token for someone
7
+ // else's strategy results, so this asks the API for a fresh one each time
8
+ // rather than caching anything.
9
+
10
+ import { writeFile } from 'node:fs/promises';
11
+ import { readKey, getBinary, DEFAULT_API } from '../api-client.mjs';
12
+
13
+ export async function cmdFetch({ dir, flags }) {
14
+ const runId = dir;
15
+ if (!runId || runId === '.') throw new Error('usage: ot fetch <run_id> [--out <file>]');
16
+ const key = readKey();
17
+ const api = flags.api ?? DEFAULT_API;
18
+
19
+ const { status, json, text, body } = await getBinary(
20
+ api, `/v1/backtest/run/${encodeURIComponent(runId)}/archive`, key,
21
+ );
22
+
23
+ if (status === 404) {
24
+ process.stderr.write(`\n no run ${runId} under this key\n\n`);
25
+ return 1;
26
+ }
27
+ if (status === 409) {
28
+ // The run exists but has produced nothing yet — a different thing from a
29
+ // missing run, and the status is the useful part.
30
+ process.stderr.write(`\n no archive yet — run is ${json?.status ?? 'not finished'}\n`
31
+ + ` ot status ${runId}\n\n`);
32
+ return 1;
33
+ }
34
+ if (status !== 200 || !body) {
35
+ process.stderr.write(`\n ${status}: ${json?.error ?? text.slice(0, 300)}\n\n`);
36
+ return 1;
37
+ }
38
+
39
+ const out = flags.out ?? `${runId}.zip`;
40
+ await writeFile(out, body);
41
+ process.stdout.write(`\n ${out} (${(body.length / 1024).toFixed(0)} KB)\n\n`);
42
+ return 0;
43
+ }
@@ -0,0 +1,269 @@
1
+ // `ot run` — replay locally, against a cloned sample archive.
2
+ //
3
+ // The same engine, the same harnesses, the same report and the same archive
4
+ // writer the queue uses. That is the whole point of the command: the docs
5
+ // promise "a backtest here and a backtest on your own machine after subscribing
6
+ // read the same bytes", and the only way that holds is by not having a second
7
+ // implementation of any of it.
8
+ //
9
+ // What differs from the worker: the data comes off disk instead of R2, and
10
+ // there is no container. `ot run` says so plainly rather than implying the
11
+ // isolation is the same — locally the strategy runs with the user's own
12
+ // privileges, which is fine, because it is their code on their machine.
13
+
14
+ import { randomBytes } from 'node:crypto';
15
+ import { spawn } from 'node:child_process';
16
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { LANGUAGES, HOOK_NAMES, LIMITS } from '../../api/lib/backtest-contract.mjs';
22
+ import { CHANNEL, EXIT, parseTrade, parseFill, parseResult, parseOutputLine } from '../../runner/harness/protocol.mjs';
23
+ import { buildReport, metrics, LATENCY_STEPS } from '../../runner/engine/report.mjs';
24
+ import { buildArchive } from '../../runner/archive.mjs';
25
+ import { loadLocalDay, localDays, looksLikeArchive } from '../local-data.mjs';
26
+ import { readSubmission, validate } from '../ot.mjs';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const RUNNER = path.join(HERE, '..', '..', 'runner');
30
+
31
+ /** Run one pass of the local harness over the given markets. */
32
+ function runHarness({ languageId, jobDir, job, markets, outputKey }) {
33
+ const cmd = languageId === 'python' ? (process.env.OT_PYTHON || 'python3') : process.execPath;
34
+ const argv = languageId === 'python'
35
+ ? [path.join(RUNNER, 'harness/python/harness.py'), jobDir]
36
+ : [path.join(RUNNER, 'harness/node/harness.mjs'), jobDir];
37
+
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(cmd, argv, { stdio: ['pipe', 'inherit', 'pipe', 'pipe'] });
40
+ const lines = [];
41
+ let tail = '';
42
+ let stderr = '';
43
+ child.stderr.on('data', (d) => { stderr += d; });
44
+ child.stdio[3].on('data', (d) => {
45
+ tail += d;
46
+ const parts = tail.split('\n');
47
+ tail = parts.pop();
48
+ for (const line of parts) {
49
+ if (!line) continue;
50
+ const parsed = parseOutputLine(outputKey, line);
51
+ if (parsed) lines.push(parsed);
52
+ }
53
+ });
54
+ child.on('error', reject);
55
+ child.on('close', (code) => resolve({ code, stderr, lines }));
56
+
57
+ child.stdin.on('error', () => {});
58
+ child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
59
+ for (const m of markets) {
60
+ child.stdin.write(`${JSON.stringify({ market: m.market, stream: m.stream, n: m.events.length })}\n`);
61
+ for (const ev of m.events) child.stdin.write(`${JSON.stringify(ev)}\n`);
62
+ }
63
+ child.stdin.end();
64
+ });
65
+ }
66
+
67
+ function demux(lines) {
68
+ const trades = [];
69
+ const fills = [];
70
+ const logs = [];
71
+ let result = parseResult({});
72
+ for (const { channel, payload } of lines) {
73
+ if (channel === CHANNEL.log) { logs.push(payload); continue; }
74
+ if (channel === CHANNEL.result) {
75
+ try { result = parseResult(JSON.parse(payload)); } catch { /* keep the empty one */ }
76
+ continue;
77
+ }
78
+ let raw;
79
+ try { raw = JSON.parse(payload); } catch { continue; }
80
+ const row = channel === CHANNEL.trade ? parseTrade(raw) : parseFill(raw);
81
+ if (!row) continue;
82
+ (channel === CHANNEL.trade ? trades : fills).push(row);
83
+ }
84
+ return { trades, fills, logs: logs.join('\n'), result };
85
+ }
86
+
87
+ export async function cmdRun({ dir, flags }) {
88
+ const dataRoot = flags.data;
89
+ if (!dataRoot) {
90
+ throw new Error('--data is required: point it at a cloned sample archive\n'
91
+ + ' git clone https://github.com/Ligengxin96/polymarket-data-samples');
92
+ }
93
+ if (!await looksLikeArchive(dataRoot)) {
94
+ throw new Error(`${path.resolve(dataRoot)} does not look like an archive — no recognisable data files under it`);
95
+ }
96
+
97
+ const files = await readSubmission(dir);
98
+ const checked = await validate(files);
99
+ const { manifest } = checked;
100
+ const languageId = manifest.languageId;
101
+
102
+ const available = await localDays(dataRoot);
103
+ if (available.length === 0) throw new Error(`no dated files under ${path.resolve(dataRoot)}`);
104
+ const days = flags.date ? [flags.date] : available;
105
+ const unknown = days.filter((d) => !available.includes(d));
106
+ if (unknown.length) {
107
+ throw new Error(`${unknown.join(', ')} not in ${path.resolve(dataRoot)} — it holds ${available[0]}..${available[available.length - 1]}`);
108
+ }
109
+
110
+ const venue = flags.venue ?? 'polymarket';
111
+ const assets = (flags.assets ?? '').split(',').map((a) => a.trim().toUpperCase()).filter(Boolean);
112
+
113
+ const markets = [];
114
+ for (const day of days) {
115
+ const loaded = await loadLocalDay({
116
+ root: dataRoot, day, venue,
117
+ assets: assets.length ? assets : ['BTC', 'ETH', 'SOL', 'XRP'],
118
+ datasets: manifest.datasets,
119
+ });
120
+ if (loaded.markets.length === 0) {
121
+ process.stderr.write(` ${day}: ${loaded.reason}\n`);
122
+ continue;
123
+ }
124
+ markets.push(...loaded.markets);
125
+ }
126
+ if (markets.length === 0) throw new Error('no market-days could be read from that archive');
127
+
128
+ const jobDir = await mkdtemp(path.join(tmpdir(), 'ot-run-'));
129
+ try {
130
+ const src = path.join(jobDir, 'src');
131
+ await mkdir(src, { recursive: true });
132
+ for (const f of checked.files) {
133
+ const dest = path.resolve(src, f.name);
134
+ await mkdir(path.dirname(dest), { recursive: true });
135
+ await writeFile(dest, f.content);
136
+ }
137
+ // The SDK has to be resolvable exactly as it is in the image, or the
138
+ // documented `import … from "outcometick"` fails locally and works remotely.
139
+ if (languageId === 'nodejs') {
140
+ const { cp } = await import('node:fs/promises');
141
+ await cp(path.join(RUNNER, 'harness/node/sdk'),
142
+ path.join(jobDir, 'node_modules', 'outcometick'), { recursive: true });
143
+ }
144
+
145
+ const baseJob = {
146
+ entry: manifest.entry,
147
+ hooks: Object.fromEntries(manifest.hooks.map((h) => [h, HOOK_NAMES[languageId][h]])),
148
+ arities: { on_market_open: 3, on_tick: 3, on_book: 3, on_trade: 3, on_settle: 4 },
149
+ params: manifest.params,
150
+ mode: manifest.mode,
151
+ seed: Number(flags.seed ?? 1),
152
+ feeBps: Number(flags['fee-bps'] ?? 0),
153
+ limits: LIMITS,
154
+ };
155
+ if (languageId === 'python') {
156
+ process.env.PYTHONPATH = [path.join(RUNNER, 'harness/python'), process.env.PYTHONPATH]
157
+ .filter(Boolean).join(path.delimiter);
158
+ }
159
+
160
+ if (!flags.json) {
161
+ process.stdout.write(`\n ${markets.length} market-days · ${manifest.language} · local replay\n`);
162
+ }
163
+
164
+ const passes = [];
165
+ for (const step of LATENCY_STEPS) {
166
+ const outputKey = randomBytes(32).toString('hex');
167
+ const res = await runHarness({
168
+ languageId, jobDir, outputKey, markets,
169
+ job: { ...baseJob, fillDelayMs: step.ms },
170
+ });
171
+ const out = demux(res.lines);
172
+ if (step.ms === 0) {
173
+ if (res.code === EXIT.rejected || res.code === EXIT.budget) {
174
+ const r = out.result.rejection ?? { code: 'E_RUNTIME', detail: res.stderr.slice(0, 2000) };
175
+ const err = new Error(r.detail);
176
+ err.code = r.code;
177
+ err.detail = r.detail;
178
+ throw err;
179
+ }
180
+ if (res.code !== EXIT.ok) throw new Error(res.stderr.slice(0, 2000) || `harness exited ${res.code}`);
181
+ }
182
+ passes.push({ delayMs: step.ms, ...out, ok: res.code === EXIT.ok });
183
+ // A strategy that never traded has no latency curve to draw.
184
+ if (step.ms === 0 && out.trades.length === 0) break;
185
+ }
186
+
187
+ const base = passes[0];
188
+ const marketMeta = new Map(markets.map((m) => [m.market.market_id, {
189
+ market_id: m.market.market_id,
190
+ asset: m.market.asset,
191
+ interval: m.market.interval,
192
+ outcome: m.market.outcome,
193
+ up_px: m.up_px,
194
+ down_px: m.down_px,
195
+ stream: m.stream,
196
+ }]));
197
+
198
+ const report = buildReport({
199
+ runId: `local_${days[0]}`,
200
+ submittedAt: 0,
201
+ manifest,
202
+ scope: {
203
+ venue,
204
+ assets: assets.length ? assets : [...new Set(markets.map((m) => m.market.asset).filter(Boolean))],
205
+ from: days[0],
206
+ to: days[days.length - 1],
207
+ marketDays: markets.length,
208
+ archivedDayCount: days.length,
209
+ },
210
+ scanned: { markets: base.result.marketsRun, market_days: markets.length, events: base.result.eventsSeen },
211
+ trades: base.trades,
212
+ fills: base.fills,
213
+ marketSummaries: [...marketMeta.values()],
214
+ marketMeta,
215
+ feesPaid: base.result.feesPaid,
216
+ latency: passes.filter((p) => p.ok).map((p) => ({
217
+ delayMs: p.delayMs, netPnl: metrics(p.trades).net_pnl ?? 0,
218
+ })),
219
+ sweep: null,
220
+ crosschecks: base.result.crosschecks,
221
+ seed: Number(flags.seed ?? 1),
222
+ coverage: {
223
+ local: true,
224
+ source: path.resolve(dataRoot),
225
+ market_days_scanned: markets.length,
226
+ streams: [...marketMeta.values()].reduce((acc, m) => {
227
+ const k = m.stream ?? 'unknown';
228
+ acc[k] = (acc[k] ?? 0) + 1;
229
+ return acc;
230
+ }, {}),
231
+ },
232
+ budget: base.result.budget,
233
+ });
234
+
235
+ if (flags.json) {
236
+ process.stdout.write(`${JSON.stringify(report)}\n`);
237
+ } else {
238
+ const m = report.metrics;
239
+ const money = (v) => (v == null ? '—' : `${v < 0 ? '-' : '+'}$${Math.abs(v).toLocaleString()}`);
240
+ process.stdout.write(`\n net pnl ${money(m.net_pnl)}\n`);
241
+ process.stdout.write(` trades ${m.trades}\n`);
242
+ process.stdout.write(` win rate ${m.win_rate == null ? '—' : `${(m.win_rate * 100).toFixed(1)}%`}\n`);
243
+ process.stdout.write(` brier ${m.brier_score ?? '—'}\n`);
244
+ process.stdout.write(` edge/contract ${m.edge_per_contract == null ? '—' : `${(m.edge_per_contract * 100).toFixed(1)}¢`}\n`);
245
+ process.stdout.write(` fees ${money(m.fees)}\n`);
246
+ if (report.slippage.pnl_lost_to_slippage != null) {
247
+ process.stdout.write(` lost to slip ${money(report.slippage.pnl_lost_to_slippage)}\n`);
248
+ }
249
+ process.stdout.write('\n This is a LOCAL replay: no container, your own privileges, sample data.\n');
250
+ }
251
+
252
+ const outFile = flags.out ?? `${path.basename(path.resolve(dir))}-local.zip`;
253
+ const zip = await buildArchive({
254
+ runId: report.run_id,
255
+ report,
256
+ trades: base.trades,
257
+ fills: base.fills,
258
+ logs: base.logs,
259
+ source: checked.files,
260
+ });
261
+ await writeFile(outFile, zip);
262
+ if (!flags.json) process.stdout.write(` archive ${outFile} (${(zip.length / 1024).toFixed(0)} KB)\n\n`);
263
+ return 0;
264
+ } finally {
265
+ await rm(jobDir, { recursive: true, force: true });
266
+ }
267
+ }
268
+
269
+ export { LANGUAGES };
@@ -0,0 +1,102 @@
1
+ // `ot status <run_id>` — where a submitted run got to.
2
+ //
3
+ // This command exists because `ot submit` prints it. A tool that tells you to
4
+ // run something that does not exist is worse than one that tells you nothing.
5
+ //
6
+ // It reads; it never bills, cancels or retries. The archive it points at is
7
+ // fetched through the API rather than a stored URL: archive links are 15-minute
8
+ // bearer tokens for someone's strategy results, so the right thing to hold onto
9
+ // is the run id, not the link.
10
+
11
+ import { readKey, get } from '../api-client.mjs';
12
+
13
+ const STATUS_LINE = {
14
+ staging: 'staging — the submission is still being stored',
15
+ queued: 'queued — waiting for a worker',
16
+ running: 'running',
17
+ done: 'done',
18
+ failed: 'failed',
19
+ rejected: 'rejected',
20
+ expired: 'expired — the archive is past its retention window',
21
+ };
22
+
23
+ // Statuses in which the ledger has settled, so spent-vs-returned is a fact
24
+ // rather than a guess. Anything not listed still holds the credits.
25
+ const SETTLED = new Set(['done', 'failed', 'rejected', 'expired']);
26
+
27
+ function ms(v) {
28
+ return v == null ? '—' : new Date(v).toISOString().replace('T', ' ').slice(0, 19);
29
+ }
30
+
31
+ export async function cmdStatus({ dir, flags }) {
32
+ // The run id is positional, which `parseArgs` hands back as `dir`.
33
+ const runId = dir;
34
+ if (!runId || runId === '.') throw new Error('usage: ot status <run_id>');
35
+ const key = readKey();
36
+ const api = flags.api ?? 'https://outcometick.com';
37
+
38
+ const { status, json, text } = await get(api, `/v1/backtest/run/${encodeURIComponent(runId)}`, key);
39
+ if (status === 404) {
40
+ process.stderr.write(`\n no run ${runId} under this key\n\n`);
41
+ return 1;
42
+ }
43
+ if (status === 401) {
44
+ process.stderr.write('\n OT_BACKTEST_KEY was not accepted.\n\n');
45
+ return 1;
46
+ }
47
+ if (status !== 200 || !json) {
48
+ process.stderr.write(`\n ${status}: ${json?.error ?? text.slice(0, 300)}\n\n`);
49
+ return 1;
50
+ }
51
+
52
+ if (flags.json) {
53
+ process.stdout.write(`${JSON.stringify(json)}\n`);
54
+ return 0;
55
+ }
56
+
57
+ process.stdout.write(`\n ${json.run_id}\n`);
58
+ process.stdout.write(` status ${STATUS_LINE[json.status] ?? json.status}\n`);
59
+ process.stdout.write(` scope ${json.venue} · ${(json.assets ?? []).join(' ')} · ${json.from}..${json.to}\n`);
60
+ process.stdout.write(` market-days ${json.market_days}\n`);
61
+ process.stdout.write(` submitted ${ms(json.created_ms)}\n`);
62
+ if (json.finished_ms) process.stdout.write(` finished ${ms(json.finished_ms)}\n`);
63
+
64
+ if (json.rejection) {
65
+ process.stdout.write(`\n ${json.rejection.code}\n ${json.rejection.detail}\n`);
66
+ }
67
+
68
+ // Held vs spent, said plainly: a partial run bills for the market-days it
69
+ // actually read, and the difference goes back. Printing only one of the two
70
+ // numbers is how a refund looks like an overcharge.
71
+ //
72
+ // But "returned" may only be said once the run has actually finished. While
73
+ // it is still staging or queued nothing has been spent and nothing has been
74
+ // returned either — the credits are HELD. Deriving the refund from
75
+ // held - spent in that state prints "0 spent (100 returned)" at a moment when
76
+ // the customer's balance is still 100 short, which is the opposite of
77
+ // reassuring.
78
+ if (SETTLED.has(json.status) && json.credits_spent != null) {
79
+ const held = json.credits_held ?? 0;
80
+ const returned = held - json.credits_spent;
81
+ process.stdout.write(` credits ${json.credits_spent} spent`);
82
+ process.stdout.write(returned > 0 ? ` (${returned} returned)\n` : '\n');
83
+ } else if (json.credits_held != null) {
84
+ process.stdout.write(` credits ${json.credits_held} held\n`);
85
+ }
86
+
87
+ const m = json.report?.metrics;
88
+ if (m) {
89
+ const money = (v) => (v == null ? '—' : `${v < 0 ? '-' : '+'}$${Math.abs(v).toLocaleString()}`);
90
+ process.stdout.write(`\n net pnl ${money(m.net_pnl)}\n`);
91
+ process.stdout.write(` trades ${m.trades}\n`);
92
+ if (m.win_rate != null) process.stdout.write(` win rate ${(m.win_rate * 100).toFixed(1)}%\n`);
93
+ }
94
+
95
+ if (json.archive_available) {
96
+ const kb = json.archive_bytes == null ? '' : ` (${(json.archive_bytes / 1024).toFixed(0)} KB)`;
97
+ process.stdout.write(`\n archive${kb}\n`);
98
+ process.stdout.write(` ot fetch ${json.run_id}\n`);
99
+ }
100
+ process.stdout.write('\n');
101
+ return 0;
102
+ }
@@ -0,0 +1,77 @@
1
+ // `ot submit` — send a validated submission to the queue.
2
+ //
3
+ // Validates locally FIRST, with the same validator the queue runs, so the
4
+ // common failures cost a round trip rather than a queued run. The server
5
+ // validates again regardless: a client that decided its own submission was fine
6
+ // is not a security model, and the CLI is a convenience, not an authority.
7
+ //
8
+ // The same goes for the price. `ot submit` prints what the server quoted; it
9
+ // never computes one. A number this file derived would be a second pricing
10
+ // implementation, and the first time it disagreed the customer would be told
11
+ // one thing and charged another.
12
+
13
+ import { readSubmission, validate } from '../ot.mjs';
14
+ import { readKey, post, DEFAULT_API } from '../api-client.mjs';
15
+
16
+ export async function cmdSubmit({ dir, flags }) {
17
+ const api = flags.api ?? DEFAULT_API;
18
+ const key = readKey();
19
+
20
+ const assets = (flags.assets ?? '').split(',').map((a) => a.trim()).filter(Boolean);
21
+ if (assets.length === 0) throw new Error('--assets is required, e.g. --assets btc,eth');
22
+ if (!flags.from && !flags.range) throw new Error('--from and --to are required, or --range "30 days"');
23
+
24
+ const files = await readSubmission(dir);
25
+ const scope = {
26
+ venue: flags.venue ?? 'polymarket',
27
+ assets,
28
+ ...(flags.range ? { range: flags.range } : { from: flags.from, to: flags.to }),
29
+ };
30
+
31
+ // Locally first. The rejection codes are identical either way, so a customer
32
+ // who fixes what `ot check` said will not be told something different here.
33
+ await validate(files);
34
+
35
+ const body = { ...scope, files, ...(flags.email ? { email: flags.email } : {}) };
36
+ const { status, json, text } = await post(api, '/v1/backtest/submit', body, key);
37
+
38
+ if (status === 202 && json?.run_id) {
39
+ if (flags.json) {
40
+ process.stdout.write(`${JSON.stringify(json)}\n`);
41
+ return 0;
42
+ }
43
+ const q = json.quote ?? {};
44
+ process.stdout.write(`\n queued — ${json.run_id}\n`);
45
+ process.stdout.write(` scope ${q.venue} · ${(q.assets ?? []).join(' ')} · ${q.from}..${q.to}\n`);
46
+ process.stdout.write(` market-days ${q.marketDays}\n`);
47
+ if (q.missingDays > 0) {
48
+ // Said out loud: a smaller number than the range asked for, unexplained,
49
+ // reads as a bug in our favour.
50
+ process.stdout.write(` not in the archive: ${q.missingDays} day(s) — not billed\n`);
51
+ }
52
+ process.stdout.write(` cost ${json.credits_held} cr\n`);
53
+ process.stdout.write(` source sha256 ${String(json.source_sha256).slice(0, 16)}…\n\n`);
54
+ process.stdout.write(` ot status ${json.run_id} (or wait for the email)\n\n`);
55
+ return 0;
56
+ }
57
+
58
+ if (status === 402) {
59
+ process.stderr.write(`\n not enough credits: have ${json?.balance}, this run needs ${json?.required}\n\n`);
60
+ return 1;
61
+ }
62
+ if (status === 401) {
63
+ process.stderr.write('\n OT_BACKTEST_KEY was not accepted.\n\n');
64
+ return 1;
65
+ }
66
+ if (status === 422 && json?.code) {
67
+ // The server rejected something the local validator passed. That should not
68
+ // happen — the promise is that they are the same validator — so say so
69
+ // rather than printing it as an ordinary error.
70
+ process.stderr.write(`\n ${json.code}\n ${json.detail}\n\n`
71
+ + ' This passed `ot check` locally. That is a bug on our side —\n'
72
+ + ' please report it with the manifest.\n\n');
73
+ return 1;
74
+ }
75
+ process.stderr.write(`\n ${status}: ${json?.error ?? text.slice(0, 300)}\n\n`);
76
+ return 1;
77
+ }
@@ -0,0 +1,177 @@
1
+ // Reading a cloned sample archive off disk, for `ot run`.
2
+ //
3
+ // The counterpart to runner/fetch-data.mjs, which reads the same archive out of
4
+ // R2. Both go through runner/events.mjs for the row -> event mapping, because
5
+ // the docs make a promise about exactly this pair:
6
+ //
7
+ // The identical files, same checksums, same coverage report. A backtest
8
+ // here and a backtest on your own machine after subscribing read the same
9
+ // bytes — that is the point of offering it.
10
+ //
11
+ // Two decoders would make that false the first time they disagreed, and the
12
+ // disagreement would be silent.
13
+
14
+ import { createReadStream } from 'node:fs';
15
+ import { readdir, stat } from 'node:fs/promises';
16
+ import { createInterface } from 'node:readline';
17
+ import { createGunzip } from 'node:zlib';
18
+ import path from 'node:path';
19
+
20
+ import { classifyPath } from '../api/lib/data-taxonomy.mjs';
21
+ import { archiveDatasetsFor, fileMatchesRun } from '../api/lib/backtest-datasets.mjs';
22
+ import { indexMarkets, eventsFromRow, finaliseMarket, parseRow } from '../runner/events.mjs';
23
+
24
+ /**
25
+ * Every file under a directory, as archive-relative paths.
26
+ *
27
+ * Symlinks are skipped rather than followed, in both directions: a symlinked
28
+ * directory is not descended into and a symlinked file is not read. `--data`
29
+ * points at a directory the user cloned from the internet, and git happily
30
+ * carries symlinks — so without this, a repo dressed up as sample data could
31
+ * name `~/.ssh/id_rsa` as a prices file and have `ot run` read it. Very little
32
+ * of an arbitrary file survives being parsed as archive rows, which makes this
33
+ * a poor exfiltration channel rather than a safe one; there is no reason to
34
+ * leave it open when the fix is to not follow the link.
35
+ *
36
+ * Regular files and real directories are all a genuine archive contains.
37
+ */
38
+ async function walk(root, prefix = '', depth = 0) {
39
+ const out = [];
40
+ // A real archive is nested about six deep. The bound is what stops a
41
+ // symlink-free cycle or a pathologically deep tree from spinning here.
42
+ if (depth > 12) return out;
43
+ let entries;
44
+ try {
45
+ entries = await readdir(path.join(root, prefix), { withFileTypes: true });
46
+ } catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
51
+ if (e.isSymbolicLink()) continue;
52
+ if (e.isDirectory()) out.push(...await walk(root, rel, depth + 1));
53
+ else if (e.isFile()) out.push(rel);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Stream one local archive file, gunzipping if needed, yielding parsed rows. */
59
+ async function* readRows(root, rel) {
60
+ const full = path.join(root, rel);
61
+ const raw = createReadStream(full);
62
+ const stream = rel.endsWith('.gz') ? raw.pipe(createGunzip()) : raw;
63
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
64
+
65
+ const isCsv = rel.includes('.csv');
66
+ let header = null;
67
+ for await (const line of rl) {
68
+ const s = line.trim();
69
+ if (!s) continue;
70
+ if (isCsv && header === null) { header = s.split(','); continue; }
71
+ const row = parseRow(s, { isCsv, header });
72
+ if (row) yield row;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Which UTC day a sample archive path belongs to.
78
+ *
79
+ * The sample repos name files with the day in them, the same as the archive.
80
+ * A file we cannot date is skipped rather than guessed at — a mis-dated file
81
+ * would put one day's events into another day's market.
82
+ */
83
+ export function dayOfPath(rel) {
84
+ const m = /(\d{4}-\d{2}-\d{2})/.exec(rel);
85
+ return m ? m[1] : null;
86
+ }
87
+
88
+ /**
89
+ * Load one day out of a local archive.
90
+ *
91
+ * Returns the same shape fetchMarketDays does, so `ot run` and the worker feed
92
+ * the harness identically.
93
+ */
94
+ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
95
+ const archiveDatasets = archiveDatasetsFor({ datasets, venue, from: day, to: day });
96
+ const all = await walk(root);
97
+ const wanted = all.filter((rel) => dayOfPath(rel) === day
98
+ && fileMatchesRun(rel, { venue, assets, archiveDatasets }));
99
+
100
+ if (wanted.length === 0) {
101
+ return { markets: [], reason: `no files for ${day} under ${root}` };
102
+ }
103
+
104
+ // Markets first: everything else is keyed by market, and the settlement
105
+ // stream resolution needs this.
106
+ const marketRows = [];
107
+ for (const rel of wanted.filter((r) => classifyPath(r).dataset === 'markets')) {
108
+ for await (const row of readRows(root, rel)) marketRows.push(row);
109
+ }
110
+ const markets = indexMarkets(marketRows);
111
+ if (markets.size === 0) {
112
+ return { markets: [], reason: `no market metadata for ${day}` };
113
+ }
114
+
115
+ const byMarket = new Map();
116
+ for (const rel of wanted) {
117
+ if (classifyPath(rel).dataset === 'markets') continue;
118
+ for await (const row of readRows(root, rel)) {
119
+ for (const [id, ev] of eventsFromRow(rel, row, markets)) {
120
+ if (!markets.has(id)) continue;
121
+ let list = byMarket.get(id);
122
+ if (!list) { list = []; byMarket.set(id, list); }
123
+ list.push(ev);
124
+ }
125
+ }
126
+ }
127
+
128
+ const out = [];
129
+ for (const [marketId, events] of byMarket) {
130
+ const market = markets.get(marketId);
131
+ // Fail-closed, exactly as the worker does: a market whose settlement stream
132
+ // we cannot read is dropped, not guessed at.
133
+ if (!market || market.stream == null) continue;
134
+ const { events: inWindow, up_px, down_px } = finaliseMarket(events, market);
135
+ if (inWindow.length === 0) continue;
136
+ out.push({
137
+ market: {
138
+ market_id: market.market_id,
139
+ asset: market.asset,
140
+ interval: market.interval,
141
+ strike: market.strike,
142
+ outcome: market.outcome,
143
+ open_ts_ms: market.open_ts_ms,
144
+ close_ts_ms: market.close_ts_ms,
145
+ },
146
+ events: inWindow,
147
+ stream: market.stream,
148
+ day,
149
+ up_px,
150
+ down_px,
151
+ });
152
+ }
153
+ return { markets: out, reason: null };
154
+ }
155
+
156
+ /** Days a local archive appears to hold, sorted. */
157
+ export async function localDays(root) {
158
+ const all = await walk(root);
159
+ const days = new Set();
160
+ for (const rel of all) {
161
+ const d = dayOfPath(rel);
162
+ if (d) days.add(d);
163
+ }
164
+ return [...days].sort();
165
+ }
166
+
167
+ /** Does this look like a cloned sample archive at all? */
168
+ export async function looksLikeArchive(root) {
169
+ try {
170
+ const s = await stat(root);
171
+ if (!s.isDirectory()) return false;
172
+ } catch {
173
+ return false;
174
+ }
175
+ const all = await walk(root);
176
+ return all.some((rel) => classifyPath(rel).dataset !== 'other');
177
+ }