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/runner/events.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { classifyPath } from '../api/lib/data-taxonomy.mjs';
14
14
  import { resolveSettlementStream } from '../api/lib/backtest-datasets.mjs';
15
+ import { bookThrottleMs } from '../api/lib/backtest-contract.mjs';
16
+ import { Book } from './engine/book.mjs';
15
17
 
16
18
  /**
17
19
  * Coerce a field to a number, or null.
@@ -42,33 +44,312 @@ export function parseLevels(v) {
42
44
  }
43
45
 
44
46
  /**
45
- * Which stream backed each market, from the markets metadata.
47
+ * A market's own record, normalised across venues.
46
48
  *
47
- * Fail-closed: a market whose config we cannot read gets `null` and is dropped,
48
- * because feeding a strategy a stream the market did not settle on is the one
49
- * error this product cannot make. An ABSENT config is a read failure, not
50
- * evidence of the pre-TWAP regime see resolveSettlementStream.
49
+ * WRITTEN AGAINST THE ARCHIVE, not against a guess at it. The previous version
50
+ * read `open_ts_ms`, `close_ts_ms`, `interval` and `outcome` none of which
51
+ * the archive publishes. Every market therefore came out with a null window,
52
+ * every event was cut by the window filter, and every run in the product's life
53
+ * reported "no market data could be read". The shapes below were taken from
54
+ * real objects; do not adjust one without looking at one.
55
+ *
56
+ * Polymarket:
57
+ * {slug, asset:"btc", interval_sec:900, condition_id, token_ids:[UP, DOWN],
58
+ * start_sec, end_sec, resolved, outcome_prices:["0","1"], strike_value, raw}
59
+ * `strike_value` is scaled by 1e18; `outcome_prices[0]` is the UP token.
60
+ *
61
+ * Predict:
62
+ * {category_slug, asset:"btc", interval_label:"15m", market_id:1506956,
63
+ * price_feed_id, price_feed_symbol, price_feed_provider, condition_id,
64
+ * start_sec, end_sec, start_price:"627.505", end_price:"628.265", status}
65
+ * Prices are plain decimals and settlement is end_price vs start_price.
66
+ */
67
+
68
+ /** `900` -> `"15m"`, `86400` -> `"1d"`. Null when the venue states it directly. */
69
+ function intervalLabel(seconds) {
70
+ const n = num(seconds);
71
+ if (n == null || n <= 0) return null;
72
+ if (n % 86400 === 0) return `${n / 86400}d`;
73
+ if (n % 3600 === 0) return `${n / 3600}h`;
74
+ if (n % 60 === 0) return `${n / 60}m`;
75
+ return `${n}s`;
76
+ }
77
+
78
+ /**
79
+ * Which side of a Polymarket market a token id belongs to.
80
+ *
81
+ * VERIFIED AGAINST SETTLED MARKETS, not assumed: for 64 of 64 resolved
82
+ * 15-minute markets on 2026-08-20, `outcome_prices[0] === '1'` coincided
83
+ * exactly with the settlement price closing above the strike. So `token_ids[0]`
84
+ * is UP. Getting this backwards would mirror every strategy's P&L while the
85
+ * report still looked entirely reasonable.
51
86
  */
