outcometick 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
6
6
  An edit made here survives until the next publish and then disappears.
7
7
 
8
- Generated from monorepo revision 5c26d7ed75e2172fff108af183089aa1460652ba.
8
+ Generated from monorepo revision c44df8d3662f109757247e01752e966858db6a05.
9
9
  -->
10
10
 
11
11
  # outcometick
@@ -18,7 +18,7 @@ Predict.fun crypto Up/Down markets.
18
18
  npm i -g outcometick
19
19
 
20
20
  ot check . # validate, free, no data
21
- ot run . --data ./polymarket-data-samples --date … # replay locally
21
+ ot run . --data ./polymarket-data-samples # replay locally
22
22
  ot submit . --assets btc,eth --from … --to … # send it to the queue
23
23
  ot status <run_id> # where it got to
24
24
  ot fetch <run_id> # download the report
@@ -63,7 +63,7 @@ ship in this package rather than being reimplemented client-side.
63
63
  It is the same engine, the same report and the same archive format the queue
64
64
  uses, against a local copy of the archive:
65
65
 
66
- git clone https://github.com/Ligengxin96/polymarket-data-samples
66
+ curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz
67
67
 
68
68
  It is **not** the sandbox. Locally your strategy runs as you, with your
69
69
  privileges, on your machine — which is fine, because it is your code. On our
@@ -15,7 +15,7 @@ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
15
15
  export const SCHEMA_VERSION = 1;
16
16
 
17
17
  /** SDK version reported by the docs page and stamped into every report. */
18
- export const SDK_VERSION = '1.6.2';
18
+ export const SDK_VERSION = '1.6.4';
19
19
 
20
20
  /**
21
21
  * The tag of the sandbox images, and the ONLY place it is written down.
@@ -60,7 +60,7 @@ export const SDK_VERSION = '1.6.2';
60
60
  * forwarded a fourth descriptor, so fd 3 was closed inside the container and no
61
61
  * containerised run had ever returned anything.
62
62
  */
63
- export const SANDBOX_IMAGE_TAG = '1.14.0';
63
+ export const SANDBOX_IMAGE_TAG = '1.15.0';
64
64
 
65
65
  // ---------------------------------------------------------------------------
66
66
  // Languages
@@ -154,12 +154,53 @@ export const DATASETS = Object.freeze({
154
154
  twap30s: 'TWAP over a 30-second lookback.',
155
155
  twap60s: 'TWAP over a 60-second lookback.',
156
156
  book: 'Order-book snapshots and deltas.',
157
+ bbo: 'Unthrottled top of book. Prices only, no sizes — it removes ladder levels the venue has since moved past, and never adds any.',
157
158
  trades: 'Every trade print on the venue.',
158
159
  markets: 'Per-market metadata, strike and settlement outcome.',
159
160
  });
160
161
 
161
162
  export const KNOWN_DATASETS = Object.freeze(Object.keys(DATASETS));
162
163
 
