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 +3 -3
- package/api/lib/backtest-contract.mjs +55 -2
- package/api/lib/backtest-datasets.mjs +135 -2
- package/api/lib/data-taxonomy.mjs +26 -3
- package/cli/commands/run.mjs +80 -26
- package/cli/local-data.mjs +17 -2
- package/cli/ot.mjs +2 -2
- package/package.json +1 -1
- package/runner/engine/book.mjs +90 -9
- package/runner/engine/replay.mjs +23 -2
- package/runner/events.mjs +147 -2
- package/runner/harness/python/otengine.py +82 -10
- package/runner/harness/python/otreplay.py +12 -2
- package/runner/stdin-writer.mjs +69 -0
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
91
|
+
interval: period ? (BY_WORD[period.toUpperCase()] ?? period.toLowerCase()) : null,
|
|
69
92
|
ext,
|
|
70
93
|
};
|
|
71
94
|
}
|
package/cli/commands/run.mjs
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
131
|
-
+ '
|
|
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
|
}),
|
package/cli/local-data.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
package/runner/engine/book.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
68
|
-
|
|
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
|
/**
|
package/runner/engine/replay.mjs
CHANGED
|
@@ -571,14 +571,35 @@ export function replayMarket({
|
|
|
571
571
|
control.setNow(ev.ts_ms);
|
|
572
572
|
|
|
573
573
|
if (ev.kind === 'book') {
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|