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.
@@ -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 fd 3
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 fd 3)\n');
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 FD 3, authenticated with the per-run key
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: the budget is a p99 over events, and
305
- // resetting it per market would let a strategy be pathological on every
306
- // market and never trip.
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
- let lastBook = null;
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' && ev.snapshot) lastBook = ev;
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
- if (out.logTruncated) result.log_truncated = true;
403
- for (const line of out.logs) logsOut.write(`${entry.market.market_id} ${line}\n`);
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: lastBook?.levels?.UP?.asks?.[0]?.[0] ?? null,
415
- down_px: lastBook?.levels?.DOWN?.asks?.[0]?.[0] ?? null,
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
- export interface OrderInit {
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
- if (limit != null && !(Number(limit) >= 0 && Number(limit) <= 1)) {
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 FD 3, authenticated. There is no output directory.
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
- /** The fd the harness writes results to. 0 is the job, 1 and 2 are discarded. */
78
- export const RESULT_FD = 3;
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 fd 3
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 replay_market # noqa: E402
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 FD 3, authenticated — see the long note in protocol.mjs.
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
- RESULT_FD = 3
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 fd 3)\n")
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
- os.write(RESULT_FD, f"{mac} {channel} {payload}\n".encode("utf-8"))
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
- counters = {"n": int(entry.get("n") or 0), "seen": 0, "last_book": None}
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 ev.get("kind") == "book" and ev.get("snapshot"):
286
- counters["last_book"] = ev
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
- log_limit=limits.get("logLinesPerMarketDay", 10_000),
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
- if out["log_truncated"]:
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
- logs_fh.write(f'{entry["market"]["market_id"]} {line}\n')
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
- lb = counters["last_book"] or {}
347
- levels = lb.get("levels") or {}
348
- up_asks = (levels.get("UP") or {}).get("asks") or []
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"),