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