outcometick 1.5.2 → 1.6.2
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 +335 -16
- 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 +15 -1
- package/cli/commands/run.mjs +206 -37
- package/cli/commands/submit.mjs +40 -3
- package/cli/local-data.mjs +86 -15
- package/cli/ot.mjs +26 -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 +96 -34
- package/runner/events.mjs +788 -55
- package/runner/harness/node/harness.mjs +166 -13
- package/runner/harness/node/sdk/index.d.ts +31 -4
- package/runner/harness/node/sdk/index.mjs +46 -2
- package/runner/harness/protocol.mjs +31 -3
- package/runner/harness/python/harness.py +137 -19
- 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 +48 -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,18 +499,61 @@ 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
|
-
|
|
403
|
-
|
|
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
|
+
}
|
|
529
|
+
// OPENING TIME FIRST, then a short id.
|
|
530
|
+
//
|
|
531
|
+
// The prefix used to be the full 64-character condition hash, which
|
|
532
|
+
// identifies a market to the venue and to nobody reading a log: there is
|
|
533
|
+
// no way to tell from it which market this was, or when. The opening
|
|
534
|
+
// time is the thing a person actually navigates by — it is what the
|
|
535
|
+
// venue puts in the slug — and eight characters of the hash still
|
|
536
|
+
// separate the several strikes that open at the same instant.
|
|
537
|
+
//
|
|
538
|
+
// MUST MATCH THE PYTHON HARNESS. Two log formats from one archive is the
|
|
539
|
+
// kind of divergence the conformance suite exists to catch.
|
|
540
|
+
// A market with no opening time keeps the id alone rather than gaining a
|
|
541
|
+
// leading space — every consumer of this file splits on whitespace, and
|
|
542
|
+
// a blank first field shifts all of them by one.
|
|
543
|
+
const shortId = String(entry.market.market_id ?? '').slice(0, 10);
|
|
544
|
+
// READABLE, because ctx.log already puts the EVENT time on every line as
|
|
545
|
+
// epoch millis. Two bare 13-digit numbers side by side are two numbers
|
|
546
|
+
// nobody can tell apart — and the one this prefix exists for is the one
|
|
547
|
+
// that would be mistaken for the other.
|
|
548
|
+
//
|
|
549
|
+
// UTC, to the minute: the market schedule is published in UTC and a
|
|
550
|
+
// market-day is a UTC day, so a local rendering would file a row under a
|
|
551
|
+
// different date than the archive does.
|
|
552
|
+
const openedAt = entry.market.open_ts_ms == null
|
|
553
|
+
? null
|
|
554
|
+
: new Date(entry.market.open_ts_ms).toISOString().slice(0, 16).replace('T', ' ');
|
|
555
|
+
const prefix = openedAt == null ? shortId : `${openedAt} ${shortId}`;
|
|
556
|
+
for (const line of out.logs) logsOut.write(`${prefix} ${line}\n`);
|
|
404
557
|
for (const c of out.crosschecks) result.crosschecks.push(c);
|
|
405
558
|
|
|
406
559
|
// Tracked as the stream went past rather than scanned afterwards: there
|
|
@@ -411,8 +564,8 @@ async function main() {
|
|
|
411
564
|
asset: entry.market.asset ?? null,
|
|
412
565
|
interval: entry.market.interval ?? null,
|
|
413
566
|
outcome: entry.market.outcome ?? null,
|
|
414
|
-
up_px:
|
|
415
|
-
down_px:
|
|
567
|
+
up_px: openQuotes?.up ?? null,
|
|
568
|
+
down_px: openQuotes?.down ?? null,
|
|
416
569
|
stream: entry.stream ?? null,
|
|
417
570
|
});
|
|
418
571
|
} 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,59 @@ 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
|
+
// SIZING IN MONEY — this is about the `notional` field, converted here
|
|
46
|
+
// rather than in the engine. `size` is CONTRACTS (see OrderSizing in
|
|
47
|
+
// index.d.ts); it is not money, and reading this heading as if it were
|
|
48
|
+
// is the one wrong turn this comment can cause.
|
|
49
|
+
//
|
|
50
|
+
// Position sizing is nearly always a budget, not a contract count, and the
|
|
51
|
+
// conversion has exactly one honest divisor: your own limit. A contract
|
|
52
|
+
// costs whatever it fills at, and a marketable order walks the book — so
|
|
53
|
+
// dividing by the current best price overspends the moment there is any
|
|
54
|
+
// slippage, by an amount nobody stated. Dividing by the limit is the price
|
|
55
|
+
// you have already said you will not exceed, which makes "spend at most
|
|
56
|
+
// this much" true rather than approximately true.
|
|
57
|
+
//
|
|
58
|
+
// Hence: `notional` REQUIRES `limit`. Without one there is no upper bound
|
|
59
|
+
// on the fill price, so "spend $80" has no answer, and picking one for the
|
|
60
|
+
// caller would be inventing a number they never wrote.
|
|
61
|
+
if (notional != null) {
|
|
62
|
+
if (size != null) {
|
|
63
|
+
throw new Error('give size or notional, not both — they answer the same question two ways');
|
|
64
|
+
}
|
|
65
|
+
if (!(typeof notional === 'number' && Number.isFinite(notional) && notional > 0)) {
|
|
66
|
+
throw new Error(`notional must be a positive number, got ${JSON.stringify(notional)}`);
|
|
67
|
+
}
|
|
68
|
+
if (limit == null) {
|
|
69
|
+
throw new Error('notional needs a limit: without a price ceiling there is no way to turn a budget into a size');
|
|
70
|
+
}
|
|
71
|
+
const px = Number(limit);
|
|
72
|
+
if (!(Number.isFinite(px) && px > 0)) {
|
|
73
|
+
throw new Error(`notional needs a limit above 0, got ${JSON.stringify(limit)}`);
|
|
74
|
+
}
|
|
75
|
+
// FLOOR, so the spend is at most the budget rather than around it.
|
|
76
|
+
const derived = Math.floor(notional / px);
|
|
77
|
+
if (derived < 1) {
|
|
78
|
+
throw new Error(`notional ${notional} buys no contracts at limit ${px}`);
|
|
79
|
+
}
|
|
80
|
+
// eslint-disable-next-line no-param-reassign
|
|
81
|
+
size = derived;
|
|
82
|
+
}
|
|
45
83
|
if (!(typeof size === 'number' && Number.isFinite(size) && size > 0)) {
|
|
46
84
|
throw new Error(`size must be a positive number, got ${JSON.stringify(size)}`);
|
|
47
85
|
}
|
|
48
|
-
|
|
86
|
+
// A NUMBER, matching the engine. `Number('0.5')` is 0.5, `Number([0.5])`
|
|
87
|
+
// is 0.5 and `Number(true)` is 1 — while Python's `float([0.5])` raises,
|
|
88
|
+
// so the same published SDK accepted a list in one language and threw in
|
|
89
|
+
// the other. Coercion is not validation, and the two languages do not
|
|
90
|
+
// coerce alike.
|
|
91
|
+
if (limit != null
|
|
92
|
+
&& !(typeof limit === 'number' && Number.isFinite(limit) && limit >= 0 && limit <= 1)) {
|
|
49
93
|
// A binary outcome token trades between 0 and 1. A limit outside that is
|
|
50
94
|
// not a price, and silently clamping it would fill an order the strategy
|
|
51
95
|
// 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,32 +7,77 @@ 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
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
21
|
+
import datetime as _datetime
|
|
20
22
|
import sys
|
|
21
23
|
|
|
22
24
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
25
|
|
|
24
|
-
from otengine import BudgetMonitor, Portfolio, RunAbort # noqa: E402
|
|
25
|
-
from otreplay import
|
|
26
|
+
from otengine import Book as _Book, BudgetMonitor, Portfolio, RunAbort # noqa: E402
|
|
27
|
+
from otreplay import ( # noqa: E402
|
|
28
|
+
write_all,
|
|
29
|
+
replay_market, make_log_budget, LOG_BYTES_PER_RUN, LOG_LINE_CHARS,
|
|
30
|
+
)
|
|
31
|
+
from otfeed import build_feeds # noqa: E402
|
|
26
32
|
|
|
27
|
-
# Results go out over
|
|
33
|
+
# Results go out over stdout, authenticated — see the long note in protocol.mjs.
|
|
28
34
|
# /out used to be a writable bind mount, and a strategy declaring the allowed
|
|
29
35
|
# `pandas` could rewrite trades.jsonl from on_settle, after being told the
|
|
30
36
|
# official outcome.
|
|
31
|
-
|
|
37
|
+
# The authenticated result channel.
|
|
38
|
+
#
|
|
39
|
+
# Docker hands a container stdin/stdout/stderr and nothing else. The worker
|
|
40
|
+
# spawns `docker run` with a fourth pipe, but that fd belongs to the docker
|
|
41
|
+
# CLIENT — inside the container fd 3 is closed, and every write to it failed
|
|
42
|
+
# with EBADF. That is why no containerised run had ever produced a result.
|
|
43
|
+
#
|
|
44
|
+
# So the real stdout is duplicated to a private fd and fd 1 is pointed at
|
|
45
|
+
# /dev/null, HERE, before any strategy code is imported: results leave over the
|
|
46
|
+
# container's stdout, and a strategy's print() goes nowhere. Which was already
|
|
47
|
+
# the intent — the worker used to discard stdout for exactly that reason.
|
|
48
|
+
#
|
|
49
|
+
# /proc/self/fd/<n> stays addressable. The MAC is what makes forgery
|
|
50
|
+
# impossible, and always was.
|
|
51
|
+
RESULT_FD = os.dup(1)
|
|
52
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
53
|
+
os.dup2(_devnull, 1)
|
|
54
|
+
os.close(_devnull)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_is_gone(*_args, **_kwargs):
|
|
58
|
+
"""print() writes to /dev/null now; say so once, on stderr.
|
|
59
|
+
|
|
60
|
+
Not silent: a strategy author whose print() vanishes without a word will
|
|
61
|
+
spend an afternoon on it. `ot run` shows stderr.
|
|
62
|
+
"""
|
|
63
|
+
if not _print_is_gone.warned:
|
|
64
|
+
_print_is_gone.warned = True
|
|
65
|
+
try:
|
|
66
|
+
sys.stderr.write("harness: print() output is discarded -- use ctx.log() instead\n")
|
|
67
|
+
except Exception:
|
|
68
|
+
pass # stderr is not worth crashing a run over
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_print_is_gone.warned = False
|
|
72
|
+
builtins.print = _print_is_gone
|
|
32
73
|
CHANNEL_TRADE = "t"
|
|
33
74
|
CHANNEL_FILL = "f"
|
|
34
75
|
CHANNEL_LOG = "l"
|
|
35
76
|
CHANNEL_RESULT = "r"
|
|
77
|
+
# "I have finished replaying market-day N." Display only, and the mirror of
|
|
78
|
+
# CHANNEL.progress in protocol.mjs -- see the long note there. Both engines or
|
|
79
|
+
# neither: runner/conformance compares them line by line.
|
|
80
|
+
CHANNEL_PROGRESS = "p"
|
|
36
81
|
|
|
37
82
|
EXIT_OK = 0
|
|
38
83
|
EXIT_REJECTED = 10
|
|
@@ -184,7 +229,7 @@ def read_line(stream):
|
|
|
184
229
|
|
|
185
230
|
def main() -> int:
|
|
186
231
|
if len(sys.argv) < 2:
|
|
187
|
-
sys.stderr.write("usage: harness.py <job-dir> (job on stdin, results on
|
|
232
|
+
sys.stderr.write("usage: harness.py <job-dir> (job on stdin, results on stdout)\n")
|
|
188
233
|
return 2
|
|
189
234
|
job_dir = sys.argv[1]
|
|
190
235
|
|
|
@@ -198,6 +243,10 @@ def main() -> int:
|
|
|
198
243
|
|
|
199
244
|
limits = job.get("limits") or {}
|
|
200
245
|
monitor = BudgetMonitor(limit_micros=limits.get("perEventBudgetMicros", 400))
|
|
246
|
+
log_budget = make_log_budget(
|
|
247
|
+
limits.get("logBytesPerRun", LOG_BYTES_PER_RUN),
|
|
248
|
+
limits.get("logLineChars", LOG_LINE_CHARS),
|
|
249
|
+
)
|
|
201
250
|
|
|
202
251
|
result = {
|
|
203
252
|
"markets_run": 0,
|
|
@@ -220,7 +269,7 @@ def main() -> int:
|
|
|
220
269
|
mac = hmac.new(
|
|
221
270
|
key_bytes, f"{channel} {payload}".encode("utf-8"), hashlib.sha256
|
|
222
271
|
).hexdigest()[:32]
|
|
223
|
-
|
|
272
|
+
write_all(RESULT_FD, f"{mac} {channel} {payload}\n".encode("utf-8"))
|
|
224
273
|
|
|
225
274
|
class _Logs:
|
|
226
275
|
@staticmethod
|
|
@@ -268,7 +317,29 @@ def main() -> int:
|
|
|
268
317
|
# Pulled one at a time as replay asks. Nothing here holds more than the
|
|
269
318
|
# current row — that is what makes "future rows are not in the process"
|
|
270
319
|
# literally true rather than approximately true.
|
|
271
|
-
|
|
320
|
+
# The book as the ENGINE sees it, advanced by the same class. A
|
|
321
|
+
# Polymarket snapshot carries one side, its ladder is published
|
|
322
|
+
# descending while Predict's is ascending, and a price_change after the
|
|
323
|
+
# last snapshot moves the price for the engine — reading the last event
|
|
324
|
+
# got all three wrong, and was a second implementation besides.
|
|
325
|
+
counters = {"n": int(entry.get("n") or 0), "seen": 0,
|
|
326
|
+
"book": _Book(entry["market"]["market_id"]),
|
|
327
|
+
# The first book state that quotes BOTH sides — the same
|
|
328
|
+
# rule the worker applies. Locking each side as it appears
|
|
329
|
+
# would mix prices from two instants, and the favourite is
|
|
330
|
+
# the dearer of the pair.
|
|
331
|
+
"open_quotes": None}
|
|
332
|
+
|
|
333
|
+
# Reference feeds and user series arrive INTERLEAVED in this same
|
|
334
|
+
# stream, in event time, and are appended to growing arrays as they
|
|
335
|
+
# pass. Not shipped as a block on the market header: a Binance price is
|
|
336
|
+
# very nearly the underlying that decides the outcome, so holding the
|
|
337
|
+
# whole window in the process would reopen exactly the hole the event
|
|
338
|
+
# stream was hardened against. Streaming keeps it structural.
|
|
339
|
+
feed_rows: dict = {}
|
|
340
|
+
|
|
341
|
+
def rows_for(name):
|
|
342
|
+
return feed_rows.setdefault(name, [])
|
|
272
343
|
|
|
273
344
|
def event_stream():
|
|
274
345
|
while counters["n"] > 0:
|
|
@@ -281,9 +352,24 @@ def main() -> int:
|
|
|
281
352
|
except json.JSONDecodeError:
|
|
282
353
|
# Corruption in OUR data, not the strategy's problem.
|
|
283
354
|
continue
|
|
355
|
+
kind = ev.get("kind")
|
|
356
|
+
if kind in ("ref", "ext"):
|
|
357
|
+
# Consumed here, never handed to a hook: not market events.
|
|
358
|
+
row = {k: v for k, v in ev.items() if k not in ("kind", "name")}
|
|
359
|
+
rows_for(ev.get("name")).append(row)
|
|
360
|
+
continue
|
|
284
361
|
counters["seen"] += 1
|
|
285
|
-
if
|
|
286
|
-
counters["
|
|
362
|
+
if kind == "book" and ev.get("snapshot"):
|
|
363
|
+
counters["book"].snapshot(ev.get("ts_ms") or 0, ev.get("levels") or {})
|
|
364
|
+
elif kind == "book" and ev.get("side") and ev.get("ladder"):
|
|
365
|
+
counters["book"].delta(
|
|
366
|
+
ev.get("ts_ms") or 0, ev["side"], ev["ladder"],
|
|
367
|
+
ev.get("px"), ev.get("size"))
|
|
368
|
+
if kind == "book" and counters["open_quotes"] is None:
|
|
369
|
+
_up = counters["book"].best("UP")
|
|
370
|
+
_down = counters["book"].best("DOWN")
|
|
371
|
+
if _up is not None and _down is not None:
|
|
372
|
+
counters["open_quotes"] = (_up, _down)
|
|
287
373
|
yield ev
|
|
288
374
|
|
|
289
375
|
def drain_rest():
|
|
@@ -313,10 +399,24 @@ def main() -> int:
|
|
|
313
399
|
hooks=job.get("hooks") or {},
|
|
314
400
|
portfolio=pf,
|
|
315
401
|
fill_delay_ms=job.get("fillDelayMs", 0),
|
|
316
|
-
|
|
402
|
+
# ONE allowance for the whole run, handed to every market.
|
|
403
|
+
# Per-market was the old shape and the reason ctx.log was an
|
|
404
|
+
# export channel: polymarket has ~386 markets a day, so a
|
|
405
|
+
# per-market budget is a per-run budget multiplied by 386.
|
|
406
|
+
log_budget=log_budget,
|
|
317
407
|
budget=monitor,
|
|
318
408
|
seed=job.get("seed", 1),
|
|
319
409
|
fee_bps=job.get("feeBps", 0),
|
|
410
|
+
references=build_feeds(
|
|
411
|
+
entry.get("references") or [],
|
|
412
|
+
{n: rows_for(n) for n in (entry.get("references") or [])},
|
|
413
|
+
entry.get("lags") or {},
|
|
414
|
+
),
|
|
415
|
+
series=build_feeds(
|
|
416
|
+
entry.get("series") or [],
|
|
417
|
+
{n: rows_for(n) for n in (entry.get("series") or [])},
|
|
418
|
+
entry.get("lags") or {},
|
|
419
|
+
),
|
|
320
420
|
)
|
|
321
421
|
except RunAbort as err:
|
|
322
422
|
drain_rest()
|
|
@@ -336,19 +436,37 @@ def main() -> int:
|
|
|
336
436
|
drain_rest()
|
|
337
437
|
result["markets_run"] += 1
|
|
338
438
|
result["events_seen"] += counters["seen"]
|
|
339
|
-
|
|
439
|
+
# Acknowledged AFTER the replay, so the panel a customer is watching
|
|
440
|
+
# counts finished work rather than queued bytes.
|
|
441
|
+
emit(CHANNEL_PROGRESS, _DUMPS({"n": result["markets_run"]}))
|
|
442
|
+
if out["log_truncated"] and not result["log_truncated"]:
|
|
340
443
|
result["log_truncated"] = True
|
|
444
|
+
# Said in the log itself, once. A log that just stops reads as a
|
|
445
|
+
# strategy that stopped calling ctx.log, and the reader goes
|
|
446
|
+
# hunting for a bug in their own code.
|
|
447
|
+
emit(CHANNEL_LOG, "[runner] log budget spent -- the rest of this"
|
|
448
|
+
" run's ctx.log output was dropped. ctx.log is for reading,"
|
|
449
|
+
" not for exporting; see the SDK docs for the limit.")
|
|
341
450
|
for line in out["logs"]:
|
|
342
|
-
|
|
451
|
+
# 开盘时间在前,短 id 在后 —— 见 node harness 里的同一处注释。
|
|
452
|
+
# 两个 harness 的日志格式必须一致。
|
|
453
|
+
_open = entry["market"].get("open_ts_ms")
|
|
454
|
+
_sid = str(entry["market"].get("market_id") or "")[:10]
|
|
455
|
+
# 格式化成可读的 UTC —— ctx.log 已经在每行放了事件时间(毫秒数),
|
|
456
|
+
# 两个 13 位裸数字挨在一起没人分得清。见 node harness 的注释。
|
|
457
|
+
# 没有开盘时间时只留 id,不要留一个前导空格。
|
|
458
|
+
if _open is None:
|
|
459
|
+
_prefix = _sid
|
|
460
|
+
else:
|
|
461
|
+
_dt = _datetime.datetime.fromtimestamp(_open / 1000, _datetime.timezone.utc)
|
|
462
|
+
_prefix = f"{_dt.strftime('%Y-%m-%d %H:%M')} {_sid}"
|
|
463
|
+
logs_fh.write(f'{_prefix} {line}\n')
|
|
343
464
|
result["crosschecks"].extend(out["crosschecks"])
|
|
344
465
|
|
|
345
466
|
# 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
|
|
467
|
+
_oq = counters["open_quotes"]
|
|
468
|
+
up_px = _oq[0] if _oq else None
|
|
469
|
+
down_px = _oq[1] if _oq else None
|
|
352
470
|
result["market_summaries"].append({
|
|
353
471
|
"market_id": entry["market"]["market_id"],
|
|
354
472
|
"asset": entry["market"].get("asset"),
|