outcometick 1.6.3 → 1.6.5

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 600941b94e4cf95204fc8daf624a24a05f9c43c0.
8
+ Generated from monorepo revision 13e95ca5b2d1f465cb53799e610a08476f1db274.
9
9
  -->
10
10
 
11
11
  # outcometick
@@ -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.3';
18
+ export const SDK_VERSION = '1.6.5';
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.3';
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.16.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
  }),
@@ -247,6 +294,24 @@ const BOOK_CAPTURE = Object.freeze({
247
294
  ]),
248
295
  });
249
296
 
297
+ /**
298
+ * The days the book cadence changed, oldest first.
299
+ *
300
+ * Exported for the prewarm, which has to warm one shape PER CADENCE TIER: the
301
+ * cadence is chosen from a run's date RANGE, so a range that stops short of a
302
+ * change and one that crosses it are two different cache entries for the same
303
+ * day. Warming a single range therefore covers exactly one tier and silently
304
+ * misses the others — which is what happened between 2026-08-25 and
305
+ * 2026-09-07, when the prewarm warmed the whole sellable span (500ms) while
306
+ * every run over recent days wanted 20ms.
307
+ *
308
+ * Derived from the table above rather than restated, so a new cadence entry
309
+ * grows the warm set without anyone remembering to come here.
310
+ */
311
+ export function bookCadenceChangeDays(venue) {
312
+ return (BOOK_CAPTURE[venue] ?? []).map((e) => e.from);
313
+ }
314
+
250
315
  /**
251
316
  * The cadence ONE ASSET replays at within a run: the COARSEST its date range
252
317
  * contains. Ask per asset; there is no run-wide answer by design.
@@ -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,15 +19,46 @@ 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
- last_trade_price: 'Every trade print',
24
+ last_trade_price: 'Trade prints, unthrottled',
24
25
  markets: 'Per-market metadata, strike and settlement outcome',
25
26
  tick_size_change: 'Tick-size changes',
26
27
  orderbook: 'Order-book snapshots (Predict.fun)',
27
- klines: 'OHLCV candles derived from the settlement feed',
28
+ klines: 'OHLC candles derived from the settlement feed, with a tick count — no trade volume; the settlement feed is a price feed',
28
29
  other: 'Uncategorised',
29
30
  };
30
31
 
32
+ /**
33
+ * The same descriptions in Chinese.
34
+ *
35
+ * HERE, not in the page that renders them. Two of these lines state the
36
+ * SETTLEMENT BASIS — which TWAP stream settles which market — and CLAUDE.md
37
+ * pins that fact to four places that must move together. A Chinese copy living
38
+ * in a component would be a fifth, and it would drift the way every other
39
+ * hand-copy in this repo has: the basis already moved once (5-minute markets
40
+ * settled on the 30s stream until 2026-08-07, on the 60s stream after), and an
41
+ * AI assistant quoted a stale copy of our coverage back to a customer.
42
+ *
43
+ * Sitting beside DATASETS means changing one entry puts the other on screen in
44
+ * the same diff. data-taxonomy.test.mjs asserts the key sets match, so a new
45
+ * dataset cannot land with only one language.
46
+ */
47
+ export const DATASETS_ZH = {
48
+ prices: '结算价流,逐 tick(Chainlink 瞬时流)',
49
+ twap30s: 'TWAP 30 秒结算流——5 分钟市场改用 60 秒回看之前的结算线,至今仍逐日归档',
50
+ twap60s: 'TWAP 60 秒结算流——5 分钟与 15 分钟市场都判定在它上面',
51
+ book: '全深度盘口快照',
52
+ best_bid_ask: '盘口顶部,未抽稀——与 price_change 携带的是同一个最优买卖价,但每次更新都出一行,而不是按采集节奏;只有价格没有挂单量,深度仍需 book 或 price_change',
53
+ price_change: '盘口增量,带最优买卖价',
54
+ last_trade_price: '成交流,未抽稀',
55
+ markets: '每个市场的元数据、strike 与结算结果',
56
+ tick_size_change: '最小变动价位的变更',
57
+ orderbook: '盘口快照(Predict.fun)',
58
+ klines: '由结算价流推导的 OHLC K 线,带 tick 计数——没有成交量,结算流是价格流不是成交流',
59
+ other: '未归类',
60
+ };
61
+
31
62
  const num = (s) => (s == null ? null : s);
32
63
 
