outcometick 1.5.2 → 1.6.1

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.
@@ -114,6 +114,20 @@ export function classifyPath(filePath) {
114
114
  * No real dimension value is 'none' (intervals are 1s…1mo, assets are BTC…ZEC),
115
115
  * so the token cannot collide with data.
116
116
  */
117
+ /**
118
+ * In the archive, never offered publicly.
119
+ *
120
+ * ZEC markets were collected but never went live on the venue, so counting it
121
+ * makes every public figure one too high — "Polymarket 8 assets" printed beside
122
+ * a venue that shows seven. Excluded from what we ADVERTISE, not from what we
123
+ * serve: a subscriber querying the archive still gets what the archive holds.
124
+ */
125
+ export const UNLISTED_ASSETS = Object.freeze(['ZEC']);
126
+
127
+ /** The assets a public-facing figure should count. */
128
+ export const publicAssets = (assets) =>
129
+ [...assets].filter((a) => !UNLISTED_ASSETS.includes(a)).sort();
130
+
117
131
  export const NO_VALUE = 'none';
118
132
 
119
133
  /**
@@ -20,7 +20,11 @@ import { fileURLToPath } from 'node:url';
20
20
 
21
21
  import { LANGUAGES, HOOK_NAMES, LIMITS } from '../../api/lib/backtest-contract.mjs';
22
22
  import { CHANNEL, EXIT, parseTrade, parseFill, parseResult, parseOutputLine } from '../../runner/harness/protocol.mjs';
23
- import { buildReport, metrics, LATENCY_STEPS } from '../../runner/engine/report.mjs';
23
+ import {
24
+ countMarketDays, countStreams, buildCoverage, mergeReferenceRows, makeBookThrottle,
25
+ } from '../../runner/events.mjs';
26
+ import { loadSeries } from '../../runner/series-data.mjs';
27
+ import { buildReport } from '../../runner/engine/report.mjs';
24
28
  import { buildArchive } from '../../runner/archive.mjs';
25
29
  import { loadLocalDay, localDays, looksLikeArchive } from '../local-data.mjs';
26
30
  import { readSubmission, validate } from '../ot.mjs';
@@ -29,36 +33,65 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
33
  const RUNNER = path.join(HERE, '..', '..', 'runner');
30
34
 
31
35
  /** Run one pass of the local harness over the given markets. */
32
- function runHarness({ languageId, jobDir, job, markets, outputKey }) {
36
+ function runHarness({
37
+ languageId, jobDir, job, markets, outputKey,
38
+ // The submitter's own CSV, already parsed. Threaded in rather than read here
39
+ // so it is parsed once for the whole run, exactly as the worker does.
40
+ seriesRows = {}, seriesLags = {}, seriesNames = [],
41
+ }) {
33
42
  const cmd = languageId === 'python' ? (process.env.OT_PYTHON || 'python3') : process.execPath;
34
43
  const argv = languageId === 'python'
35
44
  ? [path.join(RUNNER, 'harness/python/harness.py'), jobDir]
36
45
  : [path.join(RUNNER, 'harness/node/harness.mjs'), jobDir];
37
46
 
38
47
  return new Promise((resolve, reject) => {
39
- const child = spawn(cmd, argv, { stdio: ['pipe', 'inherit', 'pipe', 'pipe'] });
48
+ // stdout is the result channel (see runner/harness/protocol.mjs), so it
49
+ // cannot be inherited: a strategy's own output would land mid-line. The
50
+ // harness discards that output for the same reason a container does.
51
+ const child = spawn(cmd, argv, { stdio: ['pipe', 'pipe', 'pipe'] });
40
52
  const lines = [];
53
+ let forged = 0;
41
54
  let tail = '';
42
55
  let stderr = '';
43
56
  child.stderr.on('data', (d) => { stderr += d; });
44
- child.stdio[3].on('data', (d) => {
57
+ child.stdout.on('data', (d) => {
45
58
  tail += d;
46
59
  const parts = tail.split('\n');
47
60
  tail = parts.pop();
48
61
  for (const line of parts) {
49
62
  if (!line) continue;
50
63
  const parsed = parseOutputLine(outputKey, line);
64
+ // Counted, not shrugged off. The queued worker counts the same bytes as
65
+ // forged, and a stray write between two real lines corrupts the one
66
+ // after it — so a local replay that quietly drops them can hand back a
67
+ // report the queued run would never have produced. Silence here is the
68
+ // exact shape of the divergence this CLI exists to rule out.
51
69
  if (parsed) lines.push(parsed);
70
+ else forged += 1;
52
71
  }
53
72
  });
54
73
  child.on('error', reject);
55
- child.on('close', (code) => resolve({ code, stderr, lines }));
74
+ child.on('close', (code) => resolve({ code, stderr, lines, forged }));
56
75
 
57
76
  child.stdin.on('error', () => {});
58
77
  child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
59
78
  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`);
79
+ // Series rows are INTERLEAVED into the same stream in event time, exactly
80
+ // as the worker sends them — and `lags` travels with them, or a signal
81
+ // that declared a publication delay would be visible the instant its row
82
+ // was stamped rather than when it could have existed.
83
+ const lines = m.events.map((ev) => JSON.stringify(ev));
84
+ const merged = seriesNames.length
85
+ ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
86
+ : lines;
87
+ child.stdin.write(`${JSON.stringify({
88
+ market: m.market,
89
+ stream: m.stream,
90
+ n: merged.length,
91
+ ...(seriesNames.length ? { series: seriesNames } : {}),
92
+ ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
93
+ })}\n`);
94
+ for (const line of merged) child.stdin.write(`${line}\n`);
62
95
  }
63
96
  child.stdin.end();
64
97
  });
@@ -68,20 +101,26 @@ function demux(lines) {
68
101
  const trades = [];
69
102
  const fills = [];
70
103
  const logs = [];
104
+ // Counted, not swallowed. The queue publishes `dropped_rows`, and a local run
105
+ // that discarded the same rows in silence reported a clean report over the
106
+ // identical archive — the number exists precisely so a customer can tell that
107
+ // something did not parse.
108
+ let malformed = 0;
71
109
  let result = parseResult({});
72
110
  for (const { channel, payload } of lines) {
73
111
  if (channel === CHANNEL.log) { logs.push(payload); continue; }
112
+ if (channel === CHANNEL.progress) continue;
74
113
  if (channel === CHANNEL.result) {
75
- try { result = parseResult(JSON.parse(payload)); } catch { /* keep the empty one */ }
114
+ try { result = parseResult(JSON.parse(payload)); } catch { malformed += 1; }
76
115
  continue;
77
116
  }
78
117
  let raw;
79
- try { raw = JSON.parse(payload); } catch { continue; }
118
+ try { raw = JSON.parse(payload); } catch { malformed += 1; continue; }
80
119
  const row = channel === CHANNEL.trade ? parseTrade(raw) : parseFill(raw);
81
- if (!row) continue;
120
+ if (!row) { malformed += 1; continue; }
82
121
  (channel === CHANNEL.trade ? trades : fills).push(row);
83
122
  }
84
- return { trades, fills, logs: logs.join('\n'), result };
123
+ return { trades, fills, logs: logs.join('\n'), result, malformed };
85
124
  }
86
125
 
87
126
  export async function cmdRun({ dir, flags }) {
@@ -107,23 +146,107 @@ export async function cmdRun({ dir, flags }) {
107
146
  throw new Error(`${unknown.join(', ')} not in ${path.resolve(dataRoot)} — it holds ${available[0]}..${available[available.length - 1]}`);
108
147
  }
109
148
 
149
+ // REFERENCE FEEDS ARE REFUSED. SERIES ARE NOT.
150
+ //
151
+ // The distinction is where the data lives, and it took a review to get right:
152
+ // a reference feed is the Binance archive, which sits on the worker's own disk
153
+ // and was deliberately never put in R2 — no local run can reach it. A series
154
+ // is the submitter's own CSV, and it is right here in the directory being run.
155
+ //
156
+ // Refusing both was the safe-looking answer and the wrong one: it would have
157
+ // forced every strategy using `ctx.ext()` to spend credits in the queue to
158
+ // discover a runtime mistake it could have found locally in a second. What
159
+ // must not happen is running with an EMPTY feed, which produces a report that
160
+ // looks comparable to the queued one and is not.
161
+ const refs = (manifest.reference ?? []).map((r) => (typeof r === 'string' ? r : r.name));
162
+ if (refs.length) {
163
+ throw new Error(
164
+ `this manifest declares reference feeds a local replay cannot supply:\n ${refs.join('\n ')}\n`
165
+ + ' They come from an archive held on the worker, so `ot run` would hand\n'
166
+ + ' your strategy empty feeds and a report that does not match the queued\n'
167
+ + ' one. Submit it instead:\n'
168
+ + ' ot submit . --assets btc --range "30 days"',
169
+ );
170
+ }
171
+
172
+ // The submitter's own CSV, parsed by the SAME reader the queue uses, so a
173
+ // header it would reject there is rejected here too.
174
+ const seriesRows = manifest.series?.length
175
+ ? loadSeries(manifest.series, checked.files)
176
+ : { rowsByName: {}, problems: [] };
177
+ const fatalSeries = (seriesRows.problems ?? []).filter((x) => x.fatal !== false);
178
+ if (fatalSeries.length) {
179
+ throw new Error(`series could not be read:\n ${
180
+ fatalSeries.map((x) => `${x.name} (${x.file}): ${x.problem}`).join('\n ')}`);
181
+ }
182
+ const seriesLags = Object.fromEntries(
183
+ (manifest.series ?? []).filter((x) => x.lag_ms > 0).map((x) => [x.name, x.lag_ms]),
184
+ );
185
+ const seriesNames = Object.keys(seriesRows.rowsByName ?? {});
186
+
110
187
  const venue = flags.venue ?? 'polymarket';
111
188
  const assets = (flags.assets ?? '').split(',').map((a) => a.trim().toUpperCase()).filter(Boolean);
112
189
 
113
190
  const markets = [];
191
+ // Gaps are part of the answer, not noise to drop.
192
+ //
193
+ // A queued run's coverage names every market it could not use; a local run
194
+ // reading the same archive said nothing, so the two disagreed about what had
195
+ // been covered while agreeing about everything else. That is the harder
196
+ // discrepancy to notice, because the report looks complete.
197
+ const missing = [];
114
198
  for (const day of days) {
115
199
  const loaded = await loadLocalDay({
116
200
  root: dataRoot, day, venue,
117
201
  assets: assets.length ? assets : ['BTC', 'ETH', 'SOL', 'XRP'],
118
202
  datasets: manifest.datasets,
203
+ intervals: manifest.intervals,
204
+ // The SAME cadence the queue would replay this range at — built from the
205
+ // whole range, not this day, so an asset's density never changes partway
206
+ // through a run. Per asset, not run-wide: see makeBookThrottle.
207
+ throttle: makeBookThrottle({
208
+ venue,
209
+ assets: assets.length ? assets : ['BTC', 'ETH', 'SOL', 'XRP'],
210
+ from: days[0],
211
+ to: days[days.length - 1],
212
+ }),
119
213
  });
120
214
  if (loaded.markets.length === 0) {
121
215
  process.stderr.write(` ${day}: ${loaded.reason}\n`);
216
+ missing.push({
217
+ day,
218
+ reason: loaded.reason ?? 'no markets',
219
+ ...(loaded.unusable?.length
220
+ ? {
221
+ partial: false,
222
+ markets: loaded.unusable.length,
223
+ dropped: loaded.unusable.slice(0, 10),
224
+ reasons: [...new Set(loaded.unusable.map((u) => u.why))],
225
+ }
226
+ : {}),
227
+ });
122
228
  continue;
123
229
  }
230
+ if (loaded.unusable?.length) {
231
+ const why = `${loaded.unusable.length} market(s) dropped: ${loaded.unusable[0].why}`;
232
+ process.stderr.write(` ${day}: ${why}\n`);
233
+ missing.push({
234
+ day, partial: true, markets: loaded.unusable.length, reason: why,
235
+ // Same bound the queue applies — see MAX_DROPPED_LISTED there.
236
+ dropped: loaded.unusable.slice(0, 10),
237
+ ...(loaded.unusable.length > 10
238
+ ? { dropped_truncated: loaded.unusable.length - 10 } : {}),
239
+ reasons: [...new Set(loaded.unusable.map((u) => u.why))],
240
+ });
241
+ }
124
242
  markets.push(...loaded.markets);
125
243
  }
126
244
  if (markets.length === 0) throw new Error('no market-days could be read from that archive');
245
+ // ONE ASSET ON ONE UTC DAY — the unit the queue bills in. `markets` is one
246
+ // entry per market, and a day of BTC 15-minute markets is ninety-six of them,
247
+ // so counting entries reported a run as being a hundred times bigger than the
248
+ // customer is charged for and disagreed with the queue's own coverage.
249
+ const marketDaysScanned = countMarketDays(markets);
127
250
 
128
251
  const jobDir = await mkdtemp(path.join(tmpdir(), 'ot-run-'));
129
252
  try {
@@ -158,30 +281,53 @@ export async function cmdRun({ dir, flags }) {
158
281
  }
159
282
 
160
283
  if (!flags.json) {
161
- process.stdout.write(`\n ${markets.length} market-days · ${manifest.language} · local replay\n`);
284
+ process.stdout.write(`\n ${marketDaysScanned} market-days · ${manifest.language} · local replay\n`);
162
285
  }
163
286
 
164
287
  const passes = [];
165
- for (const step of LATENCY_STEPS) {
288
+ // ONE pass, at whatever delay the manifest asked for the same shape the
289
+ // queue runs (runner/worker.mjs). These two have drifted eight times and
290
+ // every one was "we shared the decoder and nothing else".
291
+ const delayMs = manifest.latency ?? 0;
292
+ const steps = [{ label: delayMs ? `+${delayMs} ms` : '0 ms', ms: delayMs }];
293
+ for (const step of steps) {
166
294
  const outputKey = randomBytes(32).toString('hex');
167
295
  const res = await runHarness({
168
296
  languageId, jobDir, outputKey, markets,
169
297
  job: { ...baseJob, fillDelayMs: step.ms },
298
+ seriesRows: seriesRows.rowsByName ?? {}, seriesLags, seriesNames,
170
299
  });
171
300
  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}`);
301
+ if (res.forged > 0) {
302
+ // Almost always a dependency writing to stdout: the harness takes
303
+ // console away from the strategy before loading it, but a library that
304
+ // reaches the descriptor another way still lands on the channel.
305
+ const err = new Error(
306
+ `${res.forged} line(s) on the result channel did not authenticate.`
307
+ + ' Something in this strategy or its dependencies writes to stdout;'
308
+ + ' a queued run would count the same bytes as forged and could lose'
309
+ + ' results. Use ctx.log() for output.',
310
+ );
311
+ err.code = 'E_RUNTIME';
312
+ err.detail = err.message;
313
+ throw err;
181
314
  }
315
+ // UNCONDITIONAL. This used to be wrapped in `if (step.ms === 0)`, from
316
+ // when pass 0 was the report and the rest were a comparison curve whose
317
+ // failures were survivable. There is one pass now, and its delay is
318
+ // whatever the manifest asked for — so the guard silently stopped
319
+ // running the moment anyone wrote `latency: 250`, and a rejected or
320
+ // over-budget replay became a report: locally successful, marked
321
+ // `fill_delay_ms: 250`, and refused by the queue.
322
+ if (res.code === EXIT.rejected || res.code === EXIT.budget) {
323
+ const r = out.result.rejection ?? { code: 'E_RUNTIME', detail: res.stderr.slice(0, 2000) };
324
+ const err = new Error(r.detail);
325
+ err.code = r.code;
326
+ err.detail = r.detail;
327
+ throw err;
328
+ }
329
+ if (res.code !== EXIT.ok) throw new Error(res.stderr.slice(0, 2000) || `harness exited ${res.code}`);
182
330
  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
331
  }
186
332
 
187
333
  const base = passes[0];
@@ -199,36 +345,49 @@ export async function cmdRun({ dir, flags }) {
199
345
  runId: `local_${days[0]}`,
200
346
  submittedAt: 0,
201
347
  manifest,
348
+ // Local runs have no stored submission to hash, and saying null is
349
+ // truthful: this report is not tied to a submission at all.
350
+ sourceSha256: null,
202
351
  scope: {
203
352
  venue,
204
353
  assets: assets.length ? assets : [...new Set(markets.map((m) => m.market.asset).filter(Boolean))],
205
354
  from: days[0],
206
355
  to: days[days.length - 1],
207
- marketDays: markets.length,
356
+ marketDays: marketDaysScanned,
208
357
  archivedDayCount: days.length,
209
358
  },
210
- scanned: { markets: base.result.marketsRun, market_days: markets.length, events: base.result.eventsSeen },
359
+ scanned: {
360
+ markets: base.result.marketsRun,
361
+ market_days: marketDaysScanned,
362
+ events: base.result.eventsSeen,
363
+ },
211
364
  trades: base.trades,
212
365
  fills: base.fills,
213
366
  marketSummaries: [...marketMeta.values()],
214
367
  marketMeta,
215
368
  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
- })),
369
+ fillDelayMs: delayMs,
219
370
  sweep: null,
220
371
  crosschecks: base.result.crosschecks,
221
372
  seed: Number(flags.seed ?? 1),
222
- coverage: {
373
+ coverage: buildCoverage({
374
+ marketDaysScanned,
375
+ // NOT backfilled from `scanned`. Filling it in that way meant the
376
+ // headline always said "asked for N, scanned N" — so a day that was
377
+ // entirely unusable was recorded in `missing` and simultaneously denied
378
+ // at the top of the same file. A local run does not know what was asked
379
+ // for; null says that, and saying it is the point.
380
+ marketDaysRequested: null,
381
+ marketsReportedByRunner: base.result.marketsRun,
382
+ missing,
383
+ // Always empty here: a manifest that declares one is refused above,
384
+ // because a local replay cannot supply it.
385
+ referenceDeclared: [],
386
+ streams: countStreams([...marketMeta.values()]),
387
+ droppedRows: base.malformed ?? 0,
223
388
  local: true,
224
389
  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
- },
390
+ }),
232
391
  budget: base.result.budget,
233
392
  });
234
393
 
@@ -30,9 +30,37 @@ export async function cmdSubmit({ dir, flags }) {
30
30
 
31
31
  // Locally first. The rejection codes are identical either way, so a customer
32
32
  // who fixes what `ot check` said will not be told something different here.
33
- await validate(files);
33
+ const checked = await validate(files);
34
34
 
35
- const body = { ...scope, files, ...(flags.email ? { email: flags.email } : {}) };
35
+ // Series go straight to R2, the same way the web editor sends them.
36
+ //
37
+ // Not an optimisation for the CLI's sake — it is what makes "a series never
38
+ // passes through the API box" true rather than true-for-browsers. Inlining a
39
+ // 4MB CSV cost 69MB resident there for one submission, and leaving one client
40
+ // doing it would have kept the whole cost while claiming it was gone.
41
+ const seriesNames = new Set((checked.manifest.series ?? []).map((x) => x.file));
42
+ const uploads = {};
43
+ for (const f of files.filter((x) => seriesNames.has(x.name))) {
44
+ const bytes = Buffer.byteLength(f.content, 'utf8');
45
+ const sign = await post(api, '/v1/backtest/upload', { bytes }, key);
46
+ if (sign.status !== 200 || !sign.json?.url) {
47
+ throw new Error(`could not stage ${f.name}: ${sign.json?.error ?? sign.text}`);
48
+ }
49
+ // Exactly the headers the server signed. `if-none-match: *` is among them
50
+ // and makes the url write-once; omitting any of them is a 403.
51
+ const put = await fetch(sign.json.url, {
52
+ method: 'PUT', headers: sign.json.headers, body: f.content,
53
+ });
54
+ if (!put.ok) throw new Error(`could not upload ${f.name}: HTTP ${put.status}`);
55
+ uploads[f.name] = sign.json.key;
56
+ }
57
+
58
+ const body = {
59
+ ...scope,
60
+ files: files.filter((x) => !seriesNames.has(x.name)),
61
+ ...(Object.keys(uploads).length ? { uploads } : {}),
62
+ ...(flags.email ? { email: flags.email } : {}),
63
+ };
36
64
  const { status, json, text } = await post(api, '/v1/backtest/submit', body, key);
37
65
 
38
66
  if (status === 202 && json?.run_id) {
@@ -14,12 +14,18 @@
14
14
  import { createReadStream } from 'node:fs';
15
15
  import { readdir, stat } from 'node:fs/promises';
16
16
  import { createInterface } from 'node:readline';
17
+ import { pipeline } from 'node:stream/promises';
17
18
  import { createGunzip } from 'node:zlib';
18
19
  import path from 'node:path';
19
20
 
20
21
  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';
22
+ import {
23
+ archiveDatasetsFor, fileMatchesRun, normalizeIntervals, settlementPathsFor, orderedFeed,
24
+ } from '../api/lib/backtest-datasets.mjs';
25
+ import {
26
+ indexMarkets, eventsFromRow, finaliseMarket, parseRow, buildSlugIndex, marketUnusable,
27
+ makeBookThrottle,
28
+ } from '../runner/events.mjs';
23
29
 
24
30
  /**
25
31
  * Every file under a directory, as archive-relative paths.
@@ -59,7 +65,23 @@ async function walk(root, prefix = '', depth = 0) {
59
65
  async function* readRows(root, rel) {
60
66
  const full = path.join(root, rel);
61
67
  const raw = createReadStream(full);
62
- const stream = rel.endsWith('.gz') ? raw.pipe(createGunzip()) : raw;
68
+ // `pipeline`, not `raw.pipe(...)`, for the same reason the queue's reader
69
+ // uses it: `.pipe()` does not forward errors, so a failure on `raw` — a
70
+ // truncated file, a disk that went away mid-read — emits 'error' on a stream
71
+ // nobody is listening to, which in Node is process death rather than an
72
+ // exception. That exact shape killed the worker in production; the risk is
73
+ // lower on a local file, but the wrong pattern is not worth keeping a second
74
+ // copy of.
75
+ let stream = raw;
76
+ if (rel.endsWith('.gz')) {
77
+ const gunzip = createGunzip();
78
+ stream = gunzip;
79
+ pipeline(raw, gunzip).catch((err) => {
80
+ if (!gunzip.destroyed) gunzip.destroy(err);
81
+ });
82
+ } else {
83
+ raw.on('error', () => {});
84
+ }
63
85
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
64
86
 
65
87
  const isCsv = rel.includes('.csv');
@@ -91,11 +113,15 @@ export function dayOfPath(rel) {
91
113
  * Returns the same shape fetchMarketDays does, so `ot run` and the worker feed
92
114
  * the harness identically.
93
115
  */
94
- export async function loadLocalDay({ root, day, venue, assets, datasets }) {
116
+ export async function loadLocalDay({ root, day, venue, assets, datasets, intervals, throttle = null }) {
95
117
  const archiveDatasets = archiveDatasetsFor({ datasets, venue, from: day, to: day });
118
+ // Same normalisation, same default, same two filters as the queue. `ot run`
119
+ // promises the identical files and checksums; an interval narrowing applied
120
+ // on one side only would break that on the very first 15m market.
121
+ const wantIntervals = normalizeIntervals(intervals ?? null);
96
122
  const all = await walk(root);
97
123
  const wanted = all.filter((rel) => dayOfPath(rel) === day
98
- && fileMatchesRun(rel, { venue, assets, archiveDatasets }));
124
+ && fileMatchesRun(rel, { venue, assets, archiveDatasets, intervals: wantIntervals }));
99
125
 
100
126
  if (wanted.length === 0) {
101
127
  return { markets: [], reason: `no files for ${day} under ${root}` };
@@ -107,16 +133,36 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
107
133
  for (const rel of wanted.filter((r) => classifyPath(r).dataset === 'markets')) {
108
134
  for await (const row of readRows(root, rel)) marketRows.push(row);
109
135
  }
110
- const markets = indexMarkets(marketRows);
136
+ // Same normalisation the queue uses. `ot run` promising "the identical files,
137
+ // same checksums" only holds while both sides decode the archive identically,
138
+ // so the venue has to reach the decoder here too.
139
+ const indexed = indexMarkets(marketRows, { venue });
140
+ const inScope = new Set((assets ?? []).map((a) => String(a).toUpperCase()));
141
+ const wantIv = new Set(wantIntervals.map(String));
142
+ const markets = new Map();
143
+ for (const [id, m] of indexed) {
144
+ if (m.asset && inScope.size && !inScope.has(String(m.asset).toUpperCase())) continue;
145
+ if (m.interval && wantIv.size && !wantIv.has(String(m.interval))) continue;
146
+ markets.set(id, m);
147
+ }
111
148
  if (markets.size === 0) {
112
149
  return { markets: [], reason: `no market metadata for ${day}` };
113
150
  }
151
+ const bySlug = buildSlugIndex(markets);
152
+
153
+ // The settlement files these markets need, from the SAME function the queue
154
+ // uses. Selecting them here separately is how "it runs locally but not in the
155
+ // queue" happens — and it had already happened: without this, a strategy
156
+ // asking for `prices` read nothing at all locally while the queue produced a
157
+ // report, off the identical archive.
158
+ const feed = orderedFeed([...wanted, ...settlementPathsFor(markets.values(),
159
+ all.filter((rel) => dayOfPath(rel) === day), { venue, assets, already: wanted })]);
114
160
 
115
161
  const byMarket = new Map();
116
- for (const rel of wanted) {
162
+ for (const rel of feed) {
117
163
  if (classifyPath(rel).dataset === 'markets') continue;
118
164
  for await (const row of readRows(root, rel)) {
119
- for (const [id, ev] of eventsFromRow(rel, row, markets)) {
165
+ for (const [id, ev] of eventsFromRow(rel, row, markets, bySlug, throttle)) {
120
166
  if (!markets.has(id)) continue;
121
167
  let list = byMarket.get(id);
122
168
  if (!list) { list = []; byMarket.set(id, list); }
@@ -126,13 +172,27 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
126
172
  }
127
173
 
128
174
  const out = [];
129
- for (const [marketId, events] of byMarket) {
175
+ const unusable = [];
176
+ // EVERY market in the metadata, exactly as the worker does. Iterating only
177
+ // the ones that produced events skipped the emptiest case — a market that
178
+ // exists in the archive and decodes to nothing — which is precisely the gap
179
+ // this is here to expose, and skipping it locally would put the divergence
180
+ // back after it had just been removed.
181
+ for (const marketId of markets.keys()) {
130
182
  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;
183
+ const events = byMarket.get(marketId) ?? [];
184
+ const { events: inWindow, up_px, down_px } = market
185
+ ? finaliseMarket(events, market)
186
+ : { events: [], up_px: null, down_px: null };
187
+ // THE SAME predicate the queue applies, not a local copy of it. A rule that
188
+ // lives in one reader and not the other is how `ot run` ends up replaying a
189
+ // market the queue drops — and a market-making strategy, which never reads
190
+ // the settlement price, is precisely the case that would never notice.
191
+ const why = marketUnusable(market, inWindow);
192
+ if (why) {
193
+ unusable.push({ market_id: marketId, asset: market?.asset ?? null, day, why });
194
+ continue;
195
+ }
136
196
  out.push({
137
197
  market: {
138
198
  market_id: market.market_id,
@@ -150,7 +210,13 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
150
210
  down_px,
151
211
  });
152
212
  }
153
- return { markets: out, reason: null };
213
+ return {
214
+ markets: out,
215
+ unusable,
216
+ reason: out.length === 0 && unusable.length
217
+ ? `${unusable.length} market(s) unusable: ${unusable[0].why}`
218
+ : null,
219
+ };
154
220
  }
155
221
 
156
222
  /** Days a local archive appears to hold, sorted. */
package/cli/ot.mjs CHANGED
@@ -35,6 +35,9 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
35
35
  ot run <dir> --data <archive> [--date <YYYY-MM-DD>] [--out <file>]
36
36
  Replay locally against a cloned sample archive, using the same engine
37
37
  the queue uses. Writes a report archive.
38
+ Refused if the manifest declares a reference feed: those come from an
39
+ archive held on the worker, so a local replay would hand your strategy
40
+ empty ones. Your own CSV series work locally.
38
41
 
39
42
  ot submit <dir> --assets btc,eth --from <day> --to <day> [--venue polymarket]
40
43
  Send it to the queue. Needs OT_BACKTEST_KEY.
@@ -96,8 +99,15 @@ export async function readSubmission(dir) {
96
99
  if (!/\.(py|mjs|js|json|csv)$/.test(e.name)) continue;
97
100
  const full = path.join(dir, name);
98
101
  const s = await stat(full);
99
- if (s.size > LIMITS.maxTotalSourceBytes) {
100
- throw new Error(`${name} is ${s.size} bytes, over the ${LIMITS.maxTotalSourceBytes} byte submission limit`);
102
+ // The ceiling for ANY one file is the series limit, not the source limit:
103
+ // a CSV series is allowed to be much larger than the code, and the shared
104
+ // validator is what enforces which budget a given file falls under.
105
+ // Refusing a 1MB CSV here meant `ot check` rejected a submission the API
106
+ // accepts — and "a local pass is not rejected on submit" is a promise the
107
+ // docs make.
108
+ const ceiling = Math.max(LIMITS.maxTotalSourceBytes, LIMITS.maxSeriesBytes);
109
+ if (s.size > ceiling) {
110
+ throw new Error(`${name} is ${s.size} bytes, over the ${ceiling} byte limit for a single file`);
101
111
  }
102
112
  out.push({ name, content: await readFile(full, 'utf8') });
103
113
  }
@@ -153,10 +163,19 @@ async function cmdCheck({ dir, flags }) {
153
163
  process.stdout.write(`\n ok — ${manifest.language}, entry ${manifest.entry.file}:${manifest.entry.className}\n`);
154
164
  process.stdout.write(` hooks ${Object.entries(res.hookNames).map(([k, v]) => `${k} → ${v}`).join(', ')}\n`);
155
165
  process.stdout.write(` datasets ${manifest.datasets.join(', ')}\n`);
166
+ // Both of these change what comes back, so `ot check` has to show them:
167
+ // this command exists to say what the queue will do with this submission,
168
+ // and a run narrowed to 5m at a 250ms fill delay is a different answer to
169
+ // the same strategy.
170
+ process.stdout.write(` intervals ${manifest.intervals.join(', ')}\n`);
171
+ process.stdout.write(` delay ${manifest.latency ? `${manifest.latency} ms` : 'none'}\n`);
156
172
  if (manifest.reference.length) process.stdout.write(` reference ${manifest.reference.join(', ')}\n`);
157
173
  process.stdout.write(` files ${res.files.length} / ${LIMITS.maxFiles} · ${(res.totalBytes / 1024).toFixed(1)} / ${LIMITS.maxTotalSourceBytes / 1024} KB\n`);
158
174
  if (manifest.mode === 'session') {
159
- process.stdout.write(' mode session bills at 3× the market-day rate\n');
175
+ // NOT "3x the market-day rate". That multiplier was deleted, and this was
176
+ // its third hiding place after both i18n dictionaries — the guard that
177
+ // caught the other two only scans lib/backtest-i18n.ts.
178
+ process.stdout.write(' mode session — same price as market mode\n');
160
179
  }
161
180
  process.stdout.write('\n');
162
181
  return 0;