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.
- package/README.md +8 -1
- package/api/lib/backtest-contract.mjs +265 -15
- package/api/lib/backtest-datasets.mjs +125 -1
- package/api/lib/backtest-manifest.mjs +52 -4
- package/api/lib/coverage-window.mjs +63 -1
- package/api/lib/data-taxonomy.mjs +14 -0
- package/cli/commands/run.mjs +196 -37
- package/cli/commands/submit.mjs +30 -2
- package/cli/local-data.mjs +81 -15
- package/cli/ot.mjs +22 -3
- package/index.d.ts +31 -4
- package/package.json +1 -1
- package/runner/archive.mjs +24 -13
- package/runner/engine/book.mjs +12 -1
- package/runner/engine/feed.mjs +116 -0
- package/runner/engine/portfolio.mjs +77 -5
- package/runner/engine/replay.mjs +136 -14
- package/runner/engine/report.mjs +23 -34
- package/runner/events.mjs +713 -55
- package/runner/harness/node/harness.mjs +138 -12
- package/runner/harness/node/sdk/index.d.ts +31 -4
- package/runner/harness/node/sdk/index.mjs +43 -2
- package/runner/harness/protocol.mjs +31 -3
- package/runner/harness/python/harness.py +123 -18
- package/runner/harness/python/otengine.py +116 -9
- package/runner/harness/python/otfeed.py +109 -0
- package/runner/harness/python/otreplay.py +90 -8
- package/runner/harness/python/outcometick.py +44 -4
- package/runner/series-data.mjs +220 -0
|
@@ -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
|
/**
|
package/cli/commands/run.mjs
CHANGED
|
@@ -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 {
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
61
|
-
|
|
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 {
|
|
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 ${
|
|
284
|
+
process.stdout.write(`\n ${marketDaysScanned} market-days · ${manifest.language} · local replay\n`);
|
|
162
285
|
}
|
|
163
286
|
|
|
164
287
|
const passes = [];
|
|
165
|
-
for
|
|
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 (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
356
|
+
marketDays: marketDaysScanned,
|
|
208
357
|
archivedDayCount: days.length,
|
|
209
358
|
},
|
|
210
|
-
scanned: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/cli/commands/submit.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/cli/local-data.mjs
CHANGED
|
@@ -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 {
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 {
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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;
|