164
+ /**
165
+ * Declarable, but NOT part of the manifest the editor seeds or the prewarm warms.
166
+ *
167
+ * Two reasons, and NOT coverage — see DEGRADING_DATASETS: a range that predates
168
+ * the stream runs fine, so seeding it would not break anything.
169
+ *
170
+ * 1. REPORT CONTINUITY. bbo changes which ladder levels are fillable, so
171
+ * turning it on by default changes the fills of every manifest already
172
+ * written. Reports we have already delivered would stop reproducing, and
173
+ * the customer did not ask for a different book.
174
+ * 2. BANDWIDTH. Archive fetch is the binding constraint (1.9-3.5 MB/s against a
175
+ * 20-minute wall clock), and the busiest series measure +13%~+29% on top of
176
+ * a ~112MB market-day. That is charged to every run, including the ones
177
+ * that would never look at it.
178
+ *
179
+ * The cost of opt-in is that an opted-in run pays a full decode instead of
180
+ * hitting the prewarm, because the prewarm warms the default shape. That is the
181
+ * honest trade: warming both shapes doubles a cache sized in tens of GB.
182
+ */
183
+ export const OPT_IN_DATASETS = Object.freeze(['bbo']);
184
+
185
+ /**
186
+ * Datasets that DEGRADE instead of rejecting when the archive lacks them.
187
+ *
188
+ * The general rule for a captured stream is the opposite — outside its window
189
+ * is E_COVERAGE, never a silent substitution — and that rule is right for
190
+ * anything a strategy READS. `bbo` is different in kind: a strategy never reads
191
+ * it. It refines the order book by deleting levels the venue has since moved
192
+ * past, so a day without it is not a wrong answer, it is the answer this
193
+ * product gave for its whole life before 2026-09-02.
194
+ *
195
+ * So a range that straddles the start of capture runs: the days that have it
196
+ * are refined, the days that do not behave exactly as they did before. What is
197
+ * NOT optional is saying so — `bbo_days` / `bbo_missing_days` in coverage and
198
+ * `bbo_applied` in the report, for the same reason `fill_delay_ms` is written
199
+ * out: two reports that used different books are otherwise identical, and
200
+ * whoever holds the archive has no way to tell which one they have.
201
+ */
202
+ export const DEGRADING_DATASETS = Object.freeze(['bbo']);
203
+
163
204
  /**
164
205
  * A derived stream is computed from one we hold rather than captured. It is
165
206
  * always flagged as derived on every row, and may never be presented as the
@@ -194,6 +235,12 @@ export const CAPTURE_WINDOWS = Object.freeze({
194
235
  twap30s: Object.freeze({ from: '2026-08-07', to: null }),
195
236
  twap60s: Object.freeze({ from: '2026-08-07', to: null }),
196
237
  book: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
238
+ // MEASURED, not the deploy date: collection began 2026-09-02T00:42:18.154Z,
239
+ // so 09-02 is missing its first 42 minutes and every market that opened in
240
+ // them has no top of book at all. Registering 09-02 would accept a run over
241
+ // a day it can only half serve — silent degradation, which is the one thing
242
+ // a stream added mid-archive must not do. 09-03 is the first complete day.
243
+ bbo: Object.freeze({ from: '2026-09-03', to: null }),
197
244
  trades: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
198
245
  markets: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
199
246
  }),
@@ -575,6 +622,12 @@ export const REJECTION_CODES = Object.freeze({
575
622
  E_COVERAGE: 'A captured stream was requested outside the window it was captured in.',
576
623
  E_LIMIT: 'A submission limit was exceeded — file count, total source size or series size.',
577
624
  E_SCOPE: 'The requested venue, asset or date range is not something we can serve.',
625
+ // The only one `ot check` cannot produce: it means the run started and did not
626
+ // finish. Used in eleven places across the API, the CLI and the worker long before
627
+ // it was declared here — so the docs table, which renders these keys, never listed
628
+ // the one code a customer was most likely to be holding when they came to look it up.
629
+ E_RUNTIME: 'The run started but could not finish — the sandbox crashed, the feed to it was'
630
+ + ' cut short, or the replay ended early. Nothing was billed.',
578
631
  });
579
632
 
580
633
  export const KNOWN_REJECTION_CODES = Object.freeze(Object.keys(REJECTION_CODES));
@@ -11,7 +11,8 @@
11
11
  import { classifyPath } from './data-taxonomy.mjs';
12
12
  import {
13
13
  CAPTURE_WINDOWS, DERIVED_DATASETS, KNOWN_DATASETS, BacktestRejection,
14
- MARKET_INTERVALS, DEFAULT_INTERVALS, MAX_LATENCY_MS,
14
+ MARKET_INTERVALS, DEFAULT_INTERVALS, MAX_LATENCY_MS, OPT_IN_DATASETS,
15
+ DEGRADING_DATASETS,
15
16
  } from './backtest-contract.mjs';
16
17
 
17
18
  /**
@@ -28,9 +29,15 @@ const ARCHIVE_DATASETS = Object.freeze({
28
29
  twap30s: Object.freeze(['twap30s']),
29
30
  twap60s: Object.freeze(['twap60s']),
30
31
  book: Object.freeze(['book', 'price_change']),
32
+ bbo: Object.freeze(['best_bid_ask']),
31
33
  trades: Object.freeze(['last_trade_price']),
32
34
  markets: Object.freeze(['markets']),
33
35
  }),
36
+ // Predict.fun has no equivalent stream. Deliberately absent rather than
37
+ // mapped to something close: `archiveDatasetsFor` would silently fetch
38
+ // nothing, and a strategy would get an empty feed with no error. The
39
+ // rejection comes from CAPTURE_WINDOWS.predict having no `bbo` entry, which
40
+ // makes uncapturedRange return the whole range -> E_COVERAGE.
34
41
  predict: Object.freeze({
35
42
  prices: Object.freeze(['prices']),
36
43
  twap30s: Object.freeze(['twap30s']),
@@ -140,6 +147,11 @@ const nextDay = (d) => shiftDay(d, 1);
140
147
  export function assertCoverage({ datasets, venue, from, to }) {
141
148
  for (const ds of datasets ?? []) {
142
149
  if (ds === 'settlement') continue;
150
+ // A degrading dataset never blocks a run: where the archive has it the run
151
+ // is refined, where it does not the run behaves as it always did. The days
152
+ // it covers are reported rather than enforced — see bboCoverage below and
153
+ // DEGRADING_DATASETS for why this one is not like the others.
154
+ if (DEGRADING_DATASETS.includes(ds)) continue;
143
155
  if (DERIVED_DATASETS[ds]) {
144
156
  // A derived stream is a function of one we hold, so its availability is
145
157
  // the SOURCE stream's availability, not its own.
@@ -164,6 +176,76 @@ export function assertCoverage({ datasets, venue, from, to }) {
164
176
  }
165
177
  }
166
178
 
179
+ /**
180
+ * Did this day actually read the archive dataset, judged by what it was decoded
181
+ * from rather than by what was asked for?
182
+ *
183
+ * `inputs` are "<path>:<sha256>" entries recorded by the decoder for every
184
+ * object it really opened. The sha is hex and paths do not contain a colon at
185
+ * the end, so the split is on the LAST one.
186
+ *
187
+ * WHY NOT THE REQUESTED DATASET LIST. A day inside the capture window whose
188
+ * object is simply missing from the catalog — our outage, not the customer's
189
+ * date range — still asks for `best_bid_ask`, decodes fine on the old book, and
190
+ * would be reported as covered. The report would then claim a refinement the
191
+ * replay never had.
192
+ */
193
+ export function inputsInclude(inputs, archiveDataset) {
194
+ return inputKeys(inputs, archiveDataset).size > 0;
195
+ }
196
+
197
+ /**
198
+ * Which MARKET-DAYS an archive dataset was actually read for: `asset|interval`.
199
+ *
200
+ * The day alone is not the unit. A run reads each asset group separately and
201
+ * their inputs are merged, so "this date opened a best_bid_ask object" is true
202
+ * as soon as ONE asset did — a BTC+ETH run where only BTC has the object would
203
+ * replay ETH on the old book and report the whole date as covered. Same
204
+ * false-coverage class as inferring from the capture window, hidden one
205
+ * dimension further in.
206
+ *
207
+ * `asset|interval` rather than the full billing key because the caller already
208
+ * knows which day it is asking about; joined with the day it is exactly the
209
+ * `asset|day|interval` that countMarketDays bills on.
210
+ *
211
+ * A venue-wide object carries no asset (markets metadata does not), so it
212
+ * contributes nothing here — which is right: it is not a per-market-day fact.
213
+ */
214
+ export function inputKeys(inputs, archiveDataset) {
215
+ const out = new Set();
216
+ for (const entry of inputs ?? []) {
217
+ const raw = String(entry);
218
+ // The sha is hex and an object key has no trailing colon, so split on the
219
+ // LAST one — a path that itself contains a colon must still classify.
220
+ const cut = raw.lastIndexOf(':');
221
+ const p = cut > 0 ? raw.slice(0, cut) : raw;
222
+ const c = classifyPath(p);
223
+ if (c.dataset !== archiveDataset) continue;
224
+ if (!c.asset) continue;
225
+ out.add(`${c.asset}|${c.interval ?? 'none'}`);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Split a run's days into the ones a degrading dataset covers and the ones it
232
+ * does not, so the report can state it rather than leave it to be inferred.
233
+ *
234
+ * Deliberately computed from CAPTURE_WINDOWS rather than from which files the
235
+ * worker happened to find: a day inside the window with a missing object is a
236
+ * gap in OUR archive and belongs in the same coverage entry as any other
237
+ * missing object, while a day before the window is not a gap at all — nothing
238
+ * was ever captured. Conflating them would report our outages as the customer's
239
+ * date range being too early.
240
+ *
241
+ * @returns {{covered: string[], missing: string[]}}
242
+ */
243
+ export function degradingCoverage(venue, dataset, days) {
244
+ const covered = [], missing = [];
245
+ for (const d of days ?? []) (isCaptured(venue, dataset, d) ? covered : missing).push(d);
246
+ return { covered, missing };
247
+ }
248
+
167
249
  /**
168
250
  * The archive dataset names a run must fetch for a given manifest.
169
251
  *
@@ -191,6 +273,34 @@ export function archiveDatasetsFor({ datasets, venue, from, to }) {
191
273
  return [...out].sort();
192
274
  }
193
275
 
276
+ /**
277
+ * The archive datasets to read FOR ONE DAY.
278
+ *
279
+ * Identical to archiveDatasetsFor for everything that is not degrading. The
280
+ * difference is the whole point: a degrading dataset is dropped on a day its
281
+ * capture window does not cover, so the files are never fetched, never decoded
282
+ * and never applied.
283
+ *
284
+ * WITHOUT THIS the range-level list is used for every day, and a run spanning
285
+ * the start of capture reads a PARTIAL day's file and applies it — while
286
+ * `bboCoverage` reports that same day as missing, because it asks
287
+ * CAPTURE_WINDOWS. The report would then state that a day ran on the old
288
+ * behaviour while it actually ran on a half-covered book. 2026-09-02 is exactly
289
+ * that day: capture began at 00:42:18Z, so markets opening before then get no
290
+ * refinement and markets after do — a density change inside one day, which is
291
+ * the thing the book cadence is carefully arranged never to produce.
292
+ *
293
+ * The range is still passed through rather than collapsed to the day, because
294
+ * `settlement` expands against the whole range and narrowing that here would
295
+ * change which streams a day fetches for reasons unrelated to this.
296
+ */
297
+ export function archiveDatasetsForDay({ datasets, venue, day, from, to }) {
298
+ const usable = (datasets ?? []).filter(
299
+ (d) => !DEGRADING_DATASETS.includes(d) || isCaptured(venue, d, day),
300
+ );
301
+ return archiveDatasetsFor({ datasets: usable, venue, from, to });
302
+ }
303
+
194
304
  /**
195
305
  * The settlement files a day's markets need, beyond what the strategy asked for.
196
306
  *
@@ -345,5 +455,28 @@ export function normalizeDatasets(list) {
345
455
  */
346
456
  const SETTLEMENT_STREAMS = new Set(['prices', 'twap30s', 'twap60s']);
347
457
  export const PREWARM_DATASETS = Object.freeze(
348
- KNOWN_DATASETS.filter((d) => !SETTLEMENT_STREAMS.has(d)),
458
+ KNOWN_DATASETS.filter((d) => !SETTLEMENT_STREAMS.has(d) && !OPT_IN_DATASETS.includes(d)),
349
459
  );
460
+
461
+ /**
462
+ * EVERY dataset shape worth warming: the default one, plus the default one with
463
+ * each opt-in dataset added.
464
+ *
465
+ * A decoded day is cached per SHAPE, so warming only the default leaves every
466
+ * opted-in run paying the full decode — the timer stays green, the cache grows,
467
+ * and the one customer who asked for more waits longest. That is the failure
468
+ * this file already documents for the default shape; an opt-in dataset just
469
+ * moves it one step along.
470
+ *
471
+ * The cost is bounded and mostly imaginary: on a day the opt-in dataset is not
472
+ * captured, `archiveDatasetsForDay` drops it, both shapes normalise to the same
473
+ * archive files, and the second warm is a cache HIT rather than a second copy.
474
+ * Only days that actually carry it are stored twice.
475
+ *
476
+ * Derived, so a dataset added to the product is warmed without anyone
477
+ * remembering to come here.
478
+ */
479
+ export const PREWARM_SHAPES = Object.freeze([
480
+ PREWARM_DATASETS,
481
+ ...OPT_IN_DATASETS.map((d) => Object.freeze([...PREWARM_DATASETS, d])),
482
+ ]);
@@ -19,6 +19,7 @@ export const DATASETS = {
19
19
  twap30s: 'TWAP 30s settlement stream — settled 5-minute markets before they moved to the 60s lookback; still archived daily',
20
20
  twap60s: 'TWAP 60s settlement stream — settles both 5-minute and 15-minute markets',
21
21
  book: 'Full-depth order-book snapshots',
22
+ best_bid_ask: 'Top of book, unthrottled — the same best bid/ask price_change carries, at every update rather than the capture cadence; prices only, no sizes, so depth still needs book or price_change',
22
23
  price_change: 'Order-book deltas with best bid/ask',
23
24
  last_trade_price: 'Every trade print',
24
25
  markets: 'Per-market metadata, strike and settlement outcome',
@@ -59,13 +60,35 @@ export function classifyPath(filePath) {
59
60
  return { venue, dataset: 'klines', asset: assetOf(segs[3]), interval: num(segs[4]), ext };
60
61
  }
61
62
  if (ds === 'orderbook') {
62
- // BTC-5M / BTC-15M / MARKET-<id>
63
- const m = /^([A-Za-z]+)-(\d+[mMhHdD]|DAILY)$/.exec(segs[3] ?? '');
63
+ // BTC-5M / BTC-15M / BTC-HOURLY / BTC-DAILY / BTC-OTHER / MARKET-<id>
64
+ //
65
+ // Predict names its hourly and daily series by word rather than by
66
+ // duration. Both are mapped onto the vocabulary every other dataset
67
+ // already uses, because the alternative cost customers real data twice
68
+ // over:
69
+ //
70
+ // HOURLY matched neither branch of the earlier pattern, so the whole
71
+ // series classified to interval:null. `interval=1h` returned an empty
72
+ // list — no error, just nothing — while the files sat in the archive,
73
+ // and /v1/meta never named the value at all, so a customer building an
74
+ // enumeration from it could not learn the data existed.
75
+ //
76
+ // DAILY did match, but produced `daily`: a value no duration parser
77
+ // accepts, that sorts after 1mo because sortIntervals cannot read it,
78
+ // and that shares one array with the klines' own `1d` while meaning
79
+ // the same span.
80
+ //
81
+ // The list stays a whitelist. Widening the second group to \w+ would
82
+ // turn BTC-OTHER into interval:'other' — inventing a period for the
83
+ // series that exists precisely because its period is unknown.
84
+ const BY_WORD = { HOURLY: '1h', DAILY: '1d' };
85
+ const m = /^([A-Za-z]+)-(\d+[mMhHdD]|HOURLY|DAILY)$/.exec(segs[3] ?? '');
86
+ const period = m?.[2];
64
87
  return {
65
88
  venue,
66
89
  dataset: 'orderbook',
67
90
  asset: assetOf(m?.[1] ?? segs[3]),
68
- interval: m ? m[2].toLowerCase() : null,
91
+ interval: period ? (BY_WORD[period.toUpperCase()] ?? period.toLowerCase()) : null,
69
92
  ext,
70
93
  };
71
94
  }
@@ -21,12 +21,13 @@ import { fileURLToPath } from 'node:url';
21
21
  import { LANGUAGES, HOOK_NAMES, LIMITS } from '../../api/lib/backtest-contract.mjs';
22
22
  import { CHANNEL, EXIT, parseTrade, parseFill, parseResult, parseOutputLine } from '../../runner/harness/protocol.mjs';
23
23
  import {
24
- countMarketDays, countStreams, buildCoverage, mergeReferenceRows, makeBookThrottle,
24
+ countMarketDays, countStreams, buildCoverage, bboCoverage, mergeReferenceRows, makeBookThrottle,
25
25
  sortMarketsForReplay,
26
26
  } from '../../runner/events.mjs';
27
27
  import { loadSeries } from '../../runner/series-data.mjs';
28
28
  import { buildReport } from '../../runner/engine/report.mjs';
29
29
  import { buildArchive } from '../../runner/archive.mjs';
30
+ import { createLineWriter } from '../../runner/stdin-writer.mjs';
30
31
  import { loadLocalDay, localDays, looksLikeArchive } from '../local-data.mjs';
31
32
  import { readSubmission, validate } from '../ot.mjs';
32
33
 
@@ -72,29 +73,50 @@ function runHarness({
72
73
  }
73
74
  });
74
75
  child.on('error', reject);
75
- child.on('close', (code) => resolve({ code, stderr, lines, forged }));
76
+ child.on('close', (code) => {
77
+ // A short feed that still exited 0 is the dangerous case: the harness
78
+ // replayed whatever reached it, reported cleanly, and the report looks
79
+ // complete. It must not be resolved as a successful run. When the harness
80
+ // died first the pipe breaks as a CONSEQUENCE, and its own exit code and
81
+ // stderr say more than the EPIPE does — so let that path through
82
+ // unchanged and let the caller report the real failure.
83
+ if (streamError && code === EXIT.ok) { reject(streamError); return; }
84
+ resolve({ code, stderr, lines, forged });
85
+ });
76
86
 
77
- child.stdin.on('error', () => {});
78
- child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
79
- for (const m of markets) {
80
- // Series rows are INTERLEAVED into the same stream in event time, exactly
81
- // as the worker sends them and `lags` travels with them, or a signal
82
- // that declared a publication delay would be visible the instant its row
83
- // was stamped rather than when it could have existed.
84
- const lines = m.events.map((ev) => JSON.stringify(ev));
85
- const merged = seriesNames.length
86
- ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
87
- : lines;
88
- child.stdin.write(`${JSON.stringify({
89
- market: m.market,
90
- stream: m.stream,
91
- n: merged.length,
92
- ...(seriesNames.length ? { series: seriesNames } : {}),
93
- ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
94
- })}\n`);
95
- for (const line of merged) child.stdin.write(`${line}\n`);
96
- }
97
- child.stdin.end();
87
+ // THE SAME writer the queue uses (runner/stdin-writer.mjs). This loop used
88
+ // to ignore what write() returned and swallow every stdin error, so once
89
+ // the pipe's buffer filled the rows simply stopped arriving: a 289-market
90
+ // day came back as a 2-market report, exit 0, no warning. `ot run` and the
91
+ // worker have drifted eight times; sharing the writer is how this one stops
92
+ // being a ninth.
93
+ let streamError = null;
94
+ const write = createLineWriter(child.stdin);
95
+ (async () => {
96
+ await write(JSON.stringify({ ...job, outputKey }));
97
+ for (const m of markets) {
98
+ // Series rows are INTERLEAVED into the same stream in event time,
99
+ // exactly as the worker sends them — and `lags` travels with them, or a
100
+ // signal that declared a publication delay would be visible the instant
101
+ // its row was stamped rather than when it could have existed.
102
+ const lines = m.events.map((ev) => JSON.stringify(ev));
103
+ const merged = seriesNames.length
104
+ ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
105
+ : lines;
106
+ await write(JSON.stringify({
107
+ market: m.market,
108
+ stream: m.stream,
109
+ n: merged.length,
110
+ ...(seriesNames.length ? { series: seriesNames } : {}),
111
+ ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
112
+ }));
113
+ for (const line of merged) await write(line);
114
+ }
115
+ child.stdin.end();
116
+ })().catch((err) => {
117
+ streamError = err;
118
+ child.stdin.destroy();
119
+ });
98
120
  });
