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.
@@ -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,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
- if (out.logTruncated) result.log_truncated = true;
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: lastBook?.levels?.UP?.asks?.[0]?.[0] ?? null,
415
- down_px: lastBook?.levels?.DOWN?.asks?.[0]?.[0] ?? null,
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
- 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,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
- if (limit != null && !(Number(limit) >= 0 && Number(limit) <= 1)) {
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 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,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 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
@@ -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 replay_market # noqa: E402
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 FD 3, authenticated — see the long note in protocol.mjs.
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
- RESULT_FD = 3
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 fd 3)\n")
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
- os.write(RESULT_FD, f"{mac} {channel} {payload}\n".encode("utf-8"))
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
- counters = {"n": int(entry.get("n") or 0), "seen": 0, "last_book": None}
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 ev.get("kind") == "book" and ev.get("snapshot"):
286
- counters["last_book"] = ev
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
- log_limit=limits.get("logLinesPerMarketDay", 10_000),
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
- if out["log_truncated"]:
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
- 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
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"),