52
- export function indexMarkets(rows) {
87
+ function sideOfToken(market, assetId) {
88
+ const ids = market?.token_ids;
89
+ if (!Array.isArray(ids) || assetId == null) return null;
90
+ const i = ids.indexOf(String(assetId));
91
+ if (i === 0) return 'UP';
92
+ if (i === 1) return 'DOWN';
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * UP, DOWN, or null — never a guess.
98
+ *
99
+ * Polymarket writes the settled pair as `["1","0"]` (the UP token paid) or
100
+ * `["0","1"]`. Anything else is a row we cannot read.
101
+ */
102
+ function polymarketOutcome(row) {
103
+ if (row?.resolved !== true) return null;
104
+ const p = row.outcome_prices;
105
+ if (!Array.isArray(p) || p.length !== 2) return null;
106
+ const [up, down] = p.map((x) => String(x));
107
+ if (up === '1' && down === '0') return 'UP';
108
+ if (up === '0' && down === '1') return 'DOWN';
109
+ return null;
110
+ }
111
+
112
+ /** One Polymarket markets row -> the normalised record. */
113
+ function polymarketRecord(row) {
114
+ const openMs = num(row.start_sec) == null ? null : num(row.start_sec) * 1000;
115
+ const closeMs = num(row.end_sec) == null ? null : num(row.end_sec) * 1000;
116
+ // The strike is published at full accuracy, scaled by 1e18 — the same value
117
+ // that appears in the settlement stream's `full_accuracy_value` column.
118
+ const strikeRaw = num(row.strike_value);
119
+ // The outcome is read from a COMPLETE binary pair, or not at all.
120
+ //
121
+ // `prices[0] === '1' ? UP : DOWN` made every other value a DOWN — `[]`, `['']`,
122
+ // `['0.5']`, a drifted schema, a truncated row. A settled position is priced
123
+ // at $1/$0 off this field, so one unreadable metadata row would have inverted
124
+ // a market's P&L, its baseline and its crosschecks while still looking like a
125
+ // perfectly ordinary resolved market. Fail-closed, like every other
126
+ // settlement fact here: null, and the fetcher drops the market-day.
127
+ const outcome = polymarketOutcome(row);
128
+ return {
129
+ market_id: String(row.condition_id ?? row.slug ?? ''),
130
+ slug: row.slug ?? null,
131
+ asset: row.asset ? String(row.asset).toUpperCase() : null,
132
+ interval: intervalLabel(row.interval_sec),
133
+ strike: strikeRaw == null ? null : strikeRaw / 1e18,
134
+ outcome,
135
+ open_ts_ms: openMs,
136
+ close_ts_ms: closeMs,
137
+ stream: resolveSettlementStream(row),
138
+ token_ids: Array.isArray(row.token_ids) ? row.token_ids.map(String) : [],
139
+ raw: row,
140
+ };
141
+ }
142
+
143
+ /** One Predict markets row -> the normalised record. */
144
+ function predictRecord(row) {
145
+ const openMs = num(row.start_sec) == null ? null : num(row.start_sec) * 1000;
146
+ const closeMs = num(row.end_sec) == null ? null : num(row.end_sec) * 1000;
147
+ const start = num(row.start_price);
148
+ const end = num(row.end_price);
149
+ // RESOLVED is the venue's own word for "this is final". An OPEN market has no
150
+ // outcome even if both prices are present — they are live quotes then, not a
151
+ // settlement, and treating them as one would hand a strategy the answer.
152
+ // Same rule, and the same reason: only a RESOLVED market with two readable
153
+ // prices has an outcome. A tie is not a guess either — the venue settles it
154
+ // one way and we do not know which, so the market-day is dropped.
155
+ const outcome = String(row.status).toUpperCase() === 'RESOLVED'
156
+ && start != null && end != null && end !== start
157
+ ? (end > start ? 'UP' : 'DOWN')
158
+ : null;
159
+ return {
160
+ market_id: String(row.market_id ?? row.condition_id ?? ''),
161
+ slug: row.category_slug ?? null,
162
+ asset: row.asset ? String(row.asset).toUpperCase() : null,
163
+ interval: row.interval_label ? String(row.interval_label) : null,
164
+ strike: start,
165
+ outcome,
166
+ open_ts_ms: openMs,
167
+ close_ts_ms: closeMs,
168
+ // Predict settles on its own price feed, named by the market. There is no
169
+ // TWAP variant in this tree, so the stream is `prices` — but WHICH file
170
+ // that is depends on `price_feed_id`.
171
+ //
172
+ // FAIL-CLOSED, like every other settlement question here: a market whose
173
+ // feed id cannot be read has no stream, so the fetcher drops the market-day
174
+ // rather than run it against whichever price file happened to match the
175
+ // asset. A dropped day is visible in coverage and costs the customer
176
+ // nothing; a market settled off another feed's price is invisible and makes
177
+ // the whole report a lie.
178
+ stream: num(row.price_feed_id) == null ? null : 'prices',
179
+ price_feed_id: num(row.price_feed_id),
180
+ token_ids: [],
181
+ raw: row,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Index the markets of one day, keyed by the id its event rows carry.
187
+ *
188
+ * Both venues get the SAME record shape, because everything downstream — the
189
+ * engine, the SDK, the report — is venue-agnostic. The differences are absorbed
190
+ * here and nowhere else.
191
+ */
192
+ export function indexMarkets(rows, { venue = 'polymarket' } = {}) {
53
193
  const byId = new Map();
54
194
  for (const row of rows) {
55
- const id = row.market_id ?? row.id ?? row.condition_id;
56
- if (!id) continue;
57
- byId.set(String(id), {
58
- market_id: String(id),
59
- asset: row.asset ?? null,
60
- interval: row.interval ?? null,
61
- strike: num(row.strike),
62
- outcome: row.outcome === 'UP' || row.outcome === 'DOWN' ? row.outcome : null,
63
- open_ts_ms: num(row.open_ts_ms ?? row.start_ts_ms),
64
- close_ts_ms: num(row.close_ts_ms ?? row.end_ts_ms),
65
- stream: resolveSettlementStream(row),
66
- raw: row,
67
- });
195
+ if (!row || typeof row !== 'object') continue;
196
+ const rec = venue === 'predict' ? predictRecord(row) : polymarketRecord(row);
197
+ if (!rec.market_id) continue;
198
+ byId.set(rec.market_id, rec);
68
199
  }
69
200
  return byId;
70
201
  }
71
202
 
203
+ /**
204
+ * Look up a market from the identifiers an event row actually carries.
205
+ *
206
+ * Polymarket event rows name the market by `slug` — NOT by the `condition_id`
207
+ * the markets tree is keyed on — so a straight id lookup finds nothing. Predict
208
+ * rows carry the numeric `market_id` directly.
209
+ */
210
+ function marketForRow(row, markets, bySlug) {
211
+ const direct = row.market_id ?? row.marketId ?? null;
212
+ if (direct != null && markets.has(String(direct))) return markets.get(String(direct));
213
+ const slug = row.slug ?? row.category_slug ?? null;
214
+ if (slug != null && bySlug.has(String(slug))) return bySlug.get(String(slug));
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * `[{price, size}]` or `[[px, size]]` -> `[[px, size]]`, unusable entries dropped.
220
+ *
221
+ * An outcome token pays 0 or 1, so a price outside that range is not a cheap
222
+ * quote — it is a row we cannot read. Dropped rather than passed on: the engine
223
+ * matches against the best price it is given, so a negative one becomes the
224
+ * best bid in the book and quietly poisons every fill after it. Mirroring makes
225
+ * it worse, turning one bad row into a bad row on the other side too.
226
+ */
227
+ function ladder(levels) {
228
+ if (!Array.isArray(levels)) return [];
229
+ const out = [];
230
+ for (const lv of levels) {
231
+ const px = Array.isArray(lv) ? num(lv[0]) : num(lv?.price);
232
+ const size = Array.isArray(lv) ? num(lv[1]) : num(lv?.size);
233
+ if (px == null || size == null) continue;
234
+ if (px < 0 || px > 1) continue;
235
+ if (size <= 0) continue;
236
+ out.push([px, size]);
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * The mirror of a UP-side ladder: a bid at 0.4 for UP is an ask at 0.6 for DOWN.
243
+ *
244
+ * Only reachable with prices already inside [0, 1], so the result is too — but
245
+ * asserted rather than assumed, because this is the step that would turn one
246
+ * unnoticed bad row into a negative price the engine treats as the best in the
247
+ * book.
248
+ */
249
+ function mirror(levels) {
250
+ const out = [];
251
+ for (const [px, size] of levels) {
252
+ const m = Number((1 - px).toFixed(10));
253
+ if (!(m >= 0 && m <= 1)) continue;
254
+ out.push([m, size]);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * Thin a venue's book stream to one cadence for the whole run.
261
+ *
262
+ * The archive was captured at different densities at different times, and a run
263
+ * spanning a change would otherwise fill differently in its first month than in
264
+ * its last — with nothing in the report saying why, because the backtest
265
+ * deliberately does not make a reader think about capture rates at all. So the
266
+ * finer days are thinned to match the coarsest one in range, and there is no
267
+ * boundary left to notice.
268
+ *
269
+ * THE SAME RULE THE COLLECTOR APPLIES: at most one row per market per window,
270
+ * keeping the first. A thinned day is therefore byte-for-byte the shape a
271
+ * coarser day already has, rather than an approximation of one.
272
+ *
273
+ * ONE CADENCE PER ASSET PER RUN — NOT ONE PER RUN. The window is the coarsest
274
+ * THAT ASSET had anywhere in the range, so a single asset's series never
275
+ * changes density partway through, which is the artefact a reader could
276
+ * actually notice. Levelling every asset to the run-wide coarsest was the other
277
+ * candidate and is worse on both counts that matter:
278
+ *
279
+ * - It makes a result depend on the basket. The same BTC strategy over the
280
+ * same days would return different numbers because SOL was also ticked,
281
+ * and "why did adding an asset change my BTC P&L" has no honest answer.
282
+ * - It throws away the finer data almost always. Every 500ms asset in a
283
+ * basket would drag BTC back to 500ms, so the 20ms capture would go unused
284
+ * in any multi-asset run — while accuracy is the whole reason it exists.
285
+ *
286
+ * Different assets legitimately differ in density anyway: BTC's book really
287
+ * does move more than DOGE's. What is not legitimate is the SAME book changing
288
+ * density on a date, and that is what this removes.
289
+ *
290
+ * Only the delta/snapshot stream is thinned — Polymarket's `price_change` and
291
+ * Predict's `orderbook`. Settlement ticks, trades and market metadata were
292
+ * never throttled and are not touched.
293
+ */
294
+ const THROTTLED = Object.freeze({ polymarket: 'price_change', predict: 'orderbook' });
295
+
296
+ export function makeBookThrottle({ venue, assets = [], from, to }) {
297
+ const dataset = THROTTLED[venue];
298
+ // One window per asset in scope, and the coarsest of them for anything whose
299
+ // asset we cannot tell — a row we cannot attribute must not be thinned less
300
+ // than the rows we can.
301
+ const perAsset = new Map();
302
+ for (const a of assets) {
303
+ perAsset.set(String(a).toUpperCase(), bookThrottleMs({ venue, asset: a, from, to }));
304
+ }
305
+ // The floor for anything we cannot attribute — including the case where the
306
+ // caller passed no asset list at all. Deriving it from `assets` made an empty
307
+ // list mean "no throttling", which is fail-OPEN: a local `ot run` would then
308
+ // replay denser than the queue does, and "it passed locally" would stop
309
+ // meaning anything. `'*'` asks the table directly.
310
+ const fallback = bookThrottleMs({ venue, asset: '*', from, to });
311
+ const last = new Map();
312
+ return {
313
+ /** Nothing to do when nothing in range was throttled. */
314
+ get active() { return dataset != null && fallback > 0; },
315
+
316
+ /**
317
+ * The window this gate will actually use for an asset.
318
+ *
319
+ * Asked rather than recomputed: the decoded-day cache is keyed on the
320
+ * cadence a day was thinned at, and a caller working that out for itself
321
+ * would be a second implementation of the rule that decides it — which is
322
+ * how a cache ends up serving a 500ms day to a run that asked for 20ms.
323
+ */
324
+ windowFor(asset) {
325
+ if (dataset == null) return 0;
326
+ return perAsset.get(String(asset ?? '').toUpperCase()) ?? fallback;
327
+ },
328
+ /**
329
+ * Should this row be replayed?
330
+ *
331
+ * @param {string} ds the archive dataset the row came from
332
+ * @param {string} marketId the market it belongs to
333
+ * @param {number} ts its event time
334
+ * @param {string|null} asset
335
+ */
336
+ keep(ds, marketId, ts, asset) {
337
+ if (ds !== dataset || fallback === 0) return true;
338
+ const ms = perAsset.get(String(asset ?? '').toUpperCase()) ?? fallback;
339
+ if (ms === 0) return true;
340
+ const key = String(marketId);
341
+ const prev = last.get(key);
342
+ // The FIRST row in each window, exactly as the collector keeps it. A
343
+ // window is measured from the row that opened it, not from a fixed grid:
344
+ // a grid would keep a different row than the collector did on a day it
345
+ // was actually throttling, and the two would disagree about the same day.
346
+ if (prev != null && ts - prev < ms) return false;
347
+ last.set(key, ts);
348
+ return true;
349
+ },
350
+ };
351
+ }
352
+
72
353
  /**
73
354
  * The events one archived row produces, as [marketId, event] pairs.
74
355
  *
@@ -80,70 +361,355 @@ export function indexMarkets(rows) {
80
361
  * @param {string} filePath the archive path the row came from
81
362
  * @param {object} row
82
363
  * @param {Map} markets from indexMarkets
364
+ * @param {Map} bySlug slug -> record, built once by the caller
83
365
  */
84
- export function eventsFromRow(filePath, row, markets) {
366
+ export function eventsFromRow(filePath, row, markets, bySlug = null, throttle = null) {
85
367
  const meta = classifyPath(filePath);
86
- const ts = num(row.ts_ms ?? row.timestamp_ms ?? row.event_ts_ms);
87
- if (ts == null) return [];
368
+ const slugIndex = bySlug ?? buildSlugIndex(markets);
88
369
 
89
370
  if (meta.dataset === 'prices' || meta.dataset === 'twap30s' || meta.dataset === 'twap60s') {
371
+ // Polymarket: `feed_ts_ms,value,...`. Predict: `...,publish_time,server_ts,price,recv_ms`
372
+ // with publish_time in SECONDS. Reading only one of the two spellings is
373
+ // how a whole venue ends up with no ticks at all.
374
+ const pubSec = num(row.publish_time);
375
+ const ts = num(row.feed_ts_ms ?? row.ts_ms ?? row.timestamp_ms ?? row.event_ts_ms)
376
+ ?? (pubSec == null ? null : pubSec * 1000);
377
+ if (ts == null) return [];
378
+ const value = num(row.value ?? row.price ?? row.answer);
379
+ if (value == null) return [];
380
+ const serverSec = num(row.server_ts);
90
381
  const out = [];
91
382
  for (const [id, m] of markets) {
92
383
  if (m.stream !== meta.dataset) continue;
93
- if (m.asset && meta.asset && !String(m.asset).startsWith(meta.asset)) continue;
384
+ if (m.asset && meta.asset && String(m.asset) !== String(meta.asset)) continue;
385
+ // Predict publishes one file per feed, and a market settles on its own.
386
+ // STRICT EQUALITY, so an unreadable id on either side drops the row
387
+ // instead of letting it through: the old condition only rejected when
388
+ // BOTH sides parsed, so a price file with a missing or drifted column fed
389
+ // every market of that asset — not an empty report, but plausible-looking
390
+ // ticks from the wrong feed.
391
+ if (m.price_feed_id != null) {
392
+ const rowFeed = num(row.price_feed_id);
393
+ if (rowFeed == null || rowFeed !== m.price_feed_id) continue;
394
+ }
395
+ // WINDOWED AT EMIT, not afterwards.
396
+ //
397
+ // The settlement stream is a whole day — 75,250 rows for twap60s — and a
398
+ // day of BTC is roughly 384 markets once 5- and 15-minute books are both
399
+ // in scope. Fanning every row out to every market and trimming later is
400
+ // 29 million objects held at once, which is an out-of-memory kill on a
401
+ // 2GB worker rather than a slow day. A market wants the ticks inside its
402
+ // own window and nothing else, so that is what it is given.
403
+ if (m.open_ts_ms != null && ts < m.open_ts_ms) continue;
404
+ if (m.close_ts_ms != null && ts > m.close_ts_ms) continue;
94
405
  out.push([id, {
95
406
  kind: 'tick',
96
407
  ts_ms: ts,
97
408
  market_id: id,
98
- value: num(row.value ?? row.price ?? row.answer),
409
+ value,
99
410
  source: meta.dataset,
100
411
  // Three timestamps kept apart on every row: it is what lets a fill be
101
412
  // re-priced at an arbitrary delay instead of assumed instant.
102
- server_ts_ms: num(row.server_ts_ms),
103
- recv_ts_ms: num(row.recv_ts_ms),
413
+ server_ts_ms: num(row.server_ts_ms) ?? (serverSec == null ? null : serverSec * 1000),
414
+ recv_ts_ms: num(row.recv_ts_ms ?? row.recv_ms),
104
415
  }]);
105
416
  }
106
417
  return out;
107
418
  }
108
419
 
109
- const marketId = row.market_id ?? row.asset_id ?? null;
110
- if (!marketId) return [];
420
+ const market = marketForRow(row, markets, slugIndex);
421
+ if (!market) return [];
422
+ const id = market.market_id;
423
+ const ts = num(row.event_ts_ms ?? row.update_ts_ms ?? row.ts_ms ?? row.timestamp_ms);
424
+ if (ts == null) return [];
425
+ // Applied HERE, once, so both readers get it by decoding rather than by each
426
+ // remembering to. See makeBookThrottle.
427
+ if (throttle && !throttle.keep(meta.dataset, id, ts, market.asset)) return [];
428
+ const payload = row.payload ?? row;
111
429
 
112
- if (meta.dataset === 'book' || meta.dataset === 'orderbook') {
113
- return [[String(marketId), {
430
+ if (meta.dataset === 'orderbook') {
431
+ // ONE normalised ladder per Predict market, on the UP side: 28,677 rows
432
+ // sampled and not one had a bid at or above the best ask, which is what a
433
+ // single book looks like. DOWN is its mirror, never a second stream.
434
+ const asks = ladder(payload.asks);
435
+ const bids = ladder(payload.bids);
436
+ return [[id, {
114
437
  kind: 'book',
115
438
  ts_ms: ts,
116
439
  snapshot: true,
117
440
  levels: {
118
- UP: { asks: parseLevels(row.up_asks), bids: parseLevels(row.up_bids) },
119
- DOWN: { asks: parseLevels(row.down_asks), bids: parseLevels(row.down_bids) },
441
+ UP: { asks, bids },
442
+ DOWN: { asks: mirror(bids), bids: mirror(asks) },
120
443
  },
121
444
  }]];
122
445
  }
123
- if (meta.dataset === 'price_change') {
124
- return [[String(marketId), {
446
+
447
+ if (meta.dataset === 'book') {
448
+ // Polymarket publishes ONE SIDE PER ROW, named by asset_id. Merging the two
449
+ // is the reader's job; a row that names a token this market does not own is
450
+ // not ours.
451
+ const side = sideOfToken(market, row.asset_id ?? payload.asset_id);
452
+ if (!side) return [];
453
+ // ONLY THE SIDE THIS ROW IS ABOUT. An empty object for the other side is
454
+ // not "no information" to the engine — `Book.snapshot` resets whichever
455
+ // sides it is given, and an empty ladder is a side it was given. Sending
456
+ // both meant a UP snapshot wiped DOWN, the next DOWN snapshot wiped UP, and
457
+ // a strategy only ever saw whichever side arrived last. Omitting the key
458
+ // leaves that side untouched, in both engines.
459
+ return [[id, {
125
460
  kind: 'book',
126
461
  ts_ms: ts,
127
- snapshot: false,
128
- side: row.side === 'DOWN' ? 'DOWN' : 'UP',
129
- ladder: row.ladder === 'bids' ? 'bids' : 'asks',
130
- px: num(row.price),
131
- size: num(row.size),
462
+ snapshot: true,
463
+ side,
464
+ levels: { [side]: { asks: ladder(payload.asks), bids: ladder(payload.bids) } },
132
465
  }]];
133
466
  }
467
+
468
+ if (meta.dataset === 'price_change') {
469
+ // A delta carries a batch, each entry naming its own token and ladder side.
470
+ const changes = Array.isArray(payload.price_changes) ? payload.price_changes : [];
471
+ const out = [];
472
+ for (const ch of changes) {
473
+ const side = sideOfToken(market, ch.asset_id);
474
+ if (!side) continue;
475
+ const px = num(ch.price);
476
+ const size = num(ch.size);
477
+ if (px == null || size == null) continue;
478
+ // Same range rule as a snapshot: a delta is applied to the same ladder,
479
+ // so letting one through here would reach the engine by the other door.
480
+ if (px < 0 || px > 1 || size < 0) continue;
481
+ out.push([id, {
482
+ kind: 'book',
483
+ ts_ms: ts,
484
+ snapshot: false,
485
+ side,
486
+ // BUY sits on the bid ladder, SELL on the ask ladder. The venue names
487
+ // the taker's direction, not the book side, so this mapping is the
488
+ // whole meaning of the row.
489
+ ladder: String(ch.side).toUpperCase() === 'SELL' ? 'asks' : 'bids',
490
+ px,
491
+ size,
492
+ }]);
493
+ }
494
+ return out;
495
+ }
496
+
134
497
  if (meta.dataset === 'last_trade_price') {
135
- return [[String(marketId), {
498
+ const side = sideOfToken(market, row.asset_id ?? payload.asset_id);
499
+ if (!side) return [];
500
+ const px = num(payload.price);
501
+ const size = num(payload.size);
502
+ // The same rule the book gets. A trade is a public SDK input a strategy
503
+ // sizes its own orders off, so a null or negative one is a bad signal, not
504
+ // a harmless field.
505
+ if (px == null || px < 0 || px > 1) return [];
506
+ if (size == null || size <= 0) return [];
507
+ return [[id, {
136
508
  kind: 'trade',
137
509
  ts_ms: ts,
138
- market_id: String(marketId),
139
- px: num(row.price),
140
- size: num(row.size),
141
- side: row.side === 'DOWN' ? 'DOWN' : 'UP',
510
+ market_id: id,
511
+ px,
512
+ size,
513
+ side,
514
+ // The taker's direction, kept separate from which outcome traded.
515
+ taker: String(payload.side).toUpperCase() === 'SELL' ? 'SELL' : 'BUY',
142
516
  }]];
143
517
  }
518
+
144
519
  return [];
145
520
  }
146
521
 
522
+ /** slug -> record, for the event rows that name a market that way. */
523
+ export function buildSlugIndex(markets) {
524
+ const bySlug = new Map();
525
+ for (const m of markets.values()) if (m.slug) bySlug.set(String(m.slug), m);
526
+ return bySlug;
527
+ }
528
+
529
+ /**
530
+ * The coverage block, in ONE shape.
531
+ *
532
+ * The docs promise a local run and a queued run produce "the identical files,
533
+ * same checksums, same coverage report". They already shared the decoder and
534
+ * the feed list; the coverage object was still built twice, so `ot run` emitted
535
+ * five keys where the queue emitted ten, and anyone diffing the two saw a
536
+ * schema difference rather than an answer. A field a local run genuinely cannot
537
+ * know is present and null, which is a different statement from absent.
538
+ */
539
+ export function buildCoverage({
540
+ marketDaysRequested = null,
541
+ marketDaysScanned,
542
+ marketsReportedByRunner = null,
543
+ missing = [],
544
+ referenceDeclared = [],
545
+ referenceMissing = [],
546
+ streams = {},
547
+ droppedRows = 0,
548
+ unreconciledRows = 0,
549
+ local = false,
550
+ source = null,
551
+ }) {
552
+ return {
553
+ market_days_requested: marketDaysRequested,
554
+ market_days_scanned: marketDaysScanned,
555
+ // Reported for transparency, never used to bill: this one comes from inside
556
+ // the sandbox.
557
+ markets_reported_by_runner: marketsReportedByRunner,
558
+ // Gaps are published, never smoothed over. This is the number the whole
559
+ // product's credibility rests on.
560
+ missing,
561
+ // Reference days we could not read. Separate from `missing`, which is about
562
+ // OUR archive: a customer needs to know which of the two was short, because
563
+ // only one of them is something they paid us for.
564
+ reference_declared: referenceDeclared,
565
+ reference_missing: referenceMissing,
566
+ streams,
567
+ dropped_rows: droppedRows,
568
+ // Rows the harness produced that do not describe a market the caller
569
+ // supplied. Published rather than swallowed.
570
+ unreconciled_rows: unreconciledRows,
571
+ // Local-only, and last: a queued run has no source directory to name.
572
+ ...(local ? { local: true, source } : {}),
573
+ };
574
+ }
575
+
576
+ /**
577
+ * The billing unit: one asset on one UTC day.
578
+ *
579
+ * SHARED, because a run's size is a number the customer is charged for and
580
+ * shown. `ot run` counted MARKETS here and called them market-days — a single
581
+ * day of BTC 15-minute markets is ninety-six of them — so a local report claimed
582
+ * a run a hundred times larger than the queue would bill for, off the same
583
+ * archive.
584
+ */
585
+ export function countMarketDays(markets) {
586
+ // THE BILLING UNIT: one asset, one UTC day, one market length.
587
+ //
588
+ // The interval is in the key because asking for 5m and 15m replays two
589
+ // disjoint sets of markets over the same days — twice the data, twice the
590
+ // work, twice the price. The quote multiplies by the same thing; if these
591
+ // two ever disagree, `completeRun` refunds the difference and the customer
592
+ // is charged whichever is smaller, silently.
593
+ return new Set(markets.map(
594
+ (m) => `${m.market?.asset ?? 'unknown'}|${m.day}|${m.market?.interval ?? 'none'}`,
595
+ )).size;
596
+ }
597
+
598
+ /** How many markets settled on each stream. Shared for the same reason. */
599
+ export function countStreams(items) {
600
+ const out = {};
601
+ for (const m of items) {
602
+ const s = m?.stream ?? 'unknown';
603
+ out[s] = (out[s] ?? 0) + 1;
604
+ }
605
+ return out;
606
+ }
607
+
608
+ /**
609
+ * Why a market cannot be replayed, or null if it can.
610
+ *
611
+ * ONE PREDICATE, because both readers have to agree about which markets exist.
612
+ * A rule written in the worker and not in `ot run` is the same drift the feed
613
+ * selection and the decoder each already produced: the local run replays a
614
+ * market the queue drops, and a market-making strategy — which never looks at
615
+ * the settlement price — is exactly the case that would never notice.
616
+ *
617
+ * Every reason here is fail-closed. A dropped market-day is visible in coverage
618
+ * and costs the customer nothing; a market replayed without its settlement
619
+ * stream, or settled off an outcome we could not read, is invisible and makes
620
+ * the report wrong.
621
+ */
622
+ /**
623
+ * Put one day's markets in the order they will be replayed.
624
+ *
625
+ * ASSET, THEN OPENING TIME, THEN ID — and shared, because `ot run` and the
626
+ * queue both feed markets to a strategy and a difference here is a difference
627
+ * in what a reader sees from the same archive.
628
+ *
629
+ * The decoder produces market_id order, which is a hash and therefore
630
+ * unrelated to time. Nothing about a RESULT depends on it — state is reset at
631
+ * every on_market_open, which is what lets these be sharded — but `ctx.log`
632
+ * from every market lands in one stream, and a human reads that stream as a
633
+ * timeline. In hash order its timestamps jump hours in both directions for no
634
+ * visible reason.
635
+ *
636
+ * Asset before time so each market-day stays a CONTIGUOUS block:
637
+ * `marketDayPrefix` counts a market-day done when its last market is done, and
638
+ * interleaving two assets pushes both of their last markets to the end of the
639
+ * run — a progress bar that sits still and then jumps, which is the "looks
640
+ * stuck" this channel exists to remove.
641
+ *
642
+ * The id breaks ties because many strikes open at the same instant, and an
643
+ * unstable order would make two runs of the same strategy over the same days
644
+ * emit their logs differently.
645
+ */
646
+ export function sortMarketsForReplay(markets, { mode = 'market' } = {}) {
647
+ const byId = (a, b) => {
648
+ const ai = String(a.market?.market_id ?? '');
649
+ const bi = String(b.market?.market_id ?? '');
650
+ return ai < bi ? -1 : (ai > bi ? 1 : 0);
651
+ };
652
+ const byTime = (a, b) => (a.market?.open_ts_ms ?? 0) - (b.market?.open_ts_ms ?? 0);
653
+
654
+ // SESSION MODE IS ONE STREAM, so it is ordered by time and by nothing else.
655
+ //
656
+ // Session shares one instance and one Portfolio across every market in the
657
+ // range — the docs call it "one ordered stream across the range" — which
658
+ // makes the feed order part of the RESULT, not just of the log. Its equity
659
+ // curve, its position and its P&L accumulate in whatever order markets
660
+ // arrive. Feeding it asset-major would build that curve by walking all of
661
+ // BTC and then going back in time to walk all of ETH: not a sequence that
662
+ // ever happened, and not a number anyone can act on.
663
+ //
664
+ // Before this function existed, session got market_id order — a hash. The
665
+ // "ordered stream" in the docs was ordered by nothing at all.
666
+ if (mode === 'session') return markets.sort((a, b) => byTime(a, b) || byId(a, b));
667
+
668
+ // Market mode: each market is independent (state resets at every
669
+ // on_market_open), so the order changes no result — only what a human reads.
670
+ // Asset-major keeps each market-day a CONTIGUOUS block, which is what
671
+ // marketDayPrefix needs: it counts a market-day done when its LAST market is
672
+ // done, and interleaving assets pushes every asset's last market to the end
673
+ // of the run — a progress bar that sits still and then jumps.
674
+ return markets.sort((a, b) => {
675
+ const aa = a.market?.asset ?? '';
676
+ const ba = b.market?.asset ?? '';
677
+ if (aa !== ba) return aa < ba ? -1 : 1;
678
+ return byTime(a, b) || byId(a, b);
679
+ });
680
+ }
681
+
682
+ export function marketUnusable(market, inWindow) {
683
+ if (!market) return 'no market metadata';
684
+ // WHICH ASSET IS THIS? Every layer above needs the answer and none of them
685
+ // can work it out later: the billing key is `asset|day|interval`, the book
686
+ // cadence is chosen per asset, and — since the decoded day became per-asset —
687
+ // a market with no asset belongs to no cache entry in particular. Predict
688
+ // publishes ONE venue-wide markets file, and the per-asset row filter is
689
+ // `if (m.asset && …)`, so a row missing its category lands in every asset's
690
+ // read at once, each carrying a different slice of its events under the same
691
+ // name. Keeping any one of those replays a market with half its data and
692
+ // nothing to say so.
693
+ //
694
+ // Decided HERE because this is the one function both the queue and `ot run`
695
+ // ask. The first version of this rule lived in the queue's merge step, which
696
+ // meant the local runner kept the market, and a single-asset run — which has
697
+ // nothing to merge — replayed it out of the cache anyway.
698
+ if (!market.asset) return 'market has no asset';
699
+ if (market.stream == null) return 'settlement stream could not be resolved';
700
+ if (market.outcome !== 'UP' && market.outcome !== 'DOWN') {
701
+ return 'outcome could not be read';
702
+ }
703
+ if (!inWindow || inWindow.length === 0) return 'no events inside the market window';
704
+ // Book rows alone make the window non-empty, so without this a day whose
705
+ // settlement file was missing came back as scanned and billable while
706
+ // `on_tick` never fired.
707
+ if (!inWindow.some((e) => e.kind === 'tick')) {
708
+ return `no settlement ticks on ${market.stream}`;
709
+ }
710
+ return null;
711
+ }
712
+
147
713
  /**
148
714
  * Order one market's events and cut them at its close.
149
715
  *
@@ -154,24 +720,79 @@ export function eventsFromRow(filePath, row, markets) {
154
720
  * out of the strategy's view AND out of the report's baselines.
155
721
  */
156
722
  export function finaliseMarket(events, market) {
157
- // Stable sort by event time. Ties keep insertion order, which puts a book
158
- // update before the tick that arrived in the same millisecond — a strategy
159
- // reacting to that tick should see the book as it already was.
160
- events.sort((a, b) => a.ts_ms - b.ts_ms);
723
+ // Sorted by event time, and TIES BROKEN BY KIND never by insertion order.
724
+ //
725
+ // Insertion order is the order the archive files happened to be read in: the
726
+ // catalog's order for the queue, the directory's for `ot run`. So a book
727
+ // update and a tick stamped the same millisecond could arrive either way
728
+ // round, and a strategy reacting to that tick would price against a book it
729
+ // had not been shown yet. Worse, the two readers could disagree — the same
730
+ // archive producing two different reports, which is exactly what `ot run`
731
+ // promises cannot happen.
732
+ //
733
+ // The order within a millisecond is part of the contract: the book is brought
734
+ // up to date, then what traded on it, then the tick a strategy reacts to. A
735
+ // hook sees the world as it already was, never as it is about to be.
736
+ //
737
+ // Within `book`, a full snapshot comes before a delta: a snapshot is the
738
+ // state AT that moment and a delta refines it, so applying them the other way
739
+ // round throws the delta away. The remaining ties are between rows of the
740
+ // same kind from the same file, where the archive's own order is the answer
741
+ // and the caller reads files in a fixed order — see the feed selection.
742
+ const RANK = { book: 0, trade: 1, tick: 2, ref: 3, ext: 4 };
743
+ const rank = (e) => RANK[e?.kind] ?? 9;
744
+ const sub = (e) => (e?.kind === 'book' && e.snapshot !== true ? 1 : 0);
745
+ events.sort((a, b) => (a.ts_ms - b.ts_ms) || (rank(a) - rank(b)) || (sub(a) - sub(b)));
161
746
 
747
+ // BOTH ENDS. The upper cut was here from the start; the lower one was not,
748
+ // and only the settlement fan-out applied it. So a book or trade row printed
749
+ // before the market opened was replayed to the strategy and priced the
750
+ // "opening" baseline — a quote from before there was anything to quote.
751
+ const open = market.open_ts_ms;
162
752
  const close = market.close_ts_ms;
163
- const inWindow = close == null ? events : events.filter((e) => e.ts_ms <= close);
753
+ const inWindow = events.filter((e) => (open == null || e.ts_ms >= open)
754
+ && (close == null || e.ts_ms <= close));
164
755
 
165
756
  // The closing quote on each side, from the events that survived the cut. The
166
757
  // report's naive baselines are priced off this.
758
+ //
759
+ // Walked back PER SIDE. Polymarket publishes one side per row, so the last
760
+ // snapshot in the file is a single token's book and the other side's entry is
761
+ // empty — taking both from one row left every DOWN baseline null, and a null
762
+ // baseline silently drops a comparison the report claims to make.
763
+ // The price each side could first have been BOUGHT at — the entry a naive
764
+ // baseline would have taken.
765
+ //
766
+ // The opening quote, not the closing one. "Always buy UP" means buying when
767
+ // the market opens and holding to settlement, so pricing it at the close
768
+ // compares the strategy against a trade nobody could make: by then the
769
+ // outcome is decided, "the favourite" is simply the winner, and the panel
770
+ // labelled `always_favourite` becomes a perfect strategy at no cost. Measured
771
+ // on a real day, the closing ask also does not exist for the winning side in
772
+ // 384 of 388 markets — nobody offers a certain winner below a dollar — so the
773
+ // baselines were computed over 1% of the markets they claimed to cover.
774
+ //
775
+ // Advanced through the SAME Book the engine uses, so the two cannot disagree
776
+ // about what the book was. Reading a raw ladder instead was wrong twice over:
777
+ // element zero is the worst offer on a venue that publishes descending, and
778
+ // stopping at the last snapshot ignored every delta after it.
779
+ const book = new Book();
167
780
  let upPx = null;
168
781
  let downPx = null;
169
- for (let i = inWindow.length - 1; i >= 0; i -= 1) {
170
- const ev = inWindow[i];
171
- if (ev.kind !== 'book' || !ev.snapshot) continue;
172
- upPx = ev.levels?.UP?.asks?.[0]?.[0] ?? null;
173
- downPx = ev.levels?.DOWN?.asks?.[0]?.[0] ?? null;
174
- if (upPx != null || downPx != null) break;
782
+ for (const ev of inWindow) {
783
+ if (ev.kind !== 'book') continue;
784
+ if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
785
+ else if (ev.side && ev.ladder) book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
786
+ // BOTH SIDES FROM ONE BOOK STATE, captured together.
787
+ //
788
+ // Polymarket publishes one side per row, so locking each side the moment it
789
+ // first appears mixes a UP price from one instant with a DOWN price from
790
+ // another — and `always_favourite`, which picks the dearer of the two, then
791
+ // compares prices that never coexisted. Waiting until the book quotes both
792
+ // is the first moment the comparison is about a real market.
793
+ const up = book.best('UP');
794
+ const down = book.best('DOWN');
795
+ if (up != null && down != null) { upPx = up; downPx = down; break; }
175
796
  }
176
797
 
177
798
  return { events: inWindow, up_px: upPx, down_px: downPx };
@@ -188,3 +809,115 @@ export function parseRow(line, { isCsv, header }) {
188
809
  for (let i = 0; i < header.length; i += 1) row[header[i]] = cells[i];
189
810
  return row;
190
811
  }
812
+
813
+ // Merging point-in-time feeds into a market's event stream.
814
+ //
815
+ // MOVED HERE FROM THE WORKER, and the move is not cosmetic. `ot run` needs this
816
+ // to replay a submitter's own CSV locally, and the npm package ships from an
817
+ // explicit file list — importing it from `worker.mjs` meant the published CLI
818
+ // would load a module that is not in the tarball, and would drag `pg` and the
819
+ // AWS SDK with it if it were. It worked in this repo and nowhere else.
820
+
821
+ /**
822
+ * Merge one market-day's events with the reference rows that fall inside it.
823
+ *
824
+ * Both sides are already in time order, so this is a merge rather than a sort:
825
+ * a sort over a day of 1s klines plus a day of book updates is millions of
826
+ * comparisons per market, repeated for every market on that day.
827
+ *
828
+ * Rows are clipped to the market's own window. A reference row from outside it
829
+ * would reach a strategy replaying a market that had already closed — and on
830
+ * the next market, the feed's monotone cursor would have already walked past
831
+ * it, so it would be silently invisible instead. Neither is a thing to ship.
832
+ */
833
+ /**
834
+ * First index whose ts_ms is >= `at`, over rows already sorted by ts_ms.
835
+ *
836
+ * Rows with a non-finite ts_ms sort to the end via the comparator that built
837
+ * the list, so they are handled by the caller rather than here.
838
+ */
839
+ function lowerBound(rows, at) {
840
+ let lo = 0;
841
+ let hi = rows.length;
842
+ while (lo < hi) {
843
+ const mid = (lo + hi) >> 1;
844
+ if (Number(rows[mid]?.ts_ms) < at) lo = mid + 1;
845
+ else hi = mid;
846
+ }
847
+ return lo;
848
+ }
849
+
850
+ export function mergeReferenceRows(eventLines, rowsByName, market, kind = 'ref', lags = null) {
851
+ // A bound only when there IS one. `Number(null)` is 0, not NaN, so a market
852
+ // whose close time is null — an empty cell normalises to null, and some
853
+ // streams carry no window at all — would require every reference row to be
854
+ // stamped at or before the epoch. Every one of them would be dropped, and the
855
+ // strategy would get an empty ctx.ref() with nothing anywhere saying why.
856
+ const bound = (v) => {
857
+ // Empty string too: `Number('')` is also 0. An empty CSV cell reaching here
858
+ // as '' rather than null is the same bug wearing different clothes.
859
+ if (v == null || v === '') return null;
860
+ const n = Number(v);
861
+ return Number.isFinite(n) ? n : null;
862
+ };
863
+ const openMs = bound(market?.open_ts_ms);
864
+ const closeMs = bound(market?.close_ts_ms);
865
+
866
+
867
+ // One flat, time-ordered list of ref lines across every declared feed.
868
+ //
869
+ // The rows are already sorted, so the window is found by BINARY SEARCH rather
870
+ // than by scanning. It matters at the top end: a submitted series is held for
871
+ // the whole run, and a 90-day BTC 1h scope is 2160 markets — scanning half a
872
+ // million rows for each of them, six times over for the latency curve, turns
873
+ // a linear job into a quadratic one against a 20-minute budget.
874
+ const refs = [];
875
+ for (const [name, rows] of Object.entries(rowsByName ?? {})) {
876
+ const list = rows ?? [];
877
+ // A LAGGED row stamped before the open can still become visible inside the
878
+ // market: declared lag L means a row at t is readable at t+L, so a market
879
+ // opening at 10:00 with L=60s must be sent the row stamped 09:59:30. Slicing
880
+ // from the open dropped it, and the feed's monotone cursor had already
881
+ // walked past it by the next market — invisible for the whole run, with
882
+ // nothing saying so.
883
+ const lag = Number(lags?.[name]) || 0;
884
+ // Rows stamped before the open can still become readable inside it.
885
+ const from = openMs == null ? 0 : lowerBound(list, openMs - lag);
886
+ for (let i = from; i < list.length; i += 1) {
887
+ const r = list[i];
888
+ if (!Number.isFinite(r?.ts_ms)) continue;
889
+ // ORDERED BY WHEN IT BECOMES READABLE, not by when it is stamped.
890
+ //
891
+ // A lagged row is not knowable until ts_ms + lag, and "the future is not
892
+ // in this process" has to mean exactly that: PointInTimeFeed would hide
893
+ // it either way, but queueing it at ts_ms puts it on stdin — and into the
894
+ // harness's array — before the events it is supposed to trail. Filtered
895
+ // is not the same as absent, and absent is the promise.
896
+ const visibleAt = r.ts_ms + lag;
897
+ // Sorted by ts_ms, so lag being constant per feed means visibleAt is
898
+ // sorted too: the first row past the close ends this feed.
899
+ if (closeMs != null && visibleAt > closeMs) break;
900
+ if (openMs != null && visibleAt < openMs) continue;
901
+ refs.push([visibleAt, JSON.stringify({ kind, name, ...r })]);
902
+ }
903
+ }
904
+ if (refs.length === 0) return eventLines;
905
+ refs.sort((a, b) => a[0] - b[0]);
906
+
907
+ const out = [];
908
+ let i = 0;
909
+ for (const line of eventLines) {
910
+ // The event's timestamp, without parsing the whole row: these lines are
911
+ // JSON objects whose ts_ms is a plain number, and a day of book updates is
912
+ // an expensive thing to parse twice.
913
+ const m = /"ts_ms"\s*:\s*(\d+)/.exec(line);
914
+ const ts = m ? Number(m[1]) : Number.POSITIVE_INFINITY;
915
+ // Strictly before: a reference row stamped at the same millisecond as a
916
+ // market event goes AFTER it, so a strategy handling that event cannot
917
+ // already see a bar that closed on the same tick.
918
+ while (i < refs.length && refs[i][0] < ts) out.push(refs[i++][1]);
919
+ out.push(line);
920
+ }
921
+ while (i < refs.length) out.push(refs[i++][1]);
922
+ return out;
923
+ }