99
121
  }
100
122
 
@@ -127,11 +149,13 @@ function demux(lines) {
127
149
  export async function cmdRun({ dir, flags }) {
128
150
  const dataRoot = flags.data;
129
151
  if (!dataRoot) {
130
- throw new Error('--data is required: point it at a cloned sample archive\n'
131
- + ' git clone https://github.com/Ligengxin96/polymarket-data-samples');
152
+ throw new Error('--data is required: point it at an unpacked sample archive\n'
153
+ + ' curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz');
132
154
  }
133
155
  if (!await looksLikeArchive(dataRoot)) {
134
- throw new Error(`${path.resolve(dataRoot)} does not look like an archive — no recognisable data files under it`);
156
+ throw new Error(`${path.resolve(dataRoot)} does not look like an archive — no recognisable data files under it\n`
157
+ + ' the sample archive is a release download, not the git repository:\n'
158
+ + ' curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz');
135
159
  }
136
160
 
137
161
  const files = await readSubmission(dir);
@@ -196,6 +220,7 @@ export async function cmdRun({ dir, flags }) {
196
220
  // been covered while agreeing about everything else. That is the harder
197
221
  // discrepancy to notice, because the report looks complete.
198
222
  const missing = [];
223
+ const bboApplied = new Map(); // day -> Set('<ASSET>|<interval>')
199
224
  for (const day of days) {
200
225
  const loaded = await loadLocalDay({
201
226
  root: dataRoot, day, venue,
@@ -212,6 +237,13 @@ export async function cmdRun({ dir, flags }) {
212
237
  to: days[days.length - 1],
213
238
  }),
214
239
  });
240
+ // Measured from EVENTS, keyed by market-day — the same fact the queue
241
+ // records, produced by the same decoder. See fetchDay's bboKeys.
242
+ if (loaded.bboKeys?.length) {
243
+ const acc = bboApplied.get(day) ?? new Set();
244
+ for (const k of loaded.bboKeys) acc.add(k);
245
+ bboApplied.set(day, acc);
246
+ }
215
247
  if (loaded.markets.length === 0) {
216
248
  process.stderr.write(` ${day}: ${loaded.reason}\n`);
217
249
  missing.push({
@@ -341,6 +373,27 @@ export async function cmdRun({ dir, flags }) {
341
373
  }
342
374
 
343
375
  const base = passes[0];
376
+ // A SHORT REPLAY MUST FAIL EVEN WHEN NOTHING REPORTED AN ERROR.
377
+ //
378
+ // The backpressure bug produced exactly that shape: every write "succeeded",
379
+ // the harness exited 0, and 2 of 289 markets came back as a clean, complete
380
+ // looking report. Fixing the writer closes the cause we found; counting what
381
+ // came back is what catches the next one, whatever it turns out to be.
382
+ //
383
+ // `markets_run` is incremented by the harness only after a market is fully
384
+ // replayed, so on a clean exit it equals what was fed. A rejected or
385
+ // over-budget run never reaches here — those exit non-zero and are raised
386
+ // above with the sandbox's own reason, which says more than this count.
387
+ if (base.result.marketsRun < markets.length) {
388
+ const err = new Error(
389
+ `only ${base.result.marketsRun} of ${markets.length} market(s) were replayed —`
390
+ + ' the report would be incomplete, so none was written.'
391
+ + ' This usually means the feed to the runner was cut short.',
392
+ );
393
+ err.code = 'E_RUNTIME';
394
+ err.detail = err.message;
395
+ throw err;
396
+ }
344
397
  const marketMeta = new Map(markets.map((m) => [m.market.market_id, {
345
398
  market_id: m.market.market_id,
346
399
  asset: m.market.asset,
@@ -395,6 +448,7 @@ export async function cmdRun({ dir, flags }) {
395
448
  referenceDeclared: [],
396
449
  streams: countStreams([...marketMeta.values()]),
397
450
  droppedRows: base.malformed ?? 0,
451
+ ...bboCoverage({ venue, datasets: manifest.datasets, markets, applied: bboApplied }),
398
452
  local: true,
399
453
  source: path.resolve(dataRoot),
400
454
  }),
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
 
21
21
  import { classifyPath } from '../api/lib/data-taxonomy.mjs';
22
22
  import {
23
- archiveDatasetsFor, fileMatchesRun, normalizeIntervals, settlementPathsFor, orderedFeed,
23
+ archiveDatasetsForDay, fileMatchesRun, normalizeIntervals, settlementPathsFor, orderedFeed,
24
24
  } from '../api/lib/backtest-datasets.mjs';
25
25
  import {
26
26
  indexMarkets, eventsFromRow, finaliseMarket, parseRow, buildSlugIndex, marketUnusable,
@@ -115,7 +115,10 @@ export function dayOfPath(rel) {
115
115
  * the harness identically.
116
116
  */
117
117
  export async function loadLocalDay({ root, day, venue, assets, datasets, intervals, throttle = null }) {
118
- const archiveDatasets = archiveDatasetsFor({ datasets, venue, from: day, to: day });
118
+ // Day-scoped, and degrading datasets gated by their capture window — the
119
+ // same call the queue makes, because `ot run` and the worker have drifted
120
+ // apart nine times and every one of them was a rule computed twice.
121
+ const archiveDatasets = archiveDatasetsForDay({ datasets, venue, day, from: day, to: day });
119
122
  // Same normalisation, same default, same two filters as the queue. `ot run`
120
123
  // promises the identical files and checksums; an interval narrowing applied
121
124
  // on one side only would break that on the very first 15m market.
@@ -160,11 +163,18 @@ export async function loadLocalDay({ root, day, venue, assets, datasets, interva
160
163
  all.filter((rel) => dayOfPath(rel) === day), { venue, assets, already: wanted })]);
161
164
 
162
165
  const byMarket = new Map();
166
+ const bboSeen = new Set(); // '<ASSET>|<interval>' that produced a usable bound
163
167
  for (const rel of feed) {
164
168
  if (classifyPath(rel).dataset === 'markets') continue;
165
169
  for await (const row of readRows(root, rel)) {
166
170
  for (const [id, ev] of eventsFromRow(rel, row, markets, bySlug, throttle)) {
167
171
  if (!markets.has(id)) continue;
172
+ // Same fact, same source as the queue: an EVENT, not a file. See
173
+ // fetchDay's bboKeys.
174
+ if (ev.bbo) {
175
+ const mk = markets.get(id);
176
+ if (mk?.asset) bboSeen.add(`${String(mk.asset).toUpperCase()}|${mk.interval ?? 'none'}`);
177
+ }
168
178
  let list = byMarket.get(id);
169
179
  if (!list) { list = []; byMarket.set(id, list); }
170
180
  list.push(ev);
@@ -217,6 +227,11 @@ export async function loadLocalDay({ root, day, venue, assets, datasets, interva
217
227
  // one promise `ot run` makes: the identical files from the identical
218
228
  // archive.
219
229
  markets: sortMarketsForReplay(out),
230
+ // Every file this day was actually read from, the same field the queue
231
+ // records — coverage states which days really read the top-of-book stream,
232
+ // and it has to answer that the same way on both sides.
233
+ inputs: feed,
234
+ bboKeys: [...bboSeen].sort(),
220
235
  unusable,
221
236
  reason: out.length === 0 && unusable.length
222
237
  ? `${unusable.length} market(s) unusable: ${unusable[0].why}`
package/cli/ot.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // `ot` — the command line the SDK docs tell customers to use.
3
3
  //
4
4
  // ot check . validate, free, no data
5
- // ot run . --data ./polymarket-data-samples --date … replay locally
5
+ // ot run . --data ./polymarket-data-samples replay locally
6
6
  // ot submit . --assets btc,eth --from … --to … send it to the queue
7
7
  //
8
8
  // The one thing this file must get right is that `ot check` runs the SAME
@@ -57,7 +57,7 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
57
57
  --api <url> API base (default https://outcometick.com)
58
58
 
59
59
  Free sample data:
60
- git clone https://github.com/Ligengxin96/polymarket-data-samples
60
+ curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz
61
61
  `;
62
62
 
63
63
  /** Parse argv into {command, dir, flags}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "Strategy SDK and CLI for outcometick prediction-market backtests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,15 +57,21 @@ class Ladder {
57
57
  #order(a, b) { return this.dir > 0 ? a - b : b - a; }
58
58
 
59
59
  /** Replace the whole ladder (a snapshot). */
60
- reset(levels) {
60
+ reset(levels, ts = 0) {
61
61
  this.levels = (levels ?? [])
62
- .map(([px, size]) => ({ ticks: toTicks(px), size: Number(size) }))
62
+ .map(([px, size]) => ({ ticks: toTicks(px), size: Number(size), ts }))
63
63
  .filter((l) => l.size > 0 && Number.isFinite(l.ticks))
64
64
  .sort((a, b) => this.#order(a.ticks, b.ticks));
65
65
  }
66
66
 
67
- /** Apply one delta. A size of zero removes the level. */
68
- apply(px, size) {
67
+ /**
68
+ * Apply one delta. A size of zero removes the level.
69
+ *
70
+ * `ts` is carried on the level itself so `prune` can tell a level the venue
71
+ * has moved past from one that arrived in the same millisecond as the bound
72
+ * about to delete it. See prune.
73
+ */
74
+ apply(px, size, ts = 0) {
69
75
  const ticks = toTicks(px);
70
76
  const n = Number(size);
71
77
  const i = this.levels.findIndex((l) => l.ticks === ticks);
@@ -73,10 +79,63 @@ class Ladder {
73
79
  if (i >= 0) this.levels.splice(i, 1);
74
80
  return;
75
81
  }
76
- if (i >= 0) { this.levels[i].size = n; return; }
82
+ if (i >= 0) { this.levels[i].size = n; this.levels[i].ts = ts; return; }
77
83
  let j = this.levels.length;
78
84
  while (j > 0 && this.#order(this.levels[j - 1].ticks, ticks) > 0) j -= 1;
79
- this.levels.splice(j, 0, { ticks, size: n });
85
+ this.levels.splice(j, 0, { ticks, size: n, ts });
86
+ }
87
+
88
+ /**
89
+ * Delete every level STRICTLY BETTER than `bound` that is older than `ts`.
90
+ *
91
+ * This is the whole of what the unthrottled top-of-book stream is allowed to
92
+ * do. That stream carries prices and no sizes, so it can state what is NOT on
93
+ * the ladder — "nothing better than this exists right now" — and can never
94
+ * state what is. Adding a level from it would be liquidity invented without a
95
+ * size, which is the class of bug this engine has had to fix five times.
96
+ *
97
+ * WHY IT MATTERS AT ALL: `take()` eats from the best end, and the delta stream
98
+ * that maintains this ladder is thinned to the venue's capture cadence (500ms
99
+ * for most assets). So the levels that are stalest are exactly the ones an
100
+ * order hits first, and they are stale in one direction only — a price that
101
+ * has since been taken still looks available. That is a systematic bias in the
102
+ * strategy's favour, which is the opposite of this file's stated bias.
103
+ *
104
+ * OLDER THAN, not "at or older than", and that is the point of carrying `ts`
105
+ * per level. A delta and a bound stamped the same millisecond contradict each
106
+ * other and the archive does not say which came first; leaving it to arrival
107
+ * order would make the result depend on how the reader happened to sort a tie.
108
+ * Requiring the level to be strictly older makes the outcome the same either
109
+ * way.
110
+ *
111
+ * Levels are best-first, so everything better than the bound is a prefix —
112
+ * but the age test is not prefix-aligned, so the prefix is filtered rather
113
+ * than sliced.
114
+ */
115
+ prune(bound, ts) {
116
+ // Not just finite: inside [0, 1]. An outcome token pays 0 or 1, so a bound
117
+ // outside that is a row we cannot read, and a bound is a MAXIMAL deletion
118
+ // instruction — acting on a misread one empties the ladder and the market
119
+ // silently stops filling. The decoder already refuses these; this is the
120
+ // second door, and it is what makes the engines, the Python engine and
121
+ // scripts/audit-report.py agree on the same rule rather than three
122
+ // slightly different ones.
123
+ // A NUMBER, not something that parses as one. `Number.isFinite('0.45')` is
124
+ // false because it does not coerce, while Python's `float('0.45')` is 0.45
125
+ // — so accepting strings on one side would make the same event prune in one
126
+ // engine and not the other. Both refuse anything that is not already a
127
+ // number, which is what the decoder emits.
128
+ if (typeof bound !== 'number' || !Number.isFinite(bound)) return 0;
129
+ if (bound < 0 || bound > 1) return 0;
130
+ const cap = toTicks(bound);
131
+ let removed = 0;
132
+ const kept = [];
133
+ for (const l of this.levels) {
134
+ if (this.#order(l.ticks, cap) < 0 && l.ts < ts) { removed += 1; continue; }
135
+ kept.push(l);
136
+ }
137
+ if (removed) this.levels = kept;
138
+ return removed;
80
139
  }
81
140
 
82
141
  /** Best resting price, or null when empty. */
@@ -138,8 +197,8 @@ export class Book {
138
197
  for (const side of SIDES) {
139
198
  const l = levels?.[side];
140
199
  if (!l) continue;
141
- this.ladders[side].asks.reset(l.asks);
142
- this.ladders[side].bids.reset(l.bids);
200
+ this.ladders[side].asks.reset(l.asks, ts);
201
+ this.ladders[side].bids.reset(l.bids, ts);
143
202
  }
144
203
  }
145
204
 
@@ -147,7 +206,29 @@ export class Book {
147
206
  this.ts = ts;
148
207
  if (!isSide(side)) throw new Error(`unknown side ${side}`);
149
208
  if (kind !== 'asks' && kind !== 'bids') throw new Error(`unknown ladder ${kind}`);
150
- this.ladders[side][kind].apply(px, size);
209
+ this.ladders[side][kind].apply(px, size, ts);
210
+ }
211
+
212
+ /**
213
+ * Apply an unthrottled top-of-book bound: the venue says nothing better than
214
+ * this rests on either ladder right now.
215
+ *
216
+ * DELETES ONLY. `bid` and `ask` are prices with no size behind them, so they
217
+ * can shrink the book and never grow it.
218
+ *
219
+ * 0 and 1 need no special case. `bid = 0` is how the venue writes "no bid",
220
+ * and deleting every bid strictly better than 0 deletes all of them, which is
221
+ * exactly what it means. Its complement is `ask = 1` on the other token of the
222
+ * same market, since UP + DOWN = 1 — measured as an exact pairing in the
223
+ * archive, count for count.
224
+ *
225
+ * @returns {number} levels removed, for the caller that reports it
226
+ */
227
+ bbo(ts, side, bid, ask) {
228
+ this.ts = ts;
229
+ if (!isSide(side)) throw new Error(`unknown side ${side}`);
230
+ const l = this.ladders[side];
231
+ return l.asks.prune(ask, ts) + l.bids.prune(bid, ts);
151
232
  }
152
233
 
153
234
  /**
@@ -571,14 +571,35 @@ export function replayMarket({
571
571
  control.setNow(ev.ts_ms);
572
572
 
573
573
  if (ev.kind === 'book') {
574
- if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
574
+ // BEFORE the snapshot test, because a bound carries snapshot:false and
575
+ // would otherwise be applied as a delta with no ladder, no price and no
576
+ // size — which Book.delta rejects by throwing, taking the whole run with
577
+ // it.
578
+ if (ev.bbo) book.bbo(ev.ts_ms, ev.side, ev.bid, ev.ask);
579
+ else if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
575
580
  else book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
576
581
  }
577
582
  drainUntil(ev.ts_ms);
578
583
 
579
584
  if (ev.kind === 'tick') control.pushTick(ev);
580
585
 
581
- const hook = HOOK_FOR[ev.kind];
586
+ // A BOUND REFINES THE BOOK SILENTLY. Three reasons it must not reach a hook,
587
+ // and the first one alone is enough:
588
+ //
589
+ // - The event has no `levels`, `ladder`, `px` or `size`. Handing it to
590
+ // on_book gives a documented SDK input a shape no documentation
591
+ // describes, and a strategy reading ev.levels gets undefined.
592
+ // - This stream is UNTHROTTLED — 1.3M rows in a day of BTC-5m against a
593
+ // price_change stream thinned to 20-500ms. Firing a hook on each would
594
+ // multiply hook invocations several-fold inside a 20-minute wall clock,
595
+ // and a run that times out is refunded in full at our cost.
596
+ // - Nothing is lost by staying quiet: ctx.book() is live, so the next real
597
+ // event already sees the refined ladder. Pruning only ever REMOVES
598
+ // liquidity, so not waking a strategy cannot cost it an opportunity that
599
+ // existed — which is the direction this engine resolves ambiguity in.
600
+ //
601
+ // MUST MATCH otreplay.py. Both engines or neither.
602
+ const hook = ev.bbo ? null : HOOK_FOR[ev.kind];
582
603
  if (hook && hooks[hook]) emit(call(hook, ev), ev.ts_ms);
583
604
 
584
605
  if (monitor.breached) {
package/runner/events.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // if local and remote decode the archive differently.
12
12
 
13
13
  import { classifyPath } from '../api/lib/data-taxonomy.mjs';
14
- import { resolveSettlementStream } from '../api/lib/backtest-datasets.mjs';
14
+ import { resolveSettlementStream, degradingCoverage, inputKeys } from '../api/lib/backtest-datasets.mjs';
15
15
  import { bookThrottleMs } from '../api/lib/backtest-contract.mjs';
16
16
  import { Book } from './engine/book.mjs';
17
17
 
@@ -465,6 +465,58 @@ export function eventsFromRow(filePath, row, markets, bySlug = null, throttle =
465
465
  }]];
466
466
  }
467
467
 
468
+ if (meta.dataset === 'best_bid_ask') {
469
+ // TOP OF BOOK AS A BOUND, not as a quote.
470
+ //
471
+ // This stream carries prices and no sizes, so it can never say what IS on
472
+ // the ladder — only what is NOT: "nothing better than this exists right
473
+ // now". The engine uses it to delete levels the venue has moved past and
474
+ // never to add one, because a level invented without a size is exactly the
475
+ // fabricated liquidity this product has had to fix five times.
476
+ //
477
+ // Emitted as kind:'book' deliberately: on_book already fires for the book,
478
+ // a strategy reads the refined ladder rather than this row, and no new hook
479
+ // or SDK surface appears. `bbo` marks it as a bound so the engine does not
480
+ // mistake it for a delta with a missing size.
481
+ const side = sideOfToken(market, row.asset_id ?? payload.asset_id);
482
+ if (!side) return [];
483
+ const bid = num(payload.best_bid);
484
+ const ask = num(payload.best_ask);
485
+ // MEASURED, over 900,000 rows across BTC-5m, SOL-15m and DOGE-5m on
486
+ // 2026-09-04: zero crossed, zero equal, zero outside [0,1], zero
487
+ // unparseable. So none of these guards fire on today's archive — they are
488
+ // here because the failure they prevent is silent and total. A garbage
489
+ // bound is a MAXIMAL deletion instruction: one unreadable row would empty a
490
+ // ladder, the market would stop filling, and the run would come back with
491
+ // an honest-looking report of a strategy that could not trade.
492
+ //
493
+ // Fail-safe rather than fail-closed, and that is the one place this differs
494
+ // from the rest of this file: dropping a bbo row costs nothing but the
495
+ // refinement, leaving the book exactly as it was before 2026-09-02. There
496
+ // is no wrong answer to propagate, so the market is not dropped.
497
+ if (bid == null || ask == null) return [];
498
+ if (bid < 0 || bid > 1 || ask < 0 || ask > 1) return [];
499
+ if (bid > ask) return [];
500
+ // 0 and 1 need NO special case, which is why the rule is phrased as a
501
+ // bound. `best_bid = "0"` is how the venue writes "no bid", and deleting
502
+ // every bid strictly better than 0 deletes all of them — correct. Its
503
+ // complement is `best_ask = "1"` on the other token of the same market,
504
+ // because UP + DOWN = 1: measured as an exact pairing, 3228 `bid=0 ask=0.01`
505
+ // against 3228 `bid=0.99 ask=1`, every count matching. An earlier version of
506
+ // this rule treated [0.001, 0.999] as the valid domain and would have
507
+ // thrown away precisely those rows — the ones carrying the MOST definite
508
+ // information about the book.
509
+ return [[id, {
510
+ kind: 'book',
511
+ ts_ms: ts,
512
+ snapshot: false,
513
+ bbo: true,
514
+ side,
515
+ bid,
516
+ ask,
517
+ }]];
518
+ }
519
+
468
520
  if (meta.dataset === 'price_change') {
469
521
  // A delta carries a batch, each entry naming its own token and ladder side.
470
522
  const changes = Array.isArray(payload.price_changes) ? payload.price_changes : [];
@@ -546,6 +598,21 @@ export function buildCoverage({
546
598
  streams = {},
547
599
  droppedRows = 0,
548
600
  unreconciledRows = 0,
601
+ /**
602
+ * Did the manifest declare `bbo`, and on which of the run's days did the
603
+ * archive actually have it?
604
+ *
605
+ * Always emitted, even when nothing declared it, because these three keys are
606
+ * the only place a reader can tell which book a report was computed against.
607
+ * `bbo` refines the ladder by deleting levels the venue has moved past, and a
608
+ * run over 2026-06-10 and a run over 2026-09-04 both succeed while using two
609
+ * different books. Same reason `fill_delay_ms` is written out: without it,
610
+ * whoever holds the archive cannot tell which of the two they have.
611
+ */
612
+ bboDeclared = false,
613
+ bboDays = [],
614
+ bboMissingDays = [],
615
+ bboPartialDays = [],
549
616
  local = false,
550
617
  source = null,
551
618
  }) {
@@ -568,11 +635,81 @@ export function buildCoverage({
568
635
  // Rows the harness produced that do not describe a market the caller
569
636
  // supplied. Published rather than swallowed.
570
637
  unreconciled_rows: unreconciledRows,
638
+ // Which book this report was computed against. `bbo_missing_days` is not an
639
+ // error and is not a gap in the archive for the days before 2026-09-02 —
640
+ // nothing was ever captured then. It is the list of days that ran exactly
641
+ // as this product ran for its whole life before that stream existed.
642
+ bbo_declared: bboDeclared,
643
+ bbo_days: bboDays,
644
+ bbo_missing_days: bboMissingDays,
645
+ // Dates where SOME market-days got the refinement and some did not, each
646
+ // naming the `asset|interval` that did not. A date is never in more than
647
+ // one of these three, and the three together are every date scanned.
648
+ bbo_partial_days: bboPartialDays,
571
649
  // Local-only, and last: a queued run has no source directory to name.
572
650
  ...(local ? { local: true, source } : {}),
573
651
  };
574
652
  }
575
653
 
654
+ /**
655
+ * Which of a run's days the unthrottled top-of-book stream actually covered.
656
+ *
657
+ * SHARED, for the reason every predicate in this file is shared: `ot run` and
658
+ * the queue have drifted apart nine times, always by each computing a rule the
659
+ * other also computes. This one decides what a report SAYS about itself, so a
660
+ * second copy would let the local run and the queue describe the same archive
661
+ * differently.
662
+ *
663
+ * Days come from the markets actually scanned rather than from the requested
664
+ * range, so the answer describes the run that happened.
665
+ *
666
+ * `bbo_missing_days` is not an error and not a gap in our archive: before
667
+ * 2026-09-02 nothing was captured, so those days ran exactly as this product
668
+ * ran for its whole life before the stream existed. Reported anyway, because
669
+ * two runs that used different books are otherwise indistinguishable — the same
670
+ * reason `fill_delay_ms` is written out.
671
+ */
672
+ export { inputKeys };
673
+
674
+ export function bboCoverage({ venue, datasets, markets, applied = null }) {
675
+ if (!(datasets ?? []).includes('bbo')) {
676
+ return { bboDeclared: false, bboDays: [], bboMissingDays: [], bboPartialDays: [] };
677
+ }
678
+ // MEASURED OR NOTHING. There used to be a fallback that inferred coverage
679
+ // from CAPTURE_WINDOWS when no measurement was passed, and that is fail-OPEN
680
+ // for precisely the bug this function exists to prevent: a caller added later
681
+ // — or a refactor that drops an argument — would silently go back to claiming
682
+ // a refinement the replay never had. The window says what SHOULD have been
683
+ // possible; only the decode knows what happened.
684
+ if (!applied) {
685
+ throw new Error('bboCoverage: `applied` is required when the manifest declares bbo '
686
+ + '— coverage must be measured from what was decoded, never inferred from the capture window');
687
+ }
688
+
689
+ // THE UNIT IS THE MARKET-DAY, the same `asset|day|interval` the run is billed
690
+ // on. Collapsing to the date makes one asset speak for every asset.
691
+ const byDay = new Map();
692
+ for (const m of markets ?? []) {
693
+ const day = m?.day;
694
+ if (!day) continue;
695
+ if (!byDay.has(day)) byDay.set(day, new Set());
696
+ byDay.get(day).add(`${m.market?.asset ?? 'unknown'}|${m.market?.interval ?? 'none'}`);
697
+ }
698
+
699
+ const full = [], none = [], partial = [];
700
+ for (const day of [...byDay.keys()].sort()) {
701
+ const scanned = [...byDay.get(day)];
702
+ const got = applied.get(day) ?? new Set();
703
+ const without = scanned.filter((k) => !got.has(k)).sort();
704
+ if (without.length === 0) full.push(day);
705
+ else if (without.length === scanned.length) none.push(day);
706
+ // NAMED, not counted. "Some of 2026-09-04 ran on the old book" is not
707
+ // actionable; "ETH|5m did" is. Same reason uncapturedRange returns the gap.
708
+ else partial.push({ day, without });
709
+ }
710
+ return { bboDeclared: true, bboDays: full, bboMissingDays: none, bboPartialDays: partial };
711
+ }
712
+
576
713
  /**
577
714
  * The billing unit: one asset on one UTC day.
578
715
  *
@@ -781,7 +918,15 @@ export function finaliseMarket(events, market) {
781
918
  let downPx = null;
782
919
  for (const ev of inWindow) {
783
920
  if (ev.kind !== 'book') continue;
784
- if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
921
+ // THE SAME THREE BRANCHES AS replay.mjs, IN THE SAME ORDER. A bound was
922
+ // missing here while the engine applied it, so the strategy traded against
923
+ // a pruned ladder and the report compared it to an unpruned one: a bound
924
+ // landing between the UP and DOWN snapshots left up_px at the stale 0.44
925
+ // while ctx.book() already said 0.45. The baseline is what every headline
926
+ // number is measured against, so a book it never traded on is worse than a
927
+ // missing baseline.
928
+ if (ev.bbo) book.bbo(ev.ts_ms, ev.side, ev.bid, ev.ask);
929
+ else if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
785
930
  else if (ev.side && ev.ladder) book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
786
931
  // BOTH SIDES FROM ONE BOOK STATE, captured together.
787
932
  //
@@ -85,27 +85,33 @@ class Ladder:
85
85
 
86
86
  def __init__(self, direction: int) -> None:
87
87
  self.direction = direction
88
- self.levels: list[list[int | float]] = [] # [ticks, size], best first
88
+ # [ticks, size, ts], best first. `ts` is when this level was last
89
+ # stated by the archive; `prune` needs it to tell a level the venue has
90
+ # moved past from one stamped the same millisecond as the bound that
91
+ # would delete it. Kept as a third slot rather than a parallel map so
92
+ # both engines carry it the same way.
93
+ self.levels: list[list[int | float]] = []
89
94
 
90
95
  def _worse(self, a: int, b: int) -> int:
91
96
  return (a - b) if self.direction > 0 else (b - a)
92
97
 
93
- def reset(self, levels: Iterable[Any]) -> None:
98
+ def reset(self, levels: Iterable[Any], ts: int = 0) -> None:
94
99
  rows = []
95
100
  for entry in levels or ():
96
101
  px, size = entry[0], float(entry[1])
97
102
  if size > 0:
98
- rows.append([to_ticks(float(px)), size])
103
+ rows.append([to_ticks(float(px)), size, ts])
99
104
  rows.sort(key=lambda r: r[0] * (1 if self.direction > 0 else -1))
100
105
  self.levels = rows
101
106
 
102
- def apply(self, px: float, size: float) -> None:
107
+ def apply(self, px: float, size: float, ts: int = 0) -> None:
103
108
  ticks = to_ticks(float(px))
104
109
  n = float(size)
105
110
  for i, level in enumerate(self.levels):
106
111
  if level[0] == ticks:
107
112
  if n > 0:
108
113
  level[1] = n
114
+ level[2] = ts
109
115
  else:
110
116
  self.levels.pop(i)
111
117
  return
@@ -114,7 +120,54 @@ class Ladder:
114
120
  j = len(self.levels)
115
121
  while j > 0 and self._worse(self.levels[j - 1][0], ticks) > 0:
116
122
  j -= 1
117
- self.levels.insert(j, [ticks, n])
123
+ self.levels.insert(j, [ticks, n, ts])
124
+
125
+ def prune(self, bound: float | None, ts: int) -> int:
126
+ """Delete every level STRICTLY BETTER than `bound` that is older than `ts`.
127
+
128
+ The whole of what the unthrottled top-of-book stream may do. It carries
129
+ prices and no sizes, so it can state what is NOT on the ladder and never
130
+ what is; adding a level from it would be liquidity invented without a
131
+ size.
132
+
133
+ Why it matters: `take()` eats from the best end while the delta stream
134
+ maintaining this ladder is thinned to the venue's capture cadence, so the
135
+ stalest levels are exactly the ones an order hits first — and stale in
136
+ one direction only, since a price already taken still looks available.
137
+
138
+ OLDER THAN, not "at or older than": a delta and a bound stamped the same
139
+ millisecond contradict each other and the archive does not say which came
140
+ first, so requiring the level to be strictly older makes the result the
141
+ same whichever way a tie was sorted.
142
+
143
+ MUST MATCH Ladder.prune in runner/engine/book.mjs exactly.
144
+ """
145
+ # A NUMBER, not something that parses as one. float('0.45') is 0.45
146
+ # while JavaScript's Number.isFinite('0.45') is false, so accepting
147
+ # strings here would make the same event prune in Python and not in
148
+ # Node. bool is excluded because it is an int in Python and True would
149
+ # otherwise read as the bound 1.
150
+ if not isinstance(bound, (int, float)) or isinstance(bound, bool):
151
+ return 0
152
+ b = float(bound)
153
+ if b != b or b in (float("inf"), float("-inf")):
154
+ return 0
155
+ # Inside [0, 1], for the reason spelled out in Ladder.prune in
156
+ # runner/engine/book.mjs: a bound is a maximal deletion instruction, and
157
+ # an outcome token cannot quote outside that range.
158
+ if b < 0.0 or b > 1.0:
159
+ return 0
160
+ cap = to_ticks(b)
161
+ kept = []
162
+ removed = 0
163
+ for level in self.levels:
164
+ if self._worse(level[0], cap) < 0 and level[2] < ts:
165
+ removed += 1
166
+ continue
167
+ kept.append(level)
168
+ if removed:
169
+ self.levels = kept
170
+ return removed
118
171
 
119
172
  def best(self) -> float | None:
120
173
  return from_ticks(self.levels[0][0]) if self.levels else None
@@ -122,14 +175,14 @@ class Ladder:
122
175
  def depth(self, bound: float | None = None) -> float:
123
176
  cap = None if bound is None else to_ticks(float(bound))
124
177
  total = 0.0
125
- for ticks, size in self.levels:
178
+ for ticks, size, _ts in self.levels:
126
179
  if cap is not None and self._worse(ticks, cap) > 0:
127
180
  break
128
181
  total += size
129
182
  return total
130
183
 
131
184
  def view(self, n: int = 10) -> list[list[float]]:
132
- return [[from_ticks(t), s] for t, s in self.levels[:n]]
185
+ return [[from_ticks(t), s] for t, s, _ts in self.levels[:n]]
133
186
 
134
187
  def take(self, size: float, bound: float | None):
135
188
  cap = None if bound is None else to_ticks(float(bound))
@@ -169,8 +222,8 @@ class Book:
169
222
  spec = (levels or {}).get(side)
170
223
  if not spec:
171
224
  continue
172
- self.ladders[side]["asks"].reset(spec.get("asks"))
173
- self.ladders[side]["bids"].reset(spec.get("bids"))
225
+ self.ladders[side]["asks"].reset(spec.get("asks"), ts)
226
+ self.ladders[side]["bids"].reset(spec.get("bids"), ts)
174
227
 
175
228
  def delta(self, ts: int, side: str, kind: str, px: float, size: float) -> None:
176
229
  self.ts = ts
@@ -178,7 +231,26 @@ class Book:
178
231
  raise ValueError(f"unknown side {side}")
179
232
  if kind not in ("asks", "bids"):
180
233
  raise ValueError(f"unknown ladder {kind}")
181
- self.ladders[side][kind].apply(px, size)
234
+ self.ladders[side][kind].apply(px, size, ts)
235
+
236
+ def bbo(self, ts: int, side: str, bid: float | None, ask: float | None) -> int:
237
+ """Apply an unthrottled top-of-book bound. DELETES ONLY.
238
+
239
+ `bid` and `ask` are prices with no size behind them, so they can shrink
240
+ the book and never grow it.
241
+
242
+ 0 and 1 need no special case. `bid = 0` is how the venue writes "no bid",
243
+ and deleting every bid strictly better than 0 deletes all of them. Its
244
+ complement is `ask = 1` on the other token of the same market, since
245
+ UP + DOWN = 1 — measured as an exact pairing in the archive.
246
+
247
+ MUST MATCH Book.bbo in runner/engine/book.mjs exactly.
248
+ """
249
+ self.ts = ts
250
+ if side not in SIDES:
251
+ raise ValueError(f"unknown side {side}")
252
+ lad = self.ladders[side]
253
+ return lad["asks"].prune(ask, ts) + lad["bids"].prune(bid, ts)
182
254
 
183
255
  def best(self, side: str) -> float | None:
184
256
  """The price to BUY that outcome at — the best ask."""
@@ -429,7 +429,13 @@ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
429
429
  state["now"] = ts
430
430
 
431
431
  if ev.get("kind") == "book":
432
- if ev.get("snapshot"):
432
+ # BEFORE the snapshot test, because a bound carries snapshot:false
433
+ # and would otherwise be applied as a delta with no ladder, no price
434
+ # and no size — which Book.delta rejects by raising, taking the whole
435
+ # run with it. MUST MATCH the same ordering in runner/engine/replay.mjs.
436
+ if ev.get("bbo"):
437
+ book.bbo(ts, ev.get("side"), ev.get("bid"), ev.get("ask"))
438
+ elif ev.get("snapshot"):
433
439
  book.snapshot(ts, ev.get("levels") or {})
434
440
  else:
435
441
  book.delta(ts, ev.get("side"), ev.get("ladder"), ev.get("px"), ev.get("size"))
@@ -441,7 +447,11 @@ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
441
447
  # comment in replay.mjs.
442
448
  state["history"].append(Rec(ev))
443
449
 
444
- hook = HOOK_FOR.get(ev.get("kind"))
450
+ # A bound refines the book silently and never reaches a hook the event
451
+ # has no levels/ladder/px/size, the stream is unthrottled, and ctx.book()
452
+ # is live so the next real event already sees the refined ladder. Full
453
+ # reasoning in replay.mjs; both engines or neither.
454
+ hook = None if ev.get("bbo") else HOOK_FOR.get(ev.get("kind"))
445
455
  if hook and hooks.get(hook):
446
456
  emit(call(hook, ev_rec), ts)
447
457
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Backpressure-aware line writer for a child process's stdin.
3
+ *
4
+ * Both the queued worker and `ot run` push an entire archive down one pipe —
5
+ * tens of millions of newline-framed rows for a large run. Ignoring what
6
+ * `write()` returns does not merely buffer: past the pipe's high-water mark the
7
+ * rows stop reaching the harness, and because a strategy that exits early
8
+ * closes the pipe under us, the failure arrives as an error on a stream nobody
9
+ * is listening to. The result is a run that replays the first fraction of its
10
+ * markets, exits 0, and produces a report that looks complete.
11
+ *
12
+ * That is not hypothetical: `ot run` did exactly this. It wrote 6.04M rows in a
13
+ * synchronous loop with `stdin.on('error', () => {})`, and a 289-market day
14
+ * came back as a 2-market report with no warning at all.
15
+ *
16
+ * Two details here are paid for in incidents and must not be simplified away:
17
+ *
18
+ * 1. ONE error listener for the whole stream, not one per line. A `once`
19
+ * added per write and never removed is millions of live listeners, and the
20
+ * writer dies of its own bookkeeping partway through a run.
21
+ * 2. An error has to settle a PENDING DRAIN. If the pipe breaks while we are
22
+ * parked waiting for one, the drain never arrives, the promise never
23
+ * settles, and the consumer waits for input that is not coming — burning
24
+ * the whole wall clock instead of failing in the second it broke.
25
+ */
26
+
27
+ /**
28
+ * @param {import('node:stream').Writable} stream
29
+ * @returns {(line: string) => Promise<void>} resolves once the line is accepted
30
+ */
31
+ export function createLineWriter(stream) {
32
+ let writeError = null;
33
+ let wakeDrain = null;
34
+
35
+ stream.on('error', (err) => {
36
+ writeError = err;
37
+ const wake = wakeDrain;
38
+ wakeDrain = null;
39
+ if (wake) wake();
40
+ });
41
+
42
+ return (line) => new Promise((resolve, reject) => {
43
+ if (writeError) { reject(writeError); return; }
44
+ // Sequential by contract, and it says so rather than corrupting quietly.
45
+ // `wakeDrain` is a single slot: a second concurrent call would overwrite
46
+ // the first one's continuation, so that write would never settle and its
47
+ // line could interleave into the middle of another. Both callers await
48
+ // every line, and the framing (a market header, then that market's rows)
49
+ // only means anything in order — so this is a programming error, not a
50
+ // case to support.
51
+ if (wakeDrain) {
52
+ reject(new Error('createLineWriter: concurrent write; lines must be awaited one at a time'));
53
+ return;
54
+ }
55
+ // Respect backpressure: ignoring the return of write() is the whole bug.
56
+ if (stream.write(`${line}\n`)) { resolve(); return; }
57
+ wakeDrain = () => {
58
+ stream.removeListener('drain', onDrain);
59
+ if (writeError) reject(writeError);
60
+ else resolve();
61
+ };
62
+ function onDrain() {
63
+ const wake = wakeDrain;
64
+ wakeDrain = null;
65
+ if (wake) wake();
66
+ }
67
+ stream.once('drain', onDrain);
68
+ });
69
+ }