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
|
@@ -10,16 +10,61 @@
|
|
|
10
10
|
// per-event budget is measured, and nothing but trades, fills and logs comes
|
|
11
11
|
// back out.
|
|
12
12
|
//
|
|
13
|
-
// node harness.mjs <job-dir> job on stdin, results on
|
|
13
|
+
// node harness.mjs <job-dir> job on stdin, results on stdout
|
|
14
14
|
|
|
15
15
|
import { readSync, writeSync } from 'node:fs';
|
|
16
16
|
import { createHmac } from 'node:crypto';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
|
-
import { replayMarket, BudgetMonitor, RunAbort } from '../../engine/replay.mjs';
|
|
19
|
+
import { replayMarket, makeLogBudget, BudgetMonitor, RunAbort } from '../../engine/replay.mjs';
|
|
20
|
+
import { Book as BookCls } from '../../engine/book.mjs';
|
|
21
|
+
import { buildFeeds } from '../../engine/feed.mjs';
|
|
20
22
|
import { Portfolio } from '../../engine/portfolio.mjs';
|
|
21
23
|
import { CHANNEL, RESULT_FD, EXIT } from '../protocol.mjs';
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* REPLACE `console`, before any strategy code is imported. Do not patch it.
|
|
27
|
+
*
|
|
28
|
+
* fd 1 is the result channel now (see protocol.mjs), and Node exposes no dup2,
|
|
29
|
+
* so it cannot be repointed the way the Python harness repoints it. Swapping
|
|
30
|
+
* out the METHODS is not enough: Node's console keeps the underlying stream on
|
|
31
|
+
* `console._stdout`, and `console.Console` will build a fresh one over any
|
|
32
|
+
* stream handed to it. `console._stdout.write(...)` passes the analyser today
|
|
33
|
+
* and puts arbitrary bytes on the result channel — not a forgery, the MAC still
|
|
34
|
+
* holds, but a half-line written between two real ones corrupts the record that
|
|
35
|
+
* follows it, and a dropped result reads as a run that produced nothing and
|
|
36
|
+
* gets refunded.
|
|
37
|
+
*
|
|
38
|
+
* So the strategy gets an object that holds no stream at all. Silently dropped
|
|
39
|
+
* rather than redirected: ctx.log is the documented, bounded, archived channel,
|
|
40
|
+
* and a strategy printing megabytes should not be able to turn that into worker
|
|
41
|
+
* output.
|
|
42
|
+
*/
|
|
43
|
+
let warnedAboutConsole = false;
|
|
44
|
+
function consoleIsGone() {
|
|
45
|
+
// Not silent: a strategy author whose console.log vanishes without a word
|
|
46
|
+
// will spend an afternoon on it. stderr is the harness's own channel, and
|
|
47
|
+
// `ot run` prints it.
|
|
48
|
+
if (warnedAboutConsole) return undefined;
|
|
49
|
+
warnedAboutConsole = true;
|
|
50
|
+
try {
|
|
51
|
+
writeSync(2, 'harness: console output is discarded — use ctx.log() instead\n');
|
|
52
|
+
} catch { /* stderr is not worth crashing a run over */ }
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
globalThis.console = Object.freeze(Object.fromEntries(
|
|
56
|
+
// Every method the platform documents, so a strategy calling a real one gets
|
|
57
|
+
// a no-op instead of a TypeError. Deliberately absent: _stdout, _stderr and
|
|
58
|
+
// Console — the three names that hand back a writable stream.
|
|
59
|
+
[
|
|
60
|
+
'assert', 'clear', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error',
|
|
61
|
+
'group', 'groupCollapsed', 'groupEnd', 'info', 'log', 'profile',
|
|
62
|
+
'profileEnd', 'table', 'time', 'timeEnd', 'timeLog', 'timeStamp', 'trace',
|
|
63
|
+
'warn',
|
|
64
|
+
].map((m) => [m, consoleIsGone]),
|
|
65
|
+
));
|
|
66
|
+
|
|
67
|
+
|
|
23
68
|
/**
|
|
24
69
|
* The parser, captured at module load — before any strategy is imported.
|
|
25
70
|
*
|
|
@@ -249,7 +294,7 @@ function checkHooks(Klass, hooks, arities) {
|
|
|
249
294
|
async function main() {
|
|
250
295
|
const jobDir = process.argv[2];
|
|
251
296
|
if (!jobDir) {
|
|
252
|
-
process.stderr.write('usage: harness.mjs <job-dir> (job on stdin, results on
|
|
297
|
+
process.stderr.write('usage: harness.mjs <job-dir> (job on stdin, results on stdout)\n');
|
|
253
298
|
return 2;
|
|
254
299
|
}
|
|
255
300
|
|
|
@@ -265,7 +310,7 @@ async function main() {
|
|
|
265
310
|
const job = parseJson(first);
|
|
266
311
|
const srcDir = path.join(jobDir, 'src');
|
|
267
312
|
|
|
268
|
-
// Every result line goes out over
|
|
313
|
+
// Every result line goes out over stdout, authenticated with the per-run key
|
|
269
314
|
// that arrived in the job — before any strategy was imported. See the long
|
|
270
315
|
// note in protocol.mjs: /out used to be a writable mount, and an allowlisted
|
|
271
316
|
// pandas could rewrite trades.jsonl from on_settle.
|
|
@@ -301,10 +346,18 @@ async function main() {
|
|
|
301
346
|
return code;
|
|
302
347
|
};
|
|
303
348
|
|
|
304
|
-
// One monitor across the whole run:
|
|
305
|
-
//
|
|
306
|
-
//
|
|
349
|
+
// One monitor across the whole run: resetting it per market would let a
|
|
350
|
+
// strategy be pathological on every market and never trip. That is also
|
|
351
|
+
// exactly why the verdict is a WINDOW rather than a lifetime mean — spanning
|
|
352
|
+
// the run means a cheap prefix would otherwise pay for an expensive phase.
|
|
307
353
|
const monitor = new BudgetMonitor({ limitMicros: job.limits?.perEventBudgetMicros ?? 400 });
|
|
354
|
+
// ONE log allowance for the whole run. Every market gets the same object, so
|
|
355
|
+
// a strategy cannot multiply its budget by the number of markets in a day —
|
|
356
|
+
// which on polymarket is about 386.
|
|
357
|
+
const logBudget = makeLogBudget({
|
|
358
|
+
bytes: job.limits?.logBytesPerRun,
|
|
359
|
+
lineChars: job.limits?.logLineChars,
|
|
360
|
+
});
|
|
308
361
|
|
|
309
362
|
let Klass;
|
|
310
363
|
try {
|
|
@@ -337,8 +390,40 @@ async function main() {
|
|
|
337
390
|
// them. Nothing here holds more than the current row, which is the whole
|
|
338
391
|
// point — see syncLineReader above.
|
|
339
392
|
let seenEvents = 0;
|
|
340
|
-
|
|
393
|
+
// The book as the ENGINE sees it, advanced by the same class.
|
|
394
|
+
//
|
|
395
|
+
// Three things were wrong with reading the last book event instead. A
|
|
396
|
+
// Polymarket snapshot carries one side, so the other came back null. Its
|
|
397
|
+
// ask ladder is published descending while Predict's is ascending, so
|
|
398
|
+
// element zero was the worst offer on one venue. And a `price_change`
|
|
399
|
+
// after the last snapshot moved the price for the engine but not for this
|
|
400
|
+
// number. Advancing a real Book removes all three, and removes a second
|
|
401
|
+
// implementation with them.
|
|
402
|
+
const summaryBook = new BookCls();
|
|
403
|
+
// The first book state that quotes BOTH sides — the same rule the worker
|
|
404
|
+
// applies, so the two do not report different numbers for one run. Locking
|
|
405
|
+
// each side as it appears would mix prices from two instants, and the
|
|
406
|
+
// favourite is the dearer of the pair.
|
|
407
|
+
let openQuotes = null;
|
|
341
408
|
const remaining = { n: entry.n ?? 0 };
|
|
409
|
+
|
|
410
|
+
// Reference feeds and user series arrive INTERLEAVED in the same stream,
|
|
411
|
+
// in event time, and are appended to a growing array as they pass.
|
|
412
|
+
//
|
|
413
|
+
// Not shipped as a block on the market header, which would be simpler:
|
|
414
|
+
// a Binance price is very nearly the underlying that decides the outcome,
|
|
415
|
+
// so holding the whole window in the process would reopen exactly the hole
|
|
416
|
+
// the event stream was hardened against — a strategy that patched
|
|
417
|
+
// JSON.parse at import time could read the future off it. Streaming keeps
|
|
418
|
+
// the guarantee structural: a row the replay has not reached is not in the
|
|
419
|
+
// process at all.
|
|
420
|
+
const feedRows = new Map();
|
|
421
|
+
const rowsFor = (name) => {
|
|
422
|
+
let a = feedRows.get(name);
|
|
423
|
+
if (!a) { a = []; feedRows.set(name, a); }
|
|
424
|
+
return a;
|
|
425
|
+
};
|
|
426
|
+
|
|
342
427
|
function* eventStream() {
|
|
343
428
|
while (remaining.n > 0) {
|
|
344
429
|
remaining.n -= 1;
|
|
@@ -352,12 +437,37 @@ async function main() {
|
|
|
352
437
|
// worker reconciles what it sent against what came back.
|
|
353
438
|
continue;
|
|
354
439
|
}
|
|
440
|
+
// Consumed here, never handed to a hook: these are not market events.
|
|
441
|
+
if (ev.kind === 'ref' || ev.kind === 'ext') {
|
|
442
|
+
const row = projectRow(ev, Object.keys(ev).filter((k) => k !== 'kind' && k !== 'name'));
|
|
443
|
+
rowsFor(ev.name).push(row);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
355
446
|
seenEvents += 1;
|
|
356
|
-
if (ev.kind === 'book'
|
|
447
|
+
if (ev.kind === 'book') {
|
|
448
|
+
if (ev.snapshot) summaryBook.snapshot(ev.ts_ms, ev.levels);
|
|
449
|
+
else if (ev.side && ev.ladder) {
|
|
450
|
+
summaryBook.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
|
|
451
|
+
}
|
|
452
|
+
if (openQuotes === null) {
|
|
453
|
+
const up = summaryBook.best('UP');
|
|
454
|
+
const down = summaryBook.best('DOWN');
|
|
455
|
+
if (up != null && down != null) openQuotes = { up, down };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
357
458
|
yield ev;
|
|
358
459
|
}
|
|
359
460
|
}
|
|
360
461
|
|
|
462
|
+
// The arrays are shared with the stream above, so the feeds see each row
|
|
463
|
+
// the moment the replay passes its timestamp — and not before.
|
|
464
|
+
const references = buildFeeds(entry.references ?? [], Object.fromEntries(
|
|
465
|
+
(entry.references ?? []).map((n) => [n, rowsFor(n)]),
|
|
466
|
+
), entry.lags ?? {});
|
|
467
|
+
const series = buildFeeds(entry.series ?? [], Object.fromEntries(
|
|
468
|
+
(entry.series ?? []).map((n) => [n, rowsFor(n)]),
|
|
469
|
+
), entry.lags ?? {});
|
|
470
|
+
|
|
361
471
|
/** Drain whatever the replay did not consume, so the stream stays framed. */
|
|
362
472
|
const drainRest = () => {
|
|
363
473
|
while (remaining.n > 0) {
|
|
@@ -389,17 +499,33 @@ async function main() {
|
|
|
389
499
|
strategy: instance,
|
|
390
500
|
hooks: job.hooks,
|
|
391
501
|
portfolio: pf,
|
|
502
|
+
// ONE allowance for the whole run, handed to every market. Per-market
|
|
503
|
+
// was the old shape and the reason ctx.log was an export channel.
|
|
504
|
+
logBudget,
|
|
392
505
|
fillDelayMs: job.fillDelayMs ?? 0,
|
|
393
506
|
logLimit: job.limits?.logLinesPerMarketDay ?? 10_000,
|
|
394
507
|
budget: monitor,
|
|
395
508
|
seed: job.seed ?? 1,
|
|
396
509
|
feeBps: job.feeBps ?? 0,
|
|
510
|
+
references,
|
|
511
|
+
series,
|
|
397
512
|
});
|
|
398
513
|
|
|
399
514
|
drainRest();
|
|
400
515
|
result.markets_run += 1;
|
|
401
516
|
result.events_seen += seenEvents;
|
|
402
|
-
|
|
517
|
+
// Acknowledge the market-day AFTER it is replayed, so the panel a
|
|
518
|
+
// customer is watching counts finished work rather than queued bytes.
|
|
519
|
+
emit(CHANNEL.progress, stringify({ n: result.markets_run }));
|
|
520
|
+
if (out.logTruncated && !result.log_truncated) {
|
|
521
|
+
result.log_truncated = true;
|
|
522
|
+
// SAID IN THE LOG ITSELF, once, where someone reading it will see it.
|
|
523
|
+
// A log that just stops looks like a strategy that stopped calling
|
|
524
|
+
// ctx.log — and the reader goes hunting for a bug in their own code.
|
|
525
|
+
logsOut.write('[runner] log budget spent — the rest of this run\'s'
|
|
526
|
+
+ ' ctx.log output was dropped. ctx.log is for reading, not for'
|
|
527
|
+
+ ' exporting; see the SDK docs for the limit.\n');
|
|
528
|
+
}
|
|
403
529
|
for (const line of out.logs) logsOut.write(`${entry.market.market_id} ${line}\n`);
|
|
404
530
|
for (const c of out.crosschecks) result.crosschecks.push(c);
|
|
405
531
|
|
|
@@ -411,8 +537,8 @@ async function main() {
|
|
|
411
537
|
asset: entry.market.asset ?? null,
|
|
412
538
|
interval: entry.market.interval ?? null,
|
|
413
539
|
outcome: entry.market.outcome ?? null,
|
|
414
|
-
up_px:
|
|
415
|
-
down_px:
|
|
540
|
+
up_px: openQuotes?.up ?? null,
|
|
541
|
+
down_px: openQuotes?.down ?? null,
|
|
416
542
|
stream: entry.stream ?? null,
|
|
417
543
|
});
|
|
418
544
|
} catch (err) {
|
|
@@ -132,10 +132,37 @@ export interface Ctx<P = Record<string, unknown>> {
|
|
|
132
132
|
assert_outcome(market: unknown, outcome: Side): void;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Size an order in contracts, or in money.
|
|
137
|
+
*
|
|
138
|
+
* A union rather than two optional fields, so `{ size, notional }` together is
|
|
139
|
+
* a compile error rather than a run-time rejection: they answer the same
|
|
140
|
+
* question two ways and there is no sensible reading of both.
|
|
141
|
+
*/
|
|
142
|
+
export type OrderSizing =
|
|
143
|
+
| {
|
|
144
|
+
/** Contracts. Must be positive. */
|
|
145
|
+
size: number;
|
|
146
|
+
notional?: never;
|
|
147
|
+
}
|
|
148
|
+
| {
|
|
149
|
+
size?: never;
|
|
150
|
+
/**
|
|
151
|
+
* Spend at most this much, converted to contracts as
|
|
152
|
+
* `floor(notional / limit)`.
|
|
153
|
+
*
|
|
154
|
+
* REQUIRES `limit`, which is why this arm makes it non-optional: a
|
|
155
|
+
* contract costs whatever it fills at and a marketable order walks the
|
|
156
|
+
* book, so dividing by the current best price overspends the moment there
|
|
157
|
+
* is any slippage. The limit is the price you have already said you will
|
|
158
|
+
* not exceed, which is what makes "at most" true.
|
|
159
|
+
*/
|
|
160
|
+
notional: number;
|
|
161
|
+
limit: number;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type OrderInit = OrderSizing & {
|
|
136
165
|
side: Side;
|
|
137
|
-
/** Contracts. Must be positive. */
|
|
138
|
-
size: number;
|
|
139
166
|
/**
|
|
140
167
|
* A bound in whichever direction protects you: a ceiling when opening, a
|
|
141
168
|
* floor when reducing. Must be within [0, 1] — a binary outcome token
|
|
@@ -149,7 +176,7 @@ export interface OrderInit {
|
|
|
149
176
|
/** Only 'ioc' is modelled; anything else is rejected at construction. */
|
|
150
177
|
tif?: 'ioc';
|
|
151
178
|
tag?: string | null;
|
|
152
|
-
}
|
|
179
|
+
};
|
|
153
180
|
|
|
154
181
|
/**
|
|
155
182
|
* An order a hook returns.
|
|
@@ -37,15 +37,56 @@ export class Strategy {
|
|
|
37
37
|
* opening, a floor when reducing.
|
|
38
38
|
*/
|
|
39
39
|
export class Order {
|
|
40
|
-
constructor({ side, size, limit = null, holdS = null, hold_s = null,
|
|
40
|
+
constructor({ side, size, notional = null, limit = null, holdS = null, hold_s = null,
|
|
41
41
|
reduceOnly = false, reduce_only = false, tif = 'ioc', tag = null } = {}) {
|
|
42
42
|
if (!SIDES.includes(side)) {
|
|
43
43
|
throw new Error(`side must be "UP" or "DOWN", got ${JSON.stringify(side)}`);
|
|
44
44
|
}
|
|
45
|
+
// SIZE IN MONEY, converted here rather than in the engine.
|
|
46
|
+
//
|
|
47
|
+
// Position sizing is nearly always a budget, not a contract count, and the
|
|
48
|
+
// conversion has exactly one honest divisor: your own limit. A contract
|
|
49
|
+
// costs whatever it fills at, and a marketable order walks the book — so
|
|
50
|
+
// dividing by the current best price overspends the moment there is any
|
|
51
|
+
// slippage, by an amount nobody stated. Dividing by the limit is the price
|
|
52
|
+
// you have already said you will not exceed, which makes "spend at most
|
|
53
|
+
// this much" true rather than approximately true.
|
|
54
|
+
//
|
|
55
|
+
// Hence: `notional` REQUIRES `limit`. Without one there is no upper bound
|
|
56
|
+
// on the fill price, so "spend $80" has no answer, and picking one for the
|
|
57
|
+
// caller would be inventing a number they never wrote.
|
|
58
|
+
if (notional != null) {
|
|
59
|
+
if (size != null) {
|
|
60
|
+
throw new Error('give size or notional, not both — they answer the same question two ways');
|
|
61
|
+
}
|
|
62
|
+
if (!(typeof notional === 'number' && Number.isFinite(notional) && notional > 0)) {
|
|
63
|
+
throw new Error(`notional must be a positive number, got ${JSON.stringify(notional)}`);
|
|
64
|
+
}
|
|
65
|
+
if (limit == null) {
|
|
66
|
+
throw new Error('notional needs a limit: without a price ceiling there is no way to turn a budget into a size');
|
|
67
|
+
}
|
|
68
|
+
const px = Number(limit);
|
|
69
|
+
if (!(Number.isFinite(px) && px > 0)) {
|
|
70
|
+
throw new Error(`notional needs a limit above 0, got ${JSON.stringify(limit)}`);
|
|
71
|
+
}
|
|
72
|
+
// FLOOR, so the spend is at most the budget rather than around it.
|
|
73
|
+
const derived = Math.floor(notional / px);
|
|
74
|
+
if (derived < 1) {
|
|
75
|
+
throw new Error(`notional ${notional} buys no contracts at limit ${px}`);
|
|
76
|
+
}
|
|
77
|
+
// eslint-disable-next-line no-param-reassign
|
|
78
|
+
size = derived;
|
|
79
|
+
}
|
|
45
80
|
if (!(typeof size === 'number' && Number.isFinite(size) && size > 0)) {
|
|
46
81
|
throw new Error(`size must be a positive number, got ${JSON.stringify(size)}`);
|
|
47
82
|
}
|
|
48
|
-
|
|
83
|
+
// A NUMBER, matching the engine. `Number('0.5')` is 0.5, `Number([0.5])`
|
|
84
|
+
// is 0.5 and `Number(true)` is 1 — while Python's `float([0.5])` raises,
|
|
85
|
+
// so the same published SDK accepted a list in one language and threw in
|
|
86
|
+
// the other. Coercion is not validation, and the two languages do not
|
|
87
|
+
// coerce alike.
|
|
88
|
+
if (limit != null
|
|
89
|
+
&& !(typeof limit === 'number' && Number.isFinite(limit) && limit >= 0 && limit <= 1)) {
|
|
49
90
|
// A binary outcome token trades between 0 and 1. A limit outside that is
|
|
50
91
|
// not a price, and silently clamping it would fill an order the strategy
|
|
51
92
|
// never asked for.
|
|
@@ -41,7 +41,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
|
41
41
|
export const JOB_FILE = 'job.json';
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Results come back over
|
|
44
|
+
* Results come back over stdout, authenticated. There is no output directory.
|
|
45
45
|
*
|
|
46
46
|
* `/out` used to be a writable bind mount holding trades.jsonl and fills.jsonl.
|
|
47
47
|
* A Python strategy declaring the allowlisted `pandas` could call
|
|
@@ -72,10 +72,38 @@ export const CHANNEL = Object.freeze({
|
|
|
72
72
|
fill: 'f',
|
|
73
73
|
log: 'l',
|
|
74
74
|
result: 'r',
|
|
75
|
+
/**
|
|
76
|
+
* "I have finished replaying market-day N." Display only.
|
|
77
|
+
*
|
|
78
|
+
* The worker used to count how many market-days it had WRITTEN into stdin,
|
|
79
|
+
* which is not the same thing: a write only proves the bytes were accepted
|
|
80
|
+
* into a buffer. Small days fit several at a time, so the bar ran ahead of
|
|
81
|
+
* the replay and could show 100% while the sandbox still had work to do —
|
|
82
|
+
* the "looks stuck" experience this panel exists to prevent.
|
|
83
|
+
*
|
|
84
|
+
* It comes from the sandbox, so it is attacker-influenced like every other
|
|
85
|
+
* line, and it is authenticated like every other line. That is fine for a
|
|
86
|
+
* progress bar and NOT fine for money: billing still uses only what the
|
|
87
|
+
* worker itself fetched and fed.
|
|
88
|
+
*/
|
|
89
|
+
progress: 'p',
|
|
75
90
|
});
|
|
76
91
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The authenticated result channel: the container's stdout.
|
|
94
|
+
*
|
|
95
|
+
* It used to be fd 3. Docker hands a container stdin/stdout/stderr and nothing
|
|
96
|
+
* else — the worker spawns `docker run` with a fourth pipe, but that fd belongs
|
|
97
|
+
* to the docker CLIENT, so inside the container fd 3 was closed and every write
|
|
98
|
+
* failed with EBADF. No containerised run had ever produced a result.
|
|
99
|
+
*
|
|
100
|
+
* Each harness makes fd 1 unreachable for the strategy before loading its code
|
|
101
|
+
* (Python dups it away and points 1 at /dev/null; JavaScript takes over
|
|
102
|
+
* `console`, which is the only route left once the analyser has refused
|
|
103
|
+
* `process`). The MAC is what makes forgery impossible, and always was —
|
|
104
|
+
* /proc/self/fd/1 was addressable either way.
|
|
105
|
+
*/
|
|
106
|
+
export const RESULT_FD = 1;
|
|
79
107
|
|
|
80
108
|
/** Exit codes a harness may use. Anything else is treated as a crash. */
|
|
81
109
|
export const EXIT = Object.freeze({
|
|
@@ -7,11 +7,12 @@ same exit codes. The worker does not know or care which language produced a
|
|
|
7
7
|
run's logs, which is what stops the report shape depending on the customer's
|
|
8
8
|
choice of language.
|
|
9
9
|
|
|
10
|
-
python3 harness.py <job-dir> job on stdin, results on
|
|
10
|
+
python3 harness.py <job-dir> job on stdin, results on stdout
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import builtins
|
|
15
16
|
import hashlib
|
|
16
17
|
import hmac
|
|
17
18
|
import importlib.util
|
|
@@ -21,18 +22,61 @@ import sys
|
|
|
21
22
|
|
|
22
23
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
24
|
|
|
24
|
-
from otengine import BudgetMonitor, Portfolio, RunAbort # noqa: E402
|
|
25
|
-
from otreplay import
|
|
25
|
+
from otengine import Book as _Book, BudgetMonitor, Portfolio, RunAbort # noqa: E402
|
|
26
|
+
from otreplay import ( # noqa: E402
|
|
27
|
+
write_all,
|
|
28
|
+
replay_market, make_log_budget, LOG_BYTES_PER_RUN, LOG_LINE_CHARS,
|
|
29
|
+
)
|
|
30
|
+
from otfeed import build_feeds # noqa: E402
|
|
26
31
|
|
|
27
|
-
# Results go out over
|
|
32
|
+
# Results go out over stdout, authenticated — see the long note in protocol.mjs.
|
|
28
33
|
# /out used to be a writable bind mount, and a strategy declaring the allowed
|
|
29
34
|
# `pandas` could rewrite trades.jsonl from on_settle, after being told the
|
|
30
35
|
# official outcome.
|
|
31
|
-
|
|
36
|
+
# The authenticated result channel.
|
|
37
|
+
#
|
|
38
|
+
# Docker hands a container stdin/stdout/stderr and nothing else. The worker
|
|
39
|
+
# spawns `docker run` with a fourth pipe, but that fd belongs to the docker
|
|
40
|
+
# CLIENT — inside the container fd 3 is closed, and every write to it failed
|
|
41
|
+
# with EBADF. That is why no containerised run had ever produced a result.
|
|
42
|
+
#
|
|
43
|
+
# So the real stdout is duplicated to a private fd and fd 1 is pointed at
|
|
44
|
+
# /dev/null, HERE, before any strategy code is imported: results leave over the
|
|
45
|
+
# container's stdout, and a strategy's print() goes nowhere. Which was already
|
|
46
|
+
# the intent — the worker used to discard stdout for exactly that reason.
|
|
47
|
+
#
|
|
48
|
+
# /proc/self/fd/<n> stays addressable. The MAC is what makes forgery
|
|
49
|
+
# impossible, and always was.
|
|
50
|
+
RESULT_FD = os.dup(1)
|
|
51
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
52
|
+
os.dup2(_devnull, 1)
|
|
53
|
+
os.close(_devnull)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_is_gone(*_args, **_kwargs):
|
|
57
|
+
"""print() writes to /dev/null now; say so once, on stderr.
|
|
58
|
+
|
|
59
|
+
Not silent: a strategy author whose print() vanishes without a word will
|
|
60
|
+
spend an afternoon on it. `ot run` shows stderr.
|
|
61
|
+
"""
|
|
62
|
+
if not _print_is_gone.warned:
|
|
63
|
+
_print_is_gone.warned = True
|
|
64
|
+
try:
|
|
65
|
+
sys.stderr.write("harness: print() output is discarded -- use ctx.log() instead\n")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass # stderr is not worth crashing a run over
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_print_is_gone.warned = False
|
|
71
|
+
builtins.print = _print_is_gone
|
|
32
72
|
CHANNEL_TRADE = "t"
|
|
33
73
|
CHANNEL_FILL = "f"
|
|
34
74
|
CHANNEL_LOG = "l"
|
|
35
75
|
CHANNEL_RESULT = "r"
|
|
76
|
+
# "I have finished replaying market-day N." Display only, and the mirror of
|
|
77
|
+
# CHANNEL.progress in protocol.mjs -- see the long note there. Both engines or
|
|
78
|
+
# neither: runner/conformance compares them line by line.
|
|
79
|
+
CHANNEL_PROGRESS = "p"
|
|
36
80
|
|
|
37
81
|
EXIT_OK = 0
|
|
38
82
|
EXIT_REJECTED = 10
|
|
@@ -184,7 +228,7 @@ def read_line(stream):
|
|
|
184
228
|
|
|
185
229
|
def main() -> int:
|
|
186
230
|
if len(sys.argv) < 2:
|
|
187
|
-
sys.stderr.write("usage: harness.py <job-dir> (job on stdin, results on
|
|
231
|
+
sys.stderr.write("usage: harness.py <job-dir> (job on stdin, results on stdout)\n")
|
|
188
232
|
return 2
|
|
189
233
|
job_dir = sys.argv[1]
|
|
190
234
|
|
|
@@ -198,6 +242,10 @@ def main() -> int:
|
|
|
198
242
|
|
|
199
243
|
limits = job.get("limits") or {}
|
|
200
244
|
monitor = BudgetMonitor(limit_micros=limits.get("perEventBudgetMicros", 400))
|
|
245
|
+
log_budget = make_log_budget(
|
|
246
|
+
limits.get("logBytesPerRun", LOG_BYTES_PER_RUN),
|
|
247
|
+
limits.get("logLineChars", LOG_LINE_CHARS),
|
|
248
|
+
)
|
|
201
249
|
|
|
202
250
|
result = {
|
|
203
251
|
"markets_run": 0,
|
|
@@ -220,7 +268,7 @@ def main() -> int:
|
|
|
220
268
|
mac = hmac.new(
|
|
221
269
|
key_bytes, f"{channel} {payload}".encode("utf-8"), hashlib.sha256
|
|
222
270
|
).hexdigest()[:32]
|
|
223
|
-
|
|
271
|
+
write_all(RESULT_FD, f"{mac} {channel} {payload}\n".encode("utf-8"))
|
|
224
272
|
|
|
225
273
|
class _Logs:
|
|
226
274
|
@staticmethod
|
|
@@ -268,7 +316,29 @@ def main() -> int:
|
|
|
268
316
|
# Pulled one at a time as replay asks. Nothing here holds more than the
|
|
269
317
|
# current row — that is what makes "future rows are not in the process"
|
|
270
318
|
# literally true rather than approximately true.
|
|
271
|
-
|
|
319
|
+
# The book as the ENGINE sees it, advanced by the same class. A
|
|
320
|
+
# Polymarket snapshot carries one side, its ladder is published
|
|
321
|
+
# descending while Predict's is ascending, and a price_change after the
|
|
322
|
+
# last snapshot moves the price for the engine — reading the last event
|
|
323
|
+
# got all three wrong, and was a second implementation besides.
|
|
324
|
+
counters = {"n": int(entry.get("n") or 0), "seen": 0,
|
|
325
|
+
"book": _Book(entry["market"]["market_id"]),
|
|
326
|
+
# The first book state that quotes BOTH sides — the same
|
|
327
|
+
# rule the worker applies. Locking each side as it appears
|
|
328
|
+
# would mix prices from two instants, and the favourite is
|
|
329
|
+
# the dearer of the pair.
|
|
330
|
+
"open_quotes": None}
|
|
331
|
+
|
|
332
|
+
# Reference feeds and user series arrive INTERLEAVED in this same
|
|
333
|
+
# stream, in event time, and are appended to growing arrays as they
|
|
334
|
+
# pass. Not shipped as a block on the market header: a Binance price is
|
|
335
|
+
# very nearly the underlying that decides the outcome, so holding the
|
|
336
|
+
# whole window in the process would reopen exactly the hole the event
|
|
337
|
+
# stream was hardened against. Streaming keeps it structural.
|
|
338
|
+
feed_rows: dict = {}
|
|
339
|
+
|
|
340
|
+
def rows_for(name):
|
|
341
|
+
return feed_rows.setdefault(name, [])
|
|
272
342
|
|
|
273
343
|
def event_stream():
|
|
274
344
|
while counters["n"] > 0:
|
|
@@ -281,9 +351,24 @@ def main() -> int:
|
|
|
281
351
|
except json.JSONDecodeError:
|
|
282
352
|
# Corruption in OUR data, not the strategy's problem.
|
|
283
353
|
continue
|
|
354
|
+
kind = ev.get("kind")
|
|
355
|
+
if kind in ("ref", "ext"):
|
|
356
|
+
# Consumed here, never handed to a hook: not market events.
|
|
357
|
+
row = {k: v for k, v in ev.items() if k not in ("kind", "name")}
|
|
358
|
+
rows_for(ev.get("name")).append(row)
|
|
359
|
+
continue
|
|
284
360
|
counters["seen"] += 1
|
|
285
|
-
if
|
|
286
|
-
counters["
|
|
361
|
+
if kind == "book" and ev.get("snapshot"):
|
|
362
|
+
counters["book"].snapshot(ev.get("ts_ms") or 0, ev.get("levels") or {})
|
|
363
|
+
elif kind == "book" and ev.get("side") and ev.get("ladder"):
|
|
364
|
+
counters["book"].delta(
|
|
365
|
+
ev.get("ts_ms") or 0, ev["side"], ev["ladder"],
|
|
366
|
+
ev.get("px"), ev.get("size"))
|
|
367
|
+
if kind == "book" and counters["open_quotes"] is None:
|
|
368
|
+
_up = counters["book"].best("UP")
|
|
369
|
+
_down = counters["book"].best("DOWN")
|
|
370
|
+
if _up is not None and _down is not None:
|
|
371
|
+
counters["open_quotes"] = (_up, _down)
|
|
287
372
|
yield ev
|
|
288
373
|
|
|
289
374
|
def drain_rest():
|
|
@@ -313,10 +398,24 @@ def main() -> int:
|
|
|
313
398
|
hooks=job.get("hooks") or {},
|
|
314
399
|
portfolio=pf,
|
|
315
400
|
fill_delay_ms=job.get("fillDelayMs", 0),
|
|
316
|
-
|
|
401
|
+
# ONE allowance for the whole run, handed to every market.
|
|
402
|
+
# Per-market was the old shape and the reason ctx.log was an
|
|
403
|
+
# export channel: polymarket has ~386 markets a day, so a
|
|
404
|
+
# per-market budget is a per-run budget multiplied by 386.
|
|
405
|
+
log_budget=log_budget,
|
|
317
406
|
budget=monitor,
|
|
318
407
|
seed=job.get("seed", 1),
|
|
319
408
|
fee_bps=job.get("feeBps", 0),
|
|
409
|
+
references=build_feeds(
|
|
410
|
+
entry.get("references") or [],
|
|
411
|
+
{n: rows_for(n) for n in (entry.get("references") or [])},
|
|
412
|
+
entry.get("lags") or {},
|
|
413
|
+
),
|
|
414
|
+
series=build_feeds(
|
|
415
|
+
entry.get("series") or [],
|
|
416
|
+
{n: rows_for(n) for n in (entry.get("series") or [])},
|
|
417
|
+
entry.get("lags") or {},
|
|
418
|
+
),
|
|
320
419
|
)
|
|
321
420
|
except RunAbort as err:
|
|
322
421
|
drain_rest()
|
|
@@ -336,19 +435,25 @@ def main() -> int:
|
|
|
336
435
|
drain_rest()
|
|
337
436
|
result["markets_run"] += 1
|
|
338
437
|
result["events_seen"] += counters["seen"]
|
|
339
|
-
|
|
438
|
+
# Acknowledged AFTER the replay, so the panel a customer is watching
|
|
439
|
+
# counts finished work rather than queued bytes.
|
|
440
|
+
emit(CHANNEL_PROGRESS, _DUMPS({"n": result["markets_run"]}))
|
|
441
|
+
if out["log_truncated"] and not result["log_truncated"]:
|
|
340
442
|
result["log_truncated"] = True
|
|
443
|
+
# Said in the log itself, once. A log that just stops reads as a
|
|
444
|
+
# strategy that stopped calling ctx.log, and the reader goes
|
|
445
|
+
# hunting for a bug in their own code.
|
|
446
|
+
emit(CHANNEL_LOG, "[runner] log budget spent -- the rest of this"
|
|
447
|
+
" run's ctx.log output was dropped. ctx.log is for reading,"
|
|
448
|
+
" not for exporting; see the SDK docs for the limit.")
|
|
341
449
|
for line in out["logs"]:
|
|
342
450
|
logs_fh.write(f'{entry["market"]["market_id"]} {line}\n')
|
|
343
451
|
result["crosschecks"].extend(out["crosschecks"])
|
|
344
452
|
|
|
345
453
|
# Tracked as the stream went past; there is no array left to scan.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
down_asks = (levels.get("DOWN") or {}).get("asks") or []
|
|
350
|
-
up_px = up_asks[0][0] if up_asks else None
|
|
351
|
-
down_px = down_asks[0][0] if down_asks else None
|
|
454
|
+
_oq = counters["open_quotes"]
|
|
455
|
+
up_px = _oq[0] if _oq else None
|
|
456
|
+
down_px = _oq[1] if _oq else None
|
|
352
457
|
result["market_summaries"].append({
|
|
353
458
|
"market_id": entry["market"]["market_id"],
|
|
354
459
|
"asset": entry["market"].get("asset"),
|