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 +1 -1
- package/api/lib/backtest-contract.mjs +67 -2
- package/api/lib/backtest-datasets.mjs +135 -2
- package/api/lib/data-taxonomy.mjs +58 -5
- package/cli/commands/run.mjs +10 -1
- package/cli/local-data.mjs +18 -3
- package/index.d.ts +9 -2
- package/package.json +1 -1
- package/runner/engine/book.mjs +90 -9
- package/runner/engine/portfolio.mjs +18 -2
- package/runner/engine/replay.mjs +23 -2
- package/runner/engine/report.mjs +13 -12
- package/runner/events.mjs +178 -15
- package/runner/harness/node/sdk/index.d.ts +9 -2
- package/runner/harness/protocol.mjs +3 -2
- package/runner/harness/python/otengine.py +90 -11
- package/runner/harness/python/otreplay.py +12 -2
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 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.
|
|
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.
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
121
|
+
interval: period ? (BY_WORD[period.toUpperCase()] ?? period.toLowerCase()) : null,
|
|
69
122
|
ext,
|
|
70
123
|
};
|
|
71
124
|
}
|
package/cli/commands/run.mjs
CHANGED
|
@@ -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
|
}),
|
package/cli/local-data.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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/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:
|
|
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:
|
|
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
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
|
/**
|