33
64
  /**
@@ -59,13 +90,35 @@ export function classifyPath(filePath) {
59
90
  return { venue, dataset: 'klines', asset: assetOf(segs[3]), interval: num(segs[4]), ext };
60
91
  }
61
92
  if (ds === 'orderbook') {
62
- // BTC-5M / BTC-15M / MARKET-<id>
63
- const m = /^([A-Za-z]+)-(\d+[mMhHdD]|DAILY)$/.exec(segs[3] ?? '');
93
+ // BTC-5M / BTC-15M / BTC-HOURLY / BTC-DAILY / BTC-OTHER / MARKET-<id>
94
+ //
95
+ // Predict names its hourly and daily series by word rather than by
96
+ // duration. Both are mapped onto the vocabulary every other dataset
97
+ // already uses, because the alternative cost customers real data twice
98
+ // over:
99
+ //
100
+ // HOURLY matched neither branch of the earlier pattern, so the whole
101
+ // series classified to interval:null. `interval=1h` returned an empty
102
+ // list — no error, just nothing — while the files sat in the archive,
103
+ // and /v1/meta never named the value at all, so a customer building an
104
+ // enumeration from it could not learn the data existed.
105
+ //
106
+ // DAILY did match, but produced `daily`: a value no duration parser
107
+ // accepts, that sorts after 1mo because sortIntervals cannot read it,
108
+ // and that shares one array with the klines' own `1d` while meaning
109
+ // the same span.
110
+ //
111
+ // The list stays a whitelist. Widening the second group to \w+ would
112
+ // turn BTC-OTHER into interval:'other' — inventing a period for the
113
+ // series that exists precisely because its period is unknown.
114
+ const BY_WORD = { HOURLY: '1h', DAILY: '1d' };
115
+ const m = /^([A-Za-z]+)-(\d+[mMhHdD]|HOURLY|DAILY)$/.exec(segs[3] ?? '');
116
+ const period = m?.[2];
64
117
  return {
65
118
  venue,
66
119
  dataset: 'orderbook',
67
120
  asset: assetOf(m?.[1] ?? segs[3]),
68
- interval: m ? m[2].toLowerCase() : null,
121
+ interval: period ? (BY_WORD[period.toUpperCase()] ?? period.toLowerCase()) : null,
69
122
  ext,
70
123
  };
71
124
  }
@@ -21,7 +21,7 @@ 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';
@@ -220,6 +220,7 @@ export async function cmdRun({ dir, flags }) {
220
220
  // been covered while agreeing about everything else. That is the harder
221
221
  // discrepancy to notice, because the report looks complete.
222
222
  const missing = [];
223
+ const bboApplied = new Map(); // day -> Set('<ASSET>|<interval>')
223
224
  for (const day of days) {
224
225
  const loaded = await loadLocalDay({
225
226
  root: dataRoot, day, venue,
@@ -236,6 +237,13 @@ export async function cmdRun({ dir, flags }) {
236
237
  to: days[days.length - 1],
237
238
  }),
238
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
+ }
239
247
  if (loaded.markets.length === 0) {
240
248
  process.stderr.write(` ${day}: ${loaded.reason}\n`);
241
249
  missing.push({
@@ -440,6 +448,7 @@ export async function cmdRun({ dir, flags }) {
440
448
  referenceDeclared: [],
441
449
  streams: countStreams([...marketMeta.values()]),
442
450
  droppedRows: base.malformed ?? 0,
451
+ ...bboCoverage({ venue, datasets: manifest.datasets, markets, applied: bboApplied }),
443
452
  local: true,
444
453
  source: path.resolve(dataRoot),
445
454
  }),
@@ -4,7 +4,7 @@
4
4
  // R2. Both go through runner/events.mjs for the row -> event mapping, because
5
5
  // the docs make a promise about exactly this pair:
6
6
  //
7
- // The identical files, same checksums, same coverage report. A backtest
7
+ // The identical files, byte for byte, with the same checksums. A backtest
8
8
  // here and a backtest on your own machine after subscribing read the same
9
9
  // bytes — that is the point of offering it.
10
10
  //
@@ -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/index.d.ts CHANGED
@@ -12,6 +12,13 @@
12
12
 
13
13
  export type Side = 'UP' | 'DOWN';
14
14
 
15
+ /**
16
+ * How a market settled. `'TIE'` is a 50:50 settlement — Predict.fun resolves
17
+ * an end price equal to the start price this way, and every UP and DOWN
18
+ * contract pays $0.50.
19
+ */
20
+ export type Outcome = Side | 'TIE';
21
+
15
22
  export declare const SIDES: readonly ['UP', 'DOWN'];
16
23
 
17
24
  /** One level of resting depth: [price, size]. */
@@ -129,7 +136,7 @@ export interface Ctx<P = Record<string, unknown>> {
129
136
  * one. Recorded for the cross-check panel, never enforced — a mismatch is
130
137
  * information, not a failed run.
131
138
  */
132
- assert_outcome(market: unknown, outcome: Side): void;
139
+ assert_outcome(market: unknown, outcome: Outcome): void;
133
140
  }
134
141
 
135
142
  /**
@@ -207,7 +214,7 @@ export declare class Order {
207
214
  * onTick(ctx: Ctx, tick: Tick): Order | null
208
215
  * onBook(ctx: Ctx, book: BookView): Order | null
209
216
  * onTrade(ctx: Ctx, trade: Tick): Order | null
210
- * onSettle(ctx: Ctx, market: Market, outcome: Side): void
217
+ * onSettle(ctx: Ctx, market: Market, outcome: Outcome): void
211
218
  */
212
219
  export declare class Strategy<P = Record<string, unknown>> {
213
220
  /** Params from the manifest, injected by the runner before the first hook. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
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
  /**