@agg-build/sdk 4.25.0 → 4.27.0
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/dist/agg-websocket-BstVWtLA.d.mts +6004 -0
- package/dist/agg-websocket-BstVWtLA.d.mts.map +1 -0
- package/dist/agg-websocket-BstVWtLA.d.ts +6004 -0
- package/dist/agg-websocket-BstVWtLA.d.ts.map +1 -0
- package/dist/agg-websocket-CK2iXfti.mjs +1427 -0
- package/dist/agg-websocket-CK2iXfti.mjs.map +1 -0
- package/dist/agg-websocket-DJbIfxKb.js +1478 -0
- package/dist/browser.d.mts +67 -66
- package/dist/browser.d.mts.map +1 -0
- package/dist/browser.d.ts +67 -66
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +720 -2047
- package/dist/browser.mjs +423 -799
- package/dist/browser.mjs.map +1 -0
- package/dist/candle-builder-BNewIe9K.mjs +148 -0
- package/dist/candle-builder-BNewIe9K.mjs.map +1 -0
- package/dist/candle-builder-BULYdxB4.js +151 -0
- package/dist/index.d.mts +2 -1342
- package/dist/index.d.ts +2 -1342
- package/dist/index.js +2794 -4656
- package/dist/index.mjs +2788 -2486
- package/dist/index.mjs.map +1 -0
- package/dist/market-data.d.mts +6 -6
- package/dist/market-data.d.mts.map +1 -0
- package/dist/market-data.d.ts +6 -6
- package/dist/market-data.d.ts.map +1 -0
- package/dist/market-data.js +314 -1774
- package/dist/market-data.mjs +314 -350
- package/dist/market-data.mjs.map +1 -0
- package/dist/server.d.mts +235 -234
- package/dist/server.d.mts.map +1 -0
- package/dist/server.d.ts +235 -234
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +168 -296
- package/dist/server.mjs +163 -234
- package/dist/server.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/agg-websocket-Bxt9PzTz.d.mts +0 -4479
- package/dist/agg-websocket-Bxt9PzTz.d.ts +0 -4479
- package/dist/chunk-QXBVA5TW.mjs +0 -2073
- package/dist/chunk-VD5UTAVD.mjs +0 -158
- package/dist/chunk-WNQUEZJF.mjs +0 -58
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
//#region ../common/src/orderbook-checksum.ts
|
|
2
|
+
/** IEEE CRC32 used by the existing orderbook protocol. */
|
|
3
|
+
function orderbookCrc32(value) {
|
|
4
|
+
let crc = 4294967295;
|
|
5
|
+
for (let i = 0; i < value.length; i++) {
|
|
6
|
+
crc ^= value.charCodeAt(i);
|
|
7
|
+
for (let bit = 0; bit < 8; bit++) crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0);
|
|
8
|
+
}
|
|
9
|
+
return (crc ^ 4294967295) >>> 0;
|
|
10
|
+
}
|
|
11
|
+
/** Checksum of exactly the supplied levels, independent of depth/venue attribution. */
|
|
12
|
+
function projectedOrderbookChecksum(book) {
|
|
13
|
+
let result = 0;
|
|
14
|
+
for (const level of book.bids) result ^= orderbookCrc32(`B${level.price}:${level.size}`);
|
|
15
|
+
for (const level of book.asks) result ^= orderbookCrc32(`A${level.price}:${level.size}`);
|
|
16
|
+
return result >>> 0;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/market-data/history-decoder.ts
|
|
20
|
+
/** Decode bounded dynamic history where processing lives; retain milliseconds and optional mean. */
|
|
21
|
+
function decodeChartHistory(payload) {
|
|
22
|
+
if (payload.length > 1048576) throw new Error("Chart history exceeds decode budget");
|
|
23
|
+
const body = JSON.parse(payload);
|
|
24
|
+
if (!body || !Array.isArray(body.data) || body.data.length > 5e3 || typeof body.venueMarketOutcomeId !== "string" || typeof body.venue !== "string" || ![
|
|
25
|
+
"1m",
|
|
26
|
+
"5m",
|
|
27
|
+
"1h",
|
|
28
|
+
"1d"
|
|
29
|
+
].includes(body.resolution) || !body.meta) throw new Error("Invalid chart history response");
|
|
30
|
+
const price = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
31
|
+
for (const bar of body.data) if (!Number.isSafeInteger(bar.t) || bar.t < 0 || ![
|
|
32
|
+
bar.o,
|
|
33
|
+
bar.h,
|
|
34
|
+
bar.l,
|
|
35
|
+
bar.c
|
|
36
|
+
].every(price) || bar.l > Math.min(bar.o, bar.c) || bar.h < Math.max(bar.o, bar.c) || bar.v !== null && (!Number.isFinite(bar.v) || bar.v < 0) || bar.mean !== void 0 && !price(bar.mean)) throw new Error("Invalid chart history candle");
|
|
37
|
+
const data = [...body.data].sort((a, b) => a.t - b.t);
|
|
38
|
+
if (data.some((bar, index) => index > 0 && data[index - 1].t === bar.t)) throw new Error("Duplicate chart history timestamp");
|
|
39
|
+
return {
|
|
40
|
+
...body,
|
|
41
|
+
data
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Bound received bytes before JSON parsing, including chunked responses without Content-Length. */
|
|
45
|
+
async function readChartHistoryText(response) {
|
|
46
|
+
const maxBytes = 1048576;
|
|
47
|
+
if (Number(response.headers.get("content-length")) > maxBytes) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
throw new Error("Chart history exceeds decode budget");
|
|
50
|
+
}
|
|
51
|
+
if (!response.body?.getReader) {
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes) throw new Error("Chart history exceeds decode budget");
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
const reader = response.body.getReader();
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let size = 0;
|
|
59
|
+
let text = "";
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const { value, done } = await reader.read();
|
|
63
|
+
if (done) return text + decoder.decode();
|
|
64
|
+
size += value.byteLength;
|
|
65
|
+
if (size > maxBytes) {
|
|
66
|
+
await reader.cancel();
|
|
67
|
+
throw new Error("Chart history exceeds decode budget");
|
|
68
|
+
}
|
|
69
|
+
text += decoder.decode(value, { stream: true });
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
reader.releaseLock();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/market-data/validate.ts
|
|
77
|
+
/** Bounds work before processing untrusted market frames. Protocol semantics remain in AggWebSocket. */
|
|
78
|
+
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
79
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
80
|
+
const nullable = (value) => value === null || finite(value);
|
|
81
|
+
const levels = (value, attributed) => Array.isArray(value) && value.length <= 1e4 && value.every((level) => Array.isArray(level) && finite(level[0]) && level[0] >= 0 && finite(level[1]) && level[1] >= 0 && (!attributed || object(level[2]) && Object.values(level[2]).every((size) => finite(size) && size >= 0)));
|
|
82
|
+
function validMarketFrame(frame) {
|
|
83
|
+
if (typeof frame.outcomeId !== "string" || !frame.outcomeId || !finite(frame.timestamp) || frame.timestamp < 0) return false;
|
|
84
|
+
if (frame.type === "trade") return finite(frame.price) && frame.price >= 0 && finite(frame.size) && frame.size >= 0 && typeof frame.venue === "string" && (frame.side === "buy" || frame.side === "sell");
|
|
85
|
+
if (!Number.isSafeInteger(frame.seq) || frame.seq < 0 || !Number.isInteger(frame.checksum) || frame.checksum < 0 || frame.checksum > 4294967295 || !nullable(frame.midpoint) || !nullable(frame.spread) || !object(frame.venues)) return false;
|
|
86
|
+
for (const info of Object.values(frame.venues)) if (!object(info) || !nullable(info.bestBid) || !nullable(info.bestAsk)) return false;
|
|
87
|
+
const snapshot = frame.type === "orderbook_snapshot";
|
|
88
|
+
if (!levels(frame[snapshot ? "bids" : "bidChanges"], true) || !levels(frame[snapshot ? "asks" : "askChanges"], true)) return false;
|
|
89
|
+
const books = frame[snapshot ? "venueOrderbooks" : "venueDeltaBooks"];
|
|
90
|
+
return object(books) && Object.values(books).every((book) => object(book) && levels(book[snapshot ? "bids" : "bidChanges"], false) && levels(book[snapshot ? "asks" : "askChanges"], false));
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/market-history.ts
|
|
94
|
+
async function readBoundedBody(response, signal) {
|
|
95
|
+
if (!response.body) throw new Error("Missing history body");
|
|
96
|
+
const reader = response.body.getReader();
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
const abort = () => {
|
|
100
|
+
reader.cancel(signal.reason).catch(() => void 0);
|
|
101
|
+
};
|
|
102
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
if (signal.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History request aborted");
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (signal.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History request aborted");
|
|
108
|
+
if (done) break;
|
|
109
|
+
size += value.byteLength;
|
|
110
|
+
if (size > 262144) {
|
|
111
|
+
await reader.cancel();
|
|
112
|
+
throw new Error("Oversized history object");
|
|
113
|
+
}
|
|
114
|
+
chunks.push(value);
|
|
115
|
+
}
|
|
116
|
+
const bytes = new Uint8Array(size);
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (const chunk of chunks) {
|
|
119
|
+
bytes.set(chunk, offset);
|
|
120
|
+
offset += chunk.byteLength;
|
|
121
|
+
}
|
|
122
|
+
return bytes;
|
|
123
|
+
} finally {
|
|
124
|
+
signal.removeEventListener("abort", abort);
|
|
125
|
+
reader.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Download immutable CDN objects directly. Safe to run in the browser Worker or Node. */
|
|
129
|
+
async function loadImmutableHistory({ manifest, fetch: fetcher = globalThis.fetch, signal, digest }) {
|
|
130
|
+
if (manifest.version !== 1 || ![
|
|
131
|
+
"venue-ohlcv-v1",
|
|
132
|
+
"polymarket-price-samples-v1",
|
|
133
|
+
"kalshi-ohlcv-quote-fallback-v2"
|
|
134
|
+
].includes(manifest.semantics) || manifest.chunks.length > 8 || manifest.chunks.reduce((n, c) => n + c.points, 0) > 4096 || !manifest.meta.complete) throw new Error("History manifest is incomplete or unsupported");
|
|
135
|
+
const hash = digest ?? (async (bytes) => {
|
|
136
|
+
if (!globalThis.crypto?.subtle) throw new Error("History integrity requires WebCrypto or an injected SHA-256 implementation");
|
|
137
|
+
const value = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
138
|
+
return Array.from(new Uint8Array(value), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
139
|
+
});
|
|
140
|
+
const results = new Array(manifest.chunks.length);
|
|
141
|
+
let next = 0;
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const deadline = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("History download budget exhausted")), 2e3);
|
|
144
|
+
const abort = () => controller.abort(signal?.reason);
|
|
145
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
146
|
+
if (signal?.aborted) abort();
|
|
147
|
+
try {
|
|
148
|
+
await Promise.all(Array.from({ length: Math.min(2, manifest.chunks.length) }, async () => {
|
|
149
|
+
while (next < manifest.chunks.length) {
|
|
150
|
+
if (controller.signal.aborted) throw controller.signal.reason ?? /* @__PURE__ */ new Error("History request aborted");
|
|
151
|
+
const index = next++;
|
|
152
|
+
const chunk = manifest.chunks[index];
|
|
153
|
+
if (chunk.points < 0 || chunk.points > 512 || chunk.from >= chunk.to || new URL(chunk.url).protocol !== "https:") throw new Error("Invalid history reference");
|
|
154
|
+
const response = await fetcher(chunk.url, {
|
|
155
|
+
signal: controller.signal,
|
|
156
|
+
credentials: "omit"
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) throw new Error(`History download failed (${response.status})`);
|
|
159
|
+
if (Number(response.headers.get("content-length")) > 262144) throw new Error("Oversized history object");
|
|
160
|
+
const bytes = await readBoundedBody(response, controller.signal);
|
|
161
|
+
if (bytes.byteLength > 262144 || await hash(bytes) !== chunk.sha256) throw new Error("History integrity mismatch");
|
|
162
|
+
const object = JSON.parse(new TextDecoder().decode(bytes));
|
|
163
|
+
if (object.version !== 1 || object.venueMarketOutcomeId !== manifest.venueMarketOutcomeId || object.venue !== manifest.venue || object.resolution !== manifest.resolution || object.semantics !== manifest.semantics || object.from !== chunk.from || object.to !== chunk.to || !Array.isArray(object.data) || object.data.length !== chunk.points) throw new Error("History identity mismatch");
|
|
164
|
+
let previous = -Infinity;
|
|
165
|
+
for (const bar of object.data) {
|
|
166
|
+
if (!Number.isSafeInteger(bar.t) || bar.t <= previous || bar.t < chunk.from || bar.t >= chunk.to || ![
|
|
167
|
+
bar.o,
|
|
168
|
+
bar.h,
|
|
169
|
+
bar.l,
|
|
170
|
+
bar.c
|
|
171
|
+
].every((v) => Number.isFinite(v) && v >= 0 && v <= 1) || bar.l > Math.min(bar.o, bar.c) || bar.h < Math.max(bar.o, bar.c) || bar.v !== null && (!Number.isFinite(bar.v) || bar.v < 0) || bar.mean !== void 0 && (!Number.isFinite(bar.mean) || bar.mean < 0 || bar.mean > 1)) throw new Error("Invalid history candle");
|
|
172
|
+
previous = bar.t;
|
|
173
|
+
}
|
|
174
|
+
results[index] = object.data;
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
const bars = results.flat().filter((b) => b.t >= manifest.from && b.t < manifest.to).sort((a, b) => a.t - b.t);
|
|
178
|
+
if (bars.some((b, i) => i > 0 && bars[i - 1].t === b.t)) throw new Error("Overlapping history chunks");
|
|
179
|
+
return bars;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
controller.abort(error);
|
|
182
|
+
throw error;
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(deadline);
|
|
185
|
+
signal?.removeEventListener("abort", abort);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/orderbook-utils.ts
|
|
190
|
+
const PRICE_KEY_SCALE = 1e9;
|
|
191
|
+
/**
|
|
192
|
+
* Compute the XOR-based checksum of an orderbook.
|
|
193
|
+
* Matches the Rust engine's checksum: XOR of crc32("B{price}:{size}") for each
|
|
194
|
+
* bid and crc32("A{price}:{size}") for each ask, independently hashed then XOR'd.
|
|
195
|
+
*/
|
|
196
|
+
function computeChecksum(book) {
|
|
197
|
+
return projectedOrderbookChecksum(book);
|
|
198
|
+
}
|
|
199
|
+
function attributedToLevel(wire) {
|
|
200
|
+
return {
|
|
201
|
+
price: wire[0],
|
|
202
|
+
size: wire[1],
|
|
203
|
+
venues: { ...wire[2] }
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function venueLevelToLevel(wire) {
|
|
207
|
+
return {
|
|
208
|
+
price: wire[0],
|
|
209
|
+
size: wire[1]
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function convertVenueBooks(raw) {
|
|
213
|
+
const result = {};
|
|
214
|
+
for (const [venue, book] of Object.entries(raw)) result[venue] = {
|
|
215
|
+
bids: book.bids.map(venueLevelToLevel),
|
|
216
|
+
asks: book.asks.map(venueLevelToLevel)
|
|
217
|
+
};
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Convert a WS snapshot message into local OrderbookState.
|
|
222
|
+
* Timestamp is expected to already be in seconds (converted by AggWebSocket).
|
|
223
|
+
*/
|
|
224
|
+
function snapshotToOrderbook(snapshot) {
|
|
225
|
+
return {
|
|
226
|
+
marketId: snapshot.outcomeId,
|
|
227
|
+
outcomeId: snapshot.outcomeId,
|
|
228
|
+
bids: snapshot.bids.map(attributedToLevel),
|
|
229
|
+
asks: snapshot.asks.map(attributedToLevel),
|
|
230
|
+
venueOrderbooks: convertVenueBooks(snapshot.venueOrderbooks),
|
|
231
|
+
venues: { ...snapshot.venues },
|
|
232
|
+
midpoint: snapshot.midpoint,
|
|
233
|
+
spread: snapshot.spread,
|
|
234
|
+
seq: snapshot.seq,
|
|
235
|
+
checksum: snapshot.checksum,
|
|
236
|
+
timestamp: snapshot.timestamp
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Apply a WS delta to an existing OrderbookState, returning a new state.
|
|
241
|
+
* Bids sorted descending, asks ascending (maintained invariant).
|
|
242
|
+
*/
|
|
243
|
+
function applyOrderbookDelta(state, delta) {
|
|
244
|
+
const bids = applyChanges([...state.bids], delta.bidChanges, "desc");
|
|
245
|
+
const asks = applyChanges([...state.asks], delta.askChanges, "asc");
|
|
246
|
+
const impliedBidChangesByVenue = deriveVenueChangesFromAttributedChanges(state.bids, delta.bidChanges);
|
|
247
|
+
const impliedAskChangesByVenue = deriveVenueChangesFromAttributedChanges(state.asks, delta.askChanges);
|
|
248
|
+
const venueOrderbooks = { ...state.venueOrderbooks };
|
|
249
|
+
const venuesWithDelta = /* @__PURE__ */ new Set([
|
|
250
|
+
...Object.keys(delta.venueDeltaBooks),
|
|
251
|
+
...Object.keys(impliedBidChangesByVenue),
|
|
252
|
+
...Object.keys(impliedAskChangesByVenue)
|
|
253
|
+
]);
|
|
254
|
+
for (const venue of venuesWithDelta) {
|
|
255
|
+
const explicitChanges = delta.venueDeltaBooks[venue];
|
|
256
|
+
const bidChanges = mergeVenueLevelChanges(explicitChanges?.bidChanges, impliedBidChangesByVenue[venue]);
|
|
257
|
+
const askChanges = mergeVenueLevelChanges(explicitChanges?.askChanges, impliedAskChangesByVenue[venue]);
|
|
258
|
+
if (bidChanges.length === 0 && askChanges.length === 0) continue;
|
|
259
|
+
const existing = venueOrderbooks[venue] ?? {
|
|
260
|
+
bids: [],
|
|
261
|
+
asks: []
|
|
262
|
+
};
|
|
263
|
+
venueOrderbooks[venue] = {
|
|
264
|
+
bids: applyVenueChanges([...existing.bids], bidChanges, "desc"),
|
|
265
|
+
asks: applyVenueChanges([...existing.asks], askChanges, "asc")
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
marketId: state.marketId,
|
|
270
|
+
outcomeId: state.outcomeId ?? delta.outcomeId,
|
|
271
|
+
bids,
|
|
272
|
+
asks,
|
|
273
|
+
venueOrderbooks,
|
|
274
|
+
venues: {
|
|
275
|
+
...state.venues,
|
|
276
|
+
...delta.venues
|
|
277
|
+
},
|
|
278
|
+
midpoint: delta.midpoint,
|
|
279
|
+
spread: delta.spread,
|
|
280
|
+
seq: delta.seq,
|
|
281
|
+
checksum: delta.checksum,
|
|
282
|
+
timestamp: delta.timestamp
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function toPriceKey(price) {
|
|
286
|
+
return String(Math.round(price * PRICE_KEY_SCALE));
|
|
287
|
+
}
|
|
288
|
+
function applyChanges(levels, changes, sort) {
|
|
289
|
+
const levelsByPrice = new Map(levels.map((level) => [toPriceKey(level.price), level]));
|
|
290
|
+
for (const [price, size, venues] of changes) {
|
|
291
|
+
const key = toPriceKey(price);
|
|
292
|
+
if (size === 0) {
|
|
293
|
+
levelsByPrice.delete(key);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
levelsByPrice.set(key, {
|
|
297
|
+
price,
|
|
298
|
+
size,
|
|
299
|
+
venues: { ...venues ?? {} }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
levels = [...levelsByPrice.values()];
|
|
303
|
+
levels.sort(sort === "desc" ? (a, b) => b.price - a.price : (a, b) => a.price - b.price);
|
|
304
|
+
return levels;
|
|
305
|
+
}
|
|
306
|
+
function applyVenueChanges(levels, changes, sort) {
|
|
307
|
+
const levelsByPrice = new Map(levels.map((level) => [toPriceKey(level.price), level]));
|
|
308
|
+
for (const [price, size] of changes) {
|
|
309
|
+
const key = toPriceKey(price);
|
|
310
|
+
if (size === 0) {
|
|
311
|
+
levelsByPrice.delete(key);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
levelsByPrice.set(key, {
|
|
315
|
+
price,
|
|
316
|
+
size
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
levels = [...levelsByPrice.values()];
|
|
320
|
+
levels.sort(sort === "desc" ? (a, b) => b.price - a.price : (a, b) => a.price - b.price);
|
|
321
|
+
return levels;
|
|
322
|
+
}
|
|
323
|
+
function mergeVenueLevelChanges(primary, fallback) {
|
|
324
|
+
if (!primary?.length) return fallback ? [...fallback] : [];
|
|
325
|
+
if (!fallback?.length) return [...primary];
|
|
326
|
+
const changesByPrice = /* @__PURE__ */ new Map();
|
|
327
|
+
for (const change of fallback) changesByPrice.set(toPriceKey(change[0]), change);
|
|
328
|
+
for (const change of primary) changesByPrice.set(toPriceKey(change[0]), change);
|
|
329
|
+
return [...changesByPrice.values()];
|
|
330
|
+
}
|
|
331
|
+
function deriveVenueChangesFromAttributedChanges(previousLevels, changes) {
|
|
332
|
+
const previousLevelsByPrice = new Map(previousLevels.map((level) => [toPriceKey(level.price), level]));
|
|
333
|
+
const venueChanges = {};
|
|
334
|
+
for (const [price, size, nextVenuesRaw] of changes) {
|
|
335
|
+
const key = toPriceKey(price);
|
|
336
|
+
const previousVenues = previousLevelsByPrice.get(key)?.venues ?? {};
|
|
337
|
+
const nextVenues = nextVenuesRaw ?? {};
|
|
338
|
+
const affectedVenues = /* @__PURE__ */ new Set([...Object.keys(previousVenues), ...Object.keys(nextVenues)]);
|
|
339
|
+
for (const venue of affectedVenues) {
|
|
340
|
+
const nextSize = Number(nextVenues[venue] ?? 0);
|
|
341
|
+
if (!Number.isFinite(nextSize)) continue;
|
|
342
|
+
if (!venueChanges[venue]) venueChanges[venue] = [];
|
|
343
|
+
venueChanges[venue].push([price, nextSize]);
|
|
344
|
+
}
|
|
345
|
+
if (size === 0) {
|
|
346
|
+
previousLevelsByPrice.delete(key);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
previousLevelsByPrice.set(key, {
|
|
350
|
+
price,
|
|
351
|
+
size,
|
|
352
|
+
venues: { ...nextVenues }
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return venueChanges;
|
|
356
|
+
}
|
|
357
|
+
/** Private mutable representation for ordered feeds; immutable views are built only on demand. */
|
|
358
|
+
var IndexedOrderbook = class {
|
|
359
|
+
constructor(state) {
|
|
360
|
+
this.venueBooks = /* @__PURE__ */ new Map();
|
|
361
|
+
this.bids = new Map(state.bids.map((level) => [toPriceKey(level.price), level]));
|
|
362
|
+
this.asks = new Map(state.asks.map((level) => [toPriceKey(level.price), level]));
|
|
363
|
+
for (const [venue, book] of Object.entries(state.venueOrderbooks)) this.venueBooks.set(venue, {
|
|
364
|
+
bids: new Map(book.bids.map((level) => [toPriceKey(level.price), level])),
|
|
365
|
+
asks: new Map(book.asks.map((level) => [toPriceKey(level.price), level]))
|
|
366
|
+
});
|
|
367
|
+
const { bids: _bids, asks: _asks, venueOrderbooks: _venueBooks, ...metadata } = state;
|
|
368
|
+
this.metadata = metadata;
|
|
369
|
+
this.cached = state;
|
|
370
|
+
this.checksumValue = computeChecksum(state);
|
|
371
|
+
}
|
|
372
|
+
get seq() {
|
|
373
|
+
return this.metadata.seq;
|
|
374
|
+
}
|
|
375
|
+
get checksum() {
|
|
376
|
+
return this.checksumValue;
|
|
377
|
+
}
|
|
378
|
+
/** Verify before committing, including duplicate-price changes within a frame. */
|
|
379
|
+
apply(delta) {
|
|
380
|
+
let checksum = this.checksumValue;
|
|
381
|
+
for (const [levels, changes, side] of [[
|
|
382
|
+
this.bids,
|
|
383
|
+
delta.bidChanges,
|
|
384
|
+
"B"
|
|
385
|
+
], [
|
|
386
|
+
this.asks,
|
|
387
|
+
delta.askChanges,
|
|
388
|
+
"A"
|
|
389
|
+
]]) {
|
|
390
|
+
const changed = /* @__PURE__ */ new Map();
|
|
391
|
+
for (const [price, size, venues] of changes) {
|
|
392
|
+
const key = toPriceKey(price);
|
|
393
|
+
const previous = changed.has(key) ? changed.get(key) : levels.get(key);
|
|
394
|
+
if (previous) checksum ^= orderbookCrc32(`${side}${previous.price}:${previous.size}`);
|
|
395
|
+
if (size !== 0) checksum ^= orderbookCrc32(`${side}${price}:${size}`);
|
|
396
|
+
changed.set(key, size === 0 ? void 0 : {
|
|
397
|
+
price,
|
|
398
|
+
size,
|
|
399
|
+
venues: { ...venues }
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
checksum >>>= 0;
|
|
404
|
+
if (checksum !== delta.checksum) return checksum;
|
|
405
|
+
const previousBids = delta.bidChanges.flatMap(([price]) => this.bids.get(toPriceKey(price)) ?? []);
|
|
406
|
+
const previousAsks = delta.askChanges.flatMap(([price]) => this.asks.get(toPriceKey(price)) ?? []);
|
|
407
|
+
const impliedBids = deriveVenueChangesFromAttributedChanges(previousBids, delta.bidChanges);
|
|
408
|
+
const impliedAsks = deriveVenueChangesFromAttributedChanges(previousAsks, delta.askChanges);
|
|
409
|
+
for (const venue of /* @__PURE__ */ new Set([
|
|
410
|
+
...Object.keys(delta.venueDeltaBooks),
|
|
411
|
+
...Object.keys(impliedBids),
|
|
412
|
+
...Object.keys(impliedAsks)
|
|
413
|
+
])) {
|
|
414
|
+
let book = this.venueBooks.get(venue);
|
|
415
|
+
if (!book) {
|
|
416
|
+
book = {
|
|
417
|
+
bids: /* @__PURE__ */ new Map(),
|
|
418
|
+
asks: /* @__PURE__ */ new Map()
|
|
419
|
+
};
|
|
420
|
+
this.venueBooks.set(venue, book);
|
|
421
|
+
}
|
|
422
|
+
const explicit = delta.venueDeltaBooks[venue];
|
|
423
|
+
for (const [levels, changes] of [[book.bids, mergeVenueLevelChanges(explicit?.bidChanges, impliedBids[venue])], [book.asks, mergeVenueLevelChanges(explicit?.askChanges, impliedAsks[venue])]]) for (const [price, size] of changes) if (size === 0) levels.delete(toPriceKey(price));
|
|
424
|
+
else levels.set(toPriceKey(price), {
|
|
425
|
+
price,
|
|
426
|
+
size
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
for (const [levels, changes] of [[this.bids, delta.bidChanges], [this.asks, delta.askChanges]]) for (const [price, size, venues] of changes) if (size === 0) levels.delete(toPriceKey(price));
|
|
430
|
+
else levels.set(toPriceKey(price), {
|
|
431
|
+
price,
|
|
432
|
+
size,
|
|
433
|
+
venues: { ...venues }
|
|
434
|
+
});
|
|
435
|
+
this.metadata = {
|
|
436
|
+
...this.metadata,
|
|
437
|
+
outcomeId: this.metadata.outcomeId ?? delta.outcomeId,
|
|
438
|
+
venues: {
|
|
439
|
+
...this.metadata.venues,
|
|
440
|
+
...delta.venues
|
|
441
|
+
},
|
|
442
|
+
midpoint: delta.midpoint,
|
|
443
|
+
spread: delta.spread,
|
|
444
|
+
seq: delta.seq,
|
|
445
|
+
checksum: delta.checksum,
|
|
446
|
+
timestamp: delta.timestamp
|
|
447
|
+
};
|
|
448
|
+
this.checksumValue = checksum;
|
|
449
|
+
this.cached = null;
|
|
450
|
+
return checksum;
|
|
451
|
+
}
|
|
452
|
+
view() {
|
|
453
|
+
if (this.cached) return this.cached;
|
|
454
|
+
const bids = (levels) => [...levels.values()].sort((a, b) => b.price - a.price);
|
|
455
|
+
const asks = (levels) => [...levels.values()].sort((a, b) => a.price - b.price);
|
|
456
|
+
this.cached = {
|
|
457
|
+
...this.metadata,
|
|
458
|
+
bids: bids(this.bids),
|
|
459
|
+
asks: asks(this.asks),
|
|
460
|
+
venueOrderbooks: Object.fromEntries([...this.venueBooks].map(([venue, book]) => [venue, {
|
|
461
|
+
bids: bids(book.bids),
|
|
462
|
+
asks: asks(book.asks)
|
|
463
|
+
}]))
|
|
464
|
+
};
|
|
465
|
+
return this.cached;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/agg-websocket.ts
|
|
470
|
+
/**
|
|
471
|
+
* Agg platform WebSocket client.
|
|
472
|
+
*
|
|
473
|
+
* Single connection, refcounted subscriptions. Orderbook/trades subscriptions
|
|
474
|
+
* are keyed by venueMarketOutcomeId (the `outcomes[].id` on REST market
|
|
475
|
+
* responses — NOT a venueMarketId); arb subscriptions are keyed by marketId.
|
|
476
|
+
* Maintains in-memory orderbook state per subscribed outcome id.
|
|
477
|
+
* All timestamps converted from server ms to seconds before invoking callbacks.
|
|
478
|
+
*
|
|
479
|
+
* Recovery: seq gap or checksum mismatch → resnapshot request.
|
|
480
|
+
* Stale delta guard: deltas with seq <= current book seq are silently dropped.
|
|
481
|
+
* Liveness: no message for 45s → reconnect (server sends heartbeat every 30s).
|
|
482
|
+
* Book watchdog (optional): books silent for 5 min are checked against REST
|
|
483
|
+
* `seq`; a mismatch triggers a resnapshot, a repeat mismatch a resubscribe.
|
|
484
|
+
*/
|
|
485
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
486
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
487
|
+
const STALE_TIMEOUT_MS = 45e3;
|
|
488
|
+
const ORDERBOOK_DEBUG_TOP_LEVELS = 5;
|
|
489
|
+
const WATCHDOG_SILENCE_MS = 3e5;
|
|
490
|
+
const WATCHDOG_CHECK_INTERVAL_MS = 6e4;
|
|
491
|
+
/** `GET /orderbooks/outcomes` accepts at most 100 ids per call. */
|
|
492
|
+
const WATCHDOG_BATCH_MAX = 100;
|
|
493
|
+
/** A watchdog REST check that takes longer than this is abandoned. */
|
|
494
|
+
const WATCHDOG_REQUEST_TIMEOUT_MS = 15e3;
|
|
495
|
+
/**
|
|
496
|
+
* Agg WebSocket client. Single WS connection with refcounted market subscriptions.
|
|
497
|
+
*/
|
|
498
|
+
var AggWebSocket = class {
|
|
499
|
+
constructor(options) {
|
|
500
|
+
this.ws = null;
|
|
501
|
+
this.subs = /* @__PURE__ */ new Map();
|
|
502
|
+
this.pendingSubs = /* @__PURE__ */ new Set();
|
|
503
|
+
this.pendingTradeSubs = /* @__PURE__ */ new Set();
|
|
504
|
+
this.pendingResnapshots = /* @__PURE__ */ new Set();
|
|
505
|
+
this.pendingFlushSubs = /* @__PURE__ */ new Set();
|
|
506
|
+
this.pendingFlushUnsubs = /* @__PURE__ */ new Set();
|
|
507
|
+
this.pendingFlushTradeSubs = /* @__PURE__ */ new Set();
|
|
508
|
+
this.pendingFlushTradeUnsubs = /* @__PURE__ */ new Set();
|
|
509
|
+
this.pendingFlushArbSubs = /* @__PURE__ */ new Set();
|
|
510
|
+
this.pendingFlushArbUnsubs = /* @__PURE__ */ new Set();
|
|
511
|
+
this.flushScheduled = false;
|
|
512
|
+
this.arbSubs = /* @__PURE__ */ new Map();
|
|
513
|
+
this.arbFeedCbs = /* @__PURE__ */ new Set();
|
|
514
|
+
this.latestArb = /* @__PURE__ */ new Map();
|
|
515
|
+
this.books = /* @__PURE__ */ new Map();
|
|
516
|
+
this.resyncing = /* @__PURE__ */ new Set();
|
|
517
|
+
this.bookCheckedAt = /* @__PURE__ */ new Map();
|
|
518
|
+
this.watchdogResnapshotted = /* @__PURE__ */ new Set();
|
|
519
|
+
this.watchdogTimer = null;
|
|
520
|
+
this.watchdogAbort = null;
|
|
521
|
+
this.watchdogPausedUntil = 0;
|
|
522
|
+
this.connecting = false;
|
|
523
|
+
this._connected = false;
|
|
524
|
+
this.reconnectAttempts = 0;
|
|
525
|
+
this.reconnectTimer = null;
|
|
526
|
+
this.staleTimer = null;
|
|
527
|
+
this.lastMessageTime = 0;
|
|
528
|
+
this.destroyed = false;
|
|
529
|
+
this.historyControllers = /* @__PURE__ */ new Set();
|
|
530
|
+
this.deferBookViews = options.deferBookViews ?? false;
|
|
531
|
+
this.url = options.url;
|
|
532
|
+
this.callbacks = options.callbacks ?? {};
|
|
533
|
+
this.initialToken = options.initialToken;
|
|
534
|
+
this.enableLogs = options.enableLogs ?? false;
|
|
535
|
+
this.bookWatchdog = options.bookWatchdog;
|
|
536
|
+
}
|
|
537
|
+
get connected() {
|
|
538
|
+
return this._connected;
|
|
539
|
+
}
|
|
540
|
+
/** Decode dynamic authenticated REST bytes in the same processing environment as live data. */
|
|
541
|
+
async decodeHistory(payload) {
|
|
542
|
+
return decodeChartHistory(payload);
|
|
543
|
+
}
|
|
544
|
+
/** Immutable history decoder. Browser adapters execute this inside their Worker. */
|
|
545
|
+
async loadHistory(manifest, options) {
|
|
546
|
+
if (this.destroyed) throw new Error("Market-data client disposed");
|
|
547
|
+
const controller = new AbortController();
|
|
548
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
549
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
550
|
+
if (options?.signal?.aborted) abort();
|
|
551
|
+
this.historyControllers.add(controller);
|
|
552
|
+
try {
|
|
553
|
+
controller.signal.throwIfAborted();
|
|
554
|
+
const result = await loadImmutableHistory({
|
|
555
|
+
manifest,
|
|
556
|
+
signal: controller.signal
|
|
557
|
+
});
|
|
558
|
+
controller.signal.throwIfAborted();
|
|
559
|
+
return result;
|
|
560
|
+
} finally {
|
|
561
|
+
this.historyControllers.delete(controller);
|
|
562
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Optional framework-independent host diagnostics, cleared on disposal. */
|
|
566
|
+
setHostDiagnostics(provider) {
|
|
567
|
+
this.hostDiagnostics = provider;
|
|
568
|
+
}
|
|
569
|
+
/** On-demand ownership diagnostics; contains no credentials or user/account data. */
|
|
570
|
+
getDiagnostics() {
|
|
571
|
+
return {
|
|
572
|
+
connected: this.connected,
|
|
573
|
+
host: this.hostDiagnostics?.(),
|
|
574
|
+
transport: this.destroyed ? "disposed" : "direct",
|
|
575
|
+
orderbookOutcomeIds: this.activeOrderbookMarkets(),
|
|
576
|
+
tradeOutcomeIds: this.activeTradeMarkets(),
|
|
577
|
+
arbMarketIds: this.activeArbMarkets(),
|
|
578
|
+
arbFeedSubscribers: this.arbFeedCbs.size,
|
|
579
|
+
cachedBooks: this.books.size,
|
|
580
|
+
cachedArb: this.latestArb.size,
|
|
581
|
+
resyncingBooks: this.resyncing.size,
|
|
582
|
+
pendingResnapshots: this.pendingResnapshots.size,
|
|
583
|
+
pendingHistoryRequests: this.historyControllers.size
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/** Get the current orderbook state for an outcome (or null if no snapshot received yet). */
|
|
587
|
+
getBook(outcomeId) {
|
|
588
|
+
return this.books.get(outcomeId)?.view() ?? null;
|
|
589
|
+
}
|
|
590
|
+
/** Whether an outcome is currently resyncing (waiting for fresh snapshot). */
|
|
591
|
+
isResyncing(outcomeId) {
|
|
592
|
+
return this.resyncing.has(outcomeId);
|
|
593
|
+
}
|
|
594
|
+
/** Get the latest arb update for a market (or null if none received yet). */
|
|
595
|
+
getArb(marketId) {
|
|
596
|
+
return this.latestArb.get(marketId) ?? null;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Subscribe to per-market arb updates for a given marketId.
|
|
600
|
+
* Ref-counted: multiple callers for the same marketId share one server subscription.
|
|
601
|
+
* Returns an unsubscribe function.
|
|
602
|
+
*/
|
|
603
|
+
subscribeArb(marketId, cb) {
|
|
604
|
+
let entry = this.arbSubs.get(marketId);
|
|
605
|
+
if (!entry) {
|
|
606
|
+
entry = {
|
|
607
|
+
refCount: 0,
|
|
608
|
+
cbs: /* @__PURE__ */ new Set()
|
|
609
|
+
};
|
|
610
|
+
this.arbSubs.set(marketId, entry);
|
|
611
|
+
this.ensureConnected();
|
|
612
|
+
if (this._connected) {
|
|
613
|
+
this.pendingFlushArbUnsubs.delete(marketId);
|
|
614
|
+
this.pendingFlushArbSubs.add(marketId);
|
|
615
|
+
this.scheduleFlush();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
entry.cbs.add(cb);
|
|
619
|
+
entry.refCount++;
|
|
620
|
+
return () => {
|
|
621
|
+
const e = this.arbSubs.get(marketId);
|
|
622
|
+
if (!e) return;
|
|
623
|
+
e.cbs.delete(cb);
|
|
624
|
+
e.refCount--;
|
|
625
|
+
if (e.refCount <= 0) {
|
|
626
|
+
this.arbSubs.delete(marketId);
|
|
627
|
+
if (this._connected) {
|
|
628
|
+
if (this.pendingFlushArbSubs.has(marketId)) this.pendingFlushArbSubs.delete(marketId);
|
|
629
|
+
else {
|
|
630
|
+
this.pendingFlushArbUnsubs.add(marketId);
|
|
631
|
+
this.scheduleFlush();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Subscribe to the global arb-feed batches.
|
|
639
|
+
* Only one server subscription is maintained regardless of how many callers.
|
|
640
|
+
* Returns an unsubscribe function.
|
|
641
|
+
*/
|
|
642
|
+
subscribeArbFeed(cb) {
|
|
643
|
+
const wasEmpty = this.arbFeedCbs.size === 0;
|
|
644
|
+
this.arbFeedCbs.add(cb);
|
|
645
|
+
if (wasEmpty) {
|
|
646
|
+
this.ensureConnected();
|
|
647
|
+
if (this._connected) this.send({
|
|
648
|
+
action: "subscribe",
|
|
649
|
+
channel: "arb-feed"
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return () => {
|
|
653
|
+
this.arbFeedCbs.delete(cb);
|
|
654
|
+
if (this.arbFeedCbs.size === 0 && this._connected) this.send({
|
|
655
|
+
action: "unsubscribe",
|
|
656
|
+
channel: "arb-feed"
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Subscribe to a feed by outcome ID.
|
|
662
|
+
* @param outcomeId The venueMarketOutcomeId to subscribe to.
|
|
663
|
+
* @param channel "orderbook" (default) or "trades".
|
|
664
|
+
* Returns an unsubscribe function (ref-counted).
|
|
665
|
+
*/
|
|
666
|
+
subscribe(outcomeId, channel = "orderbook") {
|
|
667
|
+
if (channel === "trades") return this._subscribeTrades(outcomeId);
|
|
668
|
+
let sub = this.subs.get(outcomeId);
|
|
669
|
+
if (!sub) {
|
|
670
|
+
sub = {
|
|
671
|
+
refCount: 0,
|
|
672
|
+
tradeRefs: 0
|
|
673
|
+
};
|
|
674
|
+
this.subs.set(outcomeId, sub);
|
|
675
|
+
}
|
|
676
|
+
if (sub.refCount === 0) {
|
|
677
|
+
this.bookCheckedAt.set(outcomeId, Date.now());
|
|
678
|
+
this.pendingSubs.add(outcomeId);
|
|
679
|
+
this.ensureConnected();
|
|
680
|
+
if (this._connected) {
|
|
681
|
+
this.pendingFlushUnsubs.delete(outcomeId);
|
|
682
|
+
this.pendingFlushSubs.add(outcomeId);
|
|
683
|
+
this.pendingSubs.delete(outcomeId);
|
|
684
|
+
this.scheduleFlush();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
sub.refCount++;
|
|
688
|
+
let released = false;
|
|
689
|
+
return () => {
|
|
690
|
+
if (released) return;
|
|
691
|
+
released = true;
|
|
692
|
+
const s = this.subs.get(outcomeId);
|
|
693
|
+
if (!s) return;
|
|
694
|
+
s.refCount--;
|
|
695
|
+
if (s.refCount <= 0) {
|
|
696
|
+
s.refCount = 0;
|
|
697
|
+
if (s.tradeRefs <= 0) {
|
|
698
|
+
this.subs.delete(outcomeId);
|
|
699
|
+
this.books.delete(outcomeId);
|
|
700
|
+
}
|
|
701
|
+
this.resyncing.delete(outcomeId);
|
|
702
|
+
this.bookCheckedAt.delete(outcomeId);
|
|
703
|
+
this.watchdogResnapshotted.delete(outcomeId);
|
|
704
|
+
if (this._connected) {
|
|
705
|
+
if (this.pendingFlushSubs.has(outcomeId)) this.pendingFlushSubs.delete(outcomeId);
|
|
706
|
+
else {
|
|
707
|
+
this.pendingFlushUnsubs.add(outcomeId);
|
|
708
|
+
this.scheduleFlush();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.pendingSubs.delete(outcomeId);
|
|
712
|
+
this.pendingResnapshots.delete(outcomeId);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Subscribe to an outcome's trade feed.
|
|
718
|
+
* @deprecated Use `subscribe(outcomeId, "trades")` instead.
|
|
719
|
+
* Returns an unsubscribe function.
|
|
720
|
+
*/
|
|
721
|
+
subscribeTrades(outcomeId) {
|
|
722
|
+
return this._subscribeTrades(outcomeId);
|
|
723
|
+
}
|
|
724
|
+
_subscribeTrades(outcomeId) {
|
|
725
|
+
let sub = this.subs.get(outcomeId);
|
|
726
|
+
if (!sub) {
|
|
727
|
+
sub = {
|
|
728
|
+
refCount: 0,
|
|
729
|
+
tradeRefs: 0
|
|
730
|
+
};
|
|
731
|
+
this.subs.set(outcomeId, sub);
|
|
732
|
+
}
|
|
733
|
+
if (sub.tradeRefs === 0) {
|
|
734
|
+
this.pendingTradeSubs.add(outcomeId);
|
|
735
|
+
this.ensureConnected();
|
|
736
|
+
if (this._connected) {
|
|
737
|
+
this.pendingFlushTradeUnsubs.delete(outcomeId);
|
|
738
|
+
this.pendingFlushTradeSubs.add(outcomeId);
|
|
739
|
+
this.pendingTradeSubs.delete(outcomeId);
|
|
740
|
+
this.scheduleFlush();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
sub.tradeRefs++;
|
|
744
|
+
let released = false;
|
|
745
|
+
return () => {
|
|
746
|
+
if (released) return;
|
|
747
|
+
released = true;
|
|
748
|
+
const s = this.subs.get(outcomeId);
|
|
749
|
+
if (!s) return;
|
|
750
|
+
s.tradeRefs--;
|
|
751
|
+
if (s.tradeRefs <= 0) {
|
|
752
|
+
s.tradeRefs = 0;
|
|
753
|
+
if (this._connected) {
|
|
754
|
+
if (this.pendingFlushTradeSubs.has(outcomeId)) this.pendingFlushTradeSubs.delete(outcomeId);
|
|
755
|
+
else {
|
|
756
|
+
this.pendingFlushTradeUnsubs.add(outcomeId);
|
|
757
|
+
this.scheduleFlush();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
this.pendingTradeSubs.delete(outcomeId);
|
|
761
|
+
this.pendingResnapshots.delete(outcomeId);
|
|
762
|
+
if (s.refCount <= 0) {
|
|
763
|
+
this.subs.delete(outcomeId);
|
|
764
|
+
this.books.delete(outcomeId);
|
|
765
|
+
this.resyncing.delete(outcomeId);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/** Send a JWT to authenticate (or re-authenticate) the connection. */
|
|
771
|
+
authenticate(token) {
|
|
772
|
+
this.initialToken = token;
|
|
773
|
+
if (this._connected) this.send({
|
|
774
|
+
action: "authenticate",
|
|
775
|
+
token
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/** Request a fresh snapshot for one or more subscribed outcomes. */
|
|
779
|
+
resnapshot(outcomeIdOrIds) {
|
|
780
|
+
(Array.isArray(outcomeIdOrIds) ? outcomeIdOrIds : [outcomeIdOrIds]).forEach((outcomeId) => {
|
|
781
|
+
this.requestResnapshot(outcomeId, true);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/** Connect (idempotent). Called automatically on first subscribe. */
|
|
785
|
+
connect() {
|
|
786
|
+
this.ensureConnected();
|
|
787
|
+
}
|
|
788
|
+
/** Disconnect and clean up. Reusable — call connect() to reconnect. */
|
|
789
|
+
disconnect() {
|
|
790
|
+
this.cancelReconnect();
|
|
791
|
+
this.cancelStaleTimer();
|
|
792
|
+
this._connected = false;
|
|
793
|
+
this.connecting = false;
|
|
794
|
+
if (this.ws) {
|
|
795
|
+
this.ws.onclose = null;
|
|
796
|
+
this.ws.close();
|
|
797
|
+
this.ws = null;
|
|
798
|
+
}
|
|
799
|
+
this.callbacks.onConnectionStateChange?.(false);
|
|
800
|
+
}
|
|
801
|
+
/** Permanently destroy. Cannot reconnect after this. */
|
|
802
|
+
destroy() {
|
|
803
|
+
this.destroyed = true;
|
|
804
|
+
for (const controller of this.historyControllers) controller.abort(/* @__PURE__ */ new Error("Market-data client disposed"));
|
|
805
|
+
this.historyControllers.clear();
|
|
806
|
+
this.disconnect();
|
|
807
|
+
this.hostDiagnostics = void 0;
|
|
808
|
+
this.callbacks = {};
|
|
809
|
+
this.subs.clear();
|
|
810
|
+
this.books.clear();
|
|
811
|
+
this.resyncing.clear();
|
|
812
|
+
if (this.watchdogTimer) clearInterval(this.watchdogTimer);
|
|
813
|
+
this.watchdogTimer = null;
|
|
814
|
+
this.watchdogAbort?.abort();
|
|
815
|
+
this.watchdogAbort = null;
|
|
816
|
+
this.bookCheckedAt.clear();
|
|
817
|
+
this.watchdogResnapshotted.clear();
|
|
818
|
+
this.pendingSubs.clear();
|
|
819
|
+
this.pendingTradeSubs.clear();
|
|
820
|
+
this.pendingResnapshots.clear();
|
|
821
|
+
this.pendingFlushSubs.clear();
|
|
822
|
+
this.pendingFlushUnsubs.clear();
|
|
823
|
+
this.pendingFlushTradeSubs.clear();
|
|
824
|
+
this.pendingFlushTradeUnsubs.clear();
|
|
825
|
+
this.arbSubs.clear();
|
|
826
|
+
this.arbFeedCbs.clear();
|
|
827
|
+
this.latestArb.clear();
|
|
828
|
+
this.pendingFlushArbSubs.clear();
|
|
829
|
+
this.pendingFlushArbUnsubs.clear();
|
|
830
|
+
this.flushScheduled = false;
|
|
831
|
+
}
|
|
832
|
+
/** Update callbacks (e.g., after React re-render). */
|
|
833
|
+
setCallbacks(callbacks) {
|
|
834
|
+
this.callbacks = callbacks;
|
|
835
|
+
}
|
|
836
|
+
ensureConnected() {
|
|
837
|
+
if (this.destroyed) return;
|
|
838
|
+
if (this.ws?.readyState === WebSocket.OPEN || this.connecting) return;
|
|
839
|
+
this.doConnect();
|
|
840
|
+
}
|
|
841
|
+
doConnect() {
|
|
842
|
+
if (this.destroyed) return;
|
|
843
|
+
this.connecting = true;
|
|
844
|
+
const ws = new WebSocket(this.url);
|
|
845
|
+
this.ws = ws;
|
|
846
|
+
ws.onopen = () => {
|
|
847
|
+
if (this.destroyed || this.ws !== ws) return;
|
|
848
|
+
this.connecting = false;
|
|
849
|
+
this.reconnectAttempts = 0;
|
|
850
|
+
this.resetStaleTimer();
|
|
851
|
+
if (this.initialToken) this.send({
|
|
852
|
+
action: "authenticate",
|
|
853
|
+
token: this.initialToken
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
ws.onmessage = (event) => {
|
|
857
|
+
if (this.destroyed || this.ws !== ws) return;
|
|
858
|
+
this.lastMessageTime = Date.now();
|
|
859
|
+
this.callbacks.onFrameReceived?.(this.lastMessageTime);
|
|
860
|
+
this.resetStaleTimer();
|
|
861
|
+
try {
|
|
862
|
+
const raw = JSON.parse(event.data);
|
|
863
|
+
this.handleMessage(raw);
|
|
864
|
+
} catch {}
|
|
865
|
+
};
|
|
866
|
+
ws.onclose = () => {
|
|
867
|
+
this.ws = null;
|
|
868
|
+
this.connecting = false;
|
|
869
|
+
const wasConnected = this._connected;
|
|
870
|
+
this._connected = false;
|
|
871
|
+
this.cancelStaleTimer();
|
|
872
|
+
if (wasConnected) this.callbacks.onConnectionStateChange?.(false);
|
|
873
|
+
if (!this.destroyed && (this.subs.size > 0 || this.arbSubs.size > 0 || this.arbFeedCbs.size > 0)) this.scheduleReconnect();
|
|
874
|
+
};
|
|
875
|
+
ws.onerror = () => {};
|
|
876
|
+
}
|
|
877
|
+
scheduleReconnect() {
|
|
878
|
+
this.cancelReconnect();
|
|
879
|
+
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts), RECONNECT_MAX_MS);
|
|
880
|
+
this.reconnectAttempts++;
|
|
881
|
+
this.emitDiagnostic({
|
|
882
|
+
type: "reconnect",
|
|
883
|
+
attempt: this.reconnectAttempts,
|
|
884
|
+
delayMs: delay
|
|
885
|
+
});
|
|
886
|
+
this.reconnectTimer = setTimeout(() => {
|
|
887
|
+
this.reconnectTimer = null;
|
|
888
|
+
this.doConnect();
|
|
889
|
+
}, delay);
|
|
890
|
+
}
|
|
891
|
+
cancelReconnect() {
|
|
892
|
+
if (this.reconnectTimer) {
|
|
893
|
+
clearTimeout(this.reconnectTimer);
|
|
894
|
+
this.reconnectTimer = null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
resetStaleTimer() {
|
|
898
|
+
this.cancelStaleTimer();
|
|
899
|
+
this.staleTimer = setTimeout(() => {
|
|
900
|
+
const age = Date.now() - this.lastMessageTime;
|
|
901
|
+
this.emitDiagnostic({
|
|
902
|
+
type: "stale_connection",
|
|
903
|
+
lastMessageAge: age
|
|
904
|
+
});
|
|
905
|
+
if (this.ws) this.ws.close();
|
|
906
|
+
}, STALE_TIMEOUT_MS);
|
|
907
|
+
}
|
|
908
|
+
cancelStaleTimer() {
|
|
909
|
+
if (this.staleTimer) {
|
|
910
|
+
clearTimeout(this.staleTimer);
|
|
911
|
+
this.staleTimer = null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
handleMessage(raw) {
|
|
915
|
+
const type = raw.type;
|
|
916
|
+
if ([
|
|
917
|
+
"orderbook_snapshot",
|
|
918
|
+
"orderbook_delta",
|
|
919
|
+
"trade"
|
|
920
|
+
].includes(type) && !validMarketFrame(raw)) {
|
|
921
|
+
this.callbacks.onError?.({
|
|
922
|
+
type: "error",
|
|
923
|
+
message: "Invalid market-data frame"
|
|
924
|
+
});
|
|
925
|
+
if (typeof raw.outcomeId === "string" && this.subs.has(raw.outcomeId)) this.requestResnapshot(raw.outcomeId);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
switch (type) {
|
|
929
|
+
case "orderbook_snapshot":
|
|
930
|
+
this.handleSnapshot(raw);
|
|
931
|
+
break;
|
|
932
|
+
case "orderbook_delta":
|
|
933
|
+
this.handleDelta(raw);
|
|
934
|
+
break;
|
|
935
|
+
case "trade":
|
|
936
|
+
this.handleTrade(raw);
|
|
937
|
+
break;
|
|
938
|
+
case "heartbeat":
|
|
939
|
+
this.handleHeartbeat(raw);
|
|
940
|
+
break;
|
|
941
|
+
case "subscribed":
|
|
942
|
+
this.callbacks.onSubscribed?.(raw);
|
|
943
|
+
break;
|
|
944
|
+
case "unsubscribed":
|
|
945
|
+
this.callbacks.onUnsubscribed?.(raw);
|
|
946
|
+
break;
|
|
947
|
+
case "connected":
|
|
948
|
+
this.handleConnected(raw);
|
|
949
|
+
break;
|
|
950
|
+
case "authenticated":
|
|
951
|
+
this.callbacks.onAuthenticated?.(raw);
|
|
952
|
+
break;
|
|
953
|
+
case "market_resolved":
|
|
954
|
+
this.handleMarketResolved(raw);
|
|
955
|
+
break;
|
|
956
|
+
case "error":
|
|
957
|
+
this.callbacks.onError?.(raw);
|
|
958
|
+
break;
|
|
959
|
+
case "order_submitted":
|
|
960
|
+
this.handleOrderSubmitted(raw);
|
|
961
|
+
break;
|
|
962
|
+
case "balance_update":
|
|
963
|
+
this.handleBalanceUpdate(raw);
|
|
964
|
+
break;
|
|
965
|
+
case "order_event":
|
|
966
|
+
if (this.callbacks.onOrderEventReceipt) {
|
|
967
|
+
const receivedAtMs = Date.now();
|
|
968
|
+
const serverTimestampMs = typeof raw.timestamp === "number" && Number.isFinite(raw.timestamp) && raw.timestamp > 0 ? raw.timestamp : null;
|
|
969
|
+
try {
|
|
970
|
+
this.callbacks.onOrderEventReceipt({
|
|
971
|
+
orderId: String(raw.orderId ?? ""),
|
|
972
|
+
event: raw.event,
|
|
973
|
+
serverTimestampMs,
|
|
974
|
+
receivedAtMs,
|
|
975
|
+
deliveryDelayMs: serverTimestampMs !== null && receivedAtMs >= serverTimestampMs ? receivedAtMs - serverTimestampMs : null
|
|
976
|
+
});
|
|
977
|
+
} catch {}
|
|
978
|
+
}
|
|
979
|
+
this.callbacks.onOrderEvent?.(raw);
|
|
980
|
+
break;
|
|
981
|
+
case "redeem_event":
|
|
982
|
+
this.handleRedeemEvent(raw);
|
|
983
|
+
break;
|
|
984
|
+
case "withdrawal_lifecycle":
|
|
985
|
+
this.callbacks.onWithdrawalLifecycle?.(raw);
|
|
986
|
+
break;
|
|
987
|
+
case "arb_market_update": {
|
|
988
|
+
const arbMsg = raw;
|
|
989
|
+
this.latestArb.set(arbMsg.marketId, arbMsg);
|
|
990
|
+
const arbEntry = this.arbSubs.get(arbMsg.marketId);
|
|
991
|
+
if (arbEntry) for (const cb of arbEntry.cbs) cb(arbMsg);
|
|
992
|
+
this.callbacks.onArbMarket?.(arbMsg);
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
case "arb_feed_batch": {
|
|
996
|
+
const feedMsg = raw;
|
|
997
|
+
for (const cb of this.arbFeedCbs) cb(feedMsg);
|
|
998
|
+
this.callbacks.onArbFeed?.(feedMsg);
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Server confirmed the connection — gateway has validated our appId and is
|
|
1005
|
+
* ready to accept subscriptions. Now flush all pending subs.
|
|
1006
|
+
*/
|
|
1007
|
+
handleConnected(msg) {
|
|
1008
|
+
this._connected = true;
|
|
1009
|
+
if (this.bookWatchdog && !this.watchdogTimer && !this.destroyed) this.watchdogTimer = setInterval(() => void this.checkSilentBooks(), this.bookWatchdog.checkIntervalMs ?? WATCHDOG_CHECK_INTERVAL_MS);
|
|
1010
|
+
this.callbacks.onConnectionStateChange?.(true);
|
|
1011
|
+
this.callbacks.onConnected?.(msg);
|
|
1012
|
+
const orderbookSet = new Set(this.activeOrderbookMarkets());
|
|
1013
|
+
const reconnectedAt = Date.now();
|
|
1014
|
+
for (const id of orderbookSet) if (this.bookCheckedAt.has(id)) this.bookCheckedAt.set(id, reconnectedAt);
|
|
1015
|
+
this.pendingSubs.forEach((id) => orderbookSet.add(id));
|
|
1016
|
+
this.pendingFlushSubs.forEach((id) => orderbookSet.add(id));
|
|
1017
|
+
const orderbookIds = Array.from(orderbookSet);
|
|
1018
|
+
if (orderbookIds.length > 0) this.sendSubscribe(orderbookIds);
|
|
1019
|
+
this.pendingSubs.clear();
|
|
1020
|
+
this.pendingFlushSubs.clear();
|
|
1021
|
+
this.pendingFlushUnsubs.clear();
|
|
1022
|
+
const tradeSet = new Set(this.activeTradeMarkets());
|
|
1023
|
+
this.pendingTradeSubs.forEach((id) => tradeSet.add(id));
|
|
1024
|
+
this.pendingFlushTradeSubs.forEach((id) => tradeSet.add(id));
|
|
1025
|
+
const tradeIds = Array.from(tradeSet);
|
|
1026
|
+
if (tradeIds.length > 0) this.send({
|
|
1027
|
+
action: "subscribe",
|
|
1028
|
+
channel: "trades",
|
|
1029
|
+
outcomeIds: tradeIds
|
|
1030
|
+
});
|
|
1031
|
+
this.pendingTradeSubs.clear();
|
|
1032
|
+
this.pendingFlushTradeSubs.clear();
|
|
1033
|
+
this.pendingFlushTradeUnsubs.clear();
|
|
1034
|
+
const resnapshotIds = Array.from(this.pendingResnapshots);
|
|
1035
|
+
if (resnapshotIds.length > 0) this.send({
|
|
1036
|
+
action: "resnapshot",
|
|
1037
|
+
channel: "orderbook",
|
|
1038
|
+
outcomeIds: resnapshotIds
|
|
1039
|
+
});
|
|
1040
|
+
this.pendingResnapshots.clear();
|
|
1041
|
+
const arbSet = new Set(this.activeArbMarkets());
|
|
1042
|
+
this.pendingFlushArbSubs.forEach((id) => arbSet.add(id));
|
|
1043
|
+
const arbIds = Array.from(arbSet);
|
|
1044
|
+
if (arbIds.length > 0) this.send({
|
|
1045
|
+
action: "subscribe",
|
|
1046
|
+
channel: "arb",
|
|
1047
|
+
marketIds: arbIds
|
|
1048
|
+
});
|
|
1049
|
+
this.pendingFlushArbSubs.clear();
|
|
1050
|
+
this.pendingFlushArbUnsubs.clear();
|
|
1051
|
+
if (this.arbFeedCbs.size > 0) this.send({
|
|
1052
|
+
action: "subscribe",
|
|
1053
|
+
channel: "arb-feed"
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
handleSnapshot(raw) {
|
|
1057
|
+
if (!this.subs.has(raw.outcomeId)) return;
|
|
1058
|
+
const msg = {
|
|
1059
|
+
...raw,
|
|
1060
|
+
timestamp: raw.timestamp / 1e3
|
|
1061
|
+
};
|
|
1062
|
+
const book = snapshotToOrderbook(msg);
|
|
1063
|
+
this.books.set(msg.outcomeId, new IndexedOrderbook(book));
|
|
1064
|
+
this.bookCheckedAt.set(msg.outcomeId, Date.now());
|
|
1065
|
+
this.resyncing.delete(msg.outcomeId);
|
|
1066
|
+
this.pendingResnapshots.delete(msg.outcomeId);
|
|
1067
|
+
this.debugOrderbook("snapshot_applied", {
|
|
1068
|
+
outcomeId: msg.outcomeId,
|
|
1069
|
+
seq: msg.seq,
|
|
1070
|
+
checksum: msg.checksum,
|
|
1071
|
+
bidsTop: this.summarizeTopLevels(book.bids),
|
|
1072
|
+
asksTop: this.summarizeTopLevels(book.asks)
|
|
1073
|
+
});
|
|
1074
|
+
this.callbacks.onBookTick?.(msg.outcomeId, {
|
|
1075
|
+
midpoint: msg.midpoint,
|
|
1076
|
+
timestamp: msg.timestamp,
|
|
1077
|
+
source: "snapshot"
|
|
1078
|
+
});
|
|
1079
|
+
if (!this.deferBookViews) this.callbacks.onSnapshot?.(msg.outcomeId, book);
|
|
1080
|
+
}
|
|
1081
|
+
handleDelta(raw) {
|
|
1082
|
+
if (!this.subs.has(raw.outcomeId)) return;
|
|
1083
|
+
const outcomeId = raw.outcomeId;
|
|
1084
|
+
const current = this.books.get(outcomeId);
|
|
1085
|
+
if (!current) {
|
|
1086
|
+
this.debugOrderbook("delta_missing_snapshot", {
|
|
1087
|
+
outcomeId,
|
|
1088
|
+
seq: raw.seq
|
|
1089
|
+
});
|
|
1090
|
+
this.requestResnapshot(outcomeId);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (raw.seq <= current.seq) {
|
|
1094
|
+
this.debugOrderbook("delta_stale_ignored", {
|
|
1095
|
+
outcomeId,
|
|
1096
|
+
currentSeq: current.seq,
|
|
1097
|
+
receivedSeq: raw.seq
|
|
1098
|
+
});
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (raw.seq > current.seq + 1) {
|
|
1102
|
+
this.debugOrderbook("delta_seq_gap", {
|
|
1103
|
+
outcomeId,
|
|
1104
|
+
expectedSeq: current.seq + 1,
|
|
1105
|
+
receivedSeq: raw.seq
|
|
1106
|
+
});
|
|
1107
|
+
this.emitDiagnostic({
|
|
1108
|
+
type: "seq_gap",
|
|
1109
|
+
marketId: outcomeId,
|
|
1110
|
+
outcomeId,
|
|
1111
|
+
expected: current.seq + 1,
|
|
1112
|
+
received: raw.seq
|
|
1113
|
+
});
|
|
1114
|
+
this.requestResnapshot(outcomeId);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
const msg = {
|
|
1118
|
+
...raw,
|
|
1119
|
+
timestamp: raw.timestamp / 1e3
|
|
1120
|
+
};
|
|
1121
|
+
const computed = current.apply(msg);
|
|
1122
|
+
if (computed !== msg.checksum) {
|
|
1123
|
+
this.debugOrderbook("delta_checksum_mismatch", {
|
|
1124
|
+
outcomeId,
|
|
1125
|
+
seq: msg.seq,
|
|
1126
|
+
expected: msg.checksum,
|
|
1127
|
+
computed
|
|
1128
|
+
});
|
|
1129
|
+
this.emitDiagnostic({
|
|
1130
|
+
type: "checksum_mismatch",
|
|
1131
|
+
marketId: outcomeId,
|
|
1132
|
+
outcomeId,
|
|
1133
|
+
expected: msg.checksum,
|
|
1134
|
+
computed
|
|
1135
|
+
});
|
|
1136
|
+
this.requestResnapshot(outcomeId);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
this.bookCheckedAt.set(outcomeId, Date.now());
|
|
1140
|
+
this.watchdogResnapshotted.delete(outcomeId);
|
|
1141
|
+
this.callbacks.onBookTick?.(outcomeId, {
|
|
1142
|
+
midpoint: msg.midpoint,
|
|
1143
|
+
timestamp: msg.timestamp,
|
|
1144
|
+
source: "delta"
|
|
1145
|
+
});
|
|
1146
|
+
if (!this.deferBookViews) this.callbacks.onDelta?.(outcomeId, current.view());
|
|
1147
|
+
}
|
|
1148
|
+
handleTrade(raw) {
|
|
1149
|
+
if (!this.subs.has(raw.outcomeId)) return;
|
|
1150
|
+
const msg = {
|
|
1151
|
+
...raw,
|
|
1152
|
+
timestamp: raw.timestamp / 1e3
|
|
1153
|
+
};
|
|
1154
|
+
this.callbacks.onTrade?.(msg);
|
|
1155
|
+
}
|
|
1156
|
+
handleHeartbeat(raw) {
|
|
1157
|
+
const msg = {
|
|
1158
|
+
...raw,
|
|
1159
|
+
ts: raw.ts / 1e3
|
|
1160
|
+
};
|
|
1161
|
+
this.callbacks.onHeartbeat?.(msg);
|
|
1162
|
+
}
|
|
1163
|
+
handleMarketResolved(raw) {
|
|
1164
|
+
const msg = {
|
|
1165
|
+
...raw,
|
|
1166
|
+
timestamp: raw.timestamp / 1e3
|
|
1167
|
+
};
|
|
1168
|
+
this.books.delete(msg.outcomeId);
|
|
1169
|
+
this.bookCheckedAt.delete(msg.outcomeId);
|
|
1170
|
+
this.watchdogResnapshotted.delete(msg.outcomeId);
|
|
1171
|
+
this.pendingResnapshots.delete(msg.outcomeId);
|
|
1172
|
+
this.resyncing.delete(msg.outcomeId);
|
|
1173
|
+
this.callbacks.onMarketResolved?.(msg);
|
|
1174
|
+
}
|
|
1175
|
+
handleOrderSubmitted(raw) {
|
|
1176
|
+
const msg = {
|
|
1177
|
+
...raw,
|
|
1178
|
+
timestamp: raw.timestamp / 1e3
|
|
1179
|
+
};
|
|
1180
|
+
this.callbacks.onOrderSubmitted?.(msg);
|
|
1181
|
+
}
|
|
1182
|
+
handleBalanceUpdate(raw) {
|
|
1183
|
+
const msg = {
|
|
1184
|
+
...raw,
|
|
1185
|
+
timestamp: raw.timestamp / 1e3
|
|
1186
|
+
};
|
|
1187
|
+
this.callbacks.onBalanceUpdate?.(msg);
|
|
1188
|
+
}
|
|
1189
|
+
handleRedeemEvent(raw) {
|
|
1190
|
+
const msg = {
|
|
1191
|
+
...raw,
|
|
1192
|
+
timestamp: raw.timestamp / 1e3
|
|
1193
|
+
};
|
|
1194
|
+
this.callbacks.onRedeemEvent?.(msg);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Heartbeats prove the socket, not each stream. Every subscribed outcome
|
|
1198
|
+
* with no progress for `silenceMs` is checked against the REST `seq`:
|
|
1199
|
+
*
|
|
1200
|
+
* - book present, not resyncing, same `seq` → the market is just quiet;
|
|
1201
|
+
* - different `seq`, no book (snapshot never arrived, or a resubscribe's
|
|
1202
|
+
* snapshot was lost), or stuck resyncing (any recovery whose snapshot
|
|
1203
|
+
* was lost) → resnapshot, then resubscribe on the next overdue check;
|
|
1204
|
+
* - REST can't serve it (`null`) → counted as an attempt, so the next
|
|
1205
|
+
* batch moves on to other outcomes instead of retrying the same 100.
|
|
1206
|
+
*/
|
|
1207
|
+
async checkSilentBooks() {
|
|
1208
|
+
const opts = this.bookWatchdog;
|
|
1209
|
+
if (!opts || !this._connected || this.watchdogAbort) return;
|
|
1210
|
+
const now = Date.now();
|
|
1211
|
+
if (now < this.watchdogPausedUntil) return;
|
|
1212
|
+
const silenceMs = opts.silenceMs ?? WATCHDOG_SILENCE_MS;
|
|
1213
|
+
const silent = this.activeOrderbookMarkets().filter((id) => this.bookCheckedAt.has(id)).filter((id) => now - this.bookCheckedAt.get(id) >= silenceMs).sort((a, b) => this.bookCheckedAt.get(a) - this.bookCheckedAt.get(b)).slice(0, WATCHDOG_BATCH_MAX);
|
|
1214
|
+
if (silent.length === 0) return;
|
|
1215
|
+
const checkedAt = new Map(silent.map((id) => [id, this.bookCheckedAt.get(id)]));
|
|
1216
|
+
const abort = new AbortController();
|
|
1217
|
+
this.watchdogAbort = abort;
|
|
1218
|
+
const timeout = setTimeout(() => abort.abort(), WATCHDOG_REQUEST_TIMEOUT_MS);
|
|
1219
|
+
try {
|
|
1220
|
+
const url = new URL(`${opts.baseUrl.replace(/\/$/, "")}/orderbooks/outcomes`);
|
|
1221
|
+
url.searchParams.set("venueMarketOutcomeIds", silent.join(","));
|
|
1222
|
+
url.searchParams.set("depth", "1");
|
|
1223
|
+
const response = await fetch(url, {
|
|
1224
|
+
headers: opts.headers,
|
|
1225
|
+
signal: abort.signal
|
|
1226
|
+
});
|
|
1227
|
+
if (!response.ok) {
|
|
1228
|
+
const retryAfterS = Number(response.headers.get("retry-after"));
|
|
1229
|
+
if (response.status === 429 && retryAfterS > 0) this.watchdogPausedUntil = Date.now() + retryAfterS * 1e3;
|
|
1230
|
+
this.emitDiagnostic({
|
|
1231
|
+
type: "book_watchdog_error",
|
|
1232
|
+
status: response.status
|
|
1233
|
+
});
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const body = await response.json();
|
|
1237
|
+
if (this.destroyed) return;
|
|
1238
|
+
silent.forEach((outcomeId, i) => {
|
|
1239
|
+
if (this.bookCheckedAt.get(outcomeId) !== checkedAt.get(outcomeId)) return;
|
|
1240
|
+
const server = body.data?.[i];
|
|
1241
|
+
if (!server) {
|
|
1242
|
+
this.bookCheckedAt.set(outcomeId, Date.now());
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const book = this.books.get(outcomeId);
|
|
1246
|
+
if (book && !this.resyncing.has(outcomeId) && server.seq === book.seq) {
|
|
1247
|
+
this.bookCheckedAt.set(outcomeId, Date.now());
|
|
1248
|
+
this.watchdogResnapshotted.delete(outcomeId);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const action = this.watchdogResnapshotted.has(outcomeId) ? "resubscribe" : "resnapshot";
|
|
1252
|
+
this.emitDiagnostic({
|
|
1253
|
+
type: "book_watchdog_resync",
|
|
1254
|
+
outcomeId,
|
|
1255
|
+
localSeq: book?.seq ?? null,
|
|
1256
|
+
serverSeq: server.seq,
|
|
1257
|
+
action
|
|
1258
|
+
});
|
|
1259
|
+
this.bookCheckedAt.set(outcomeId, Date.now());
|
|
1260
|
+
if (action === "resubscribe") {
|
|
1261
|
+
this.watchdogResnapshotted.delete(outcomeId);
|
|
1262
|
+
this.books.delete(outcomeId);
|
|
1263
|
+
this.resyncing.add(outcomeId);
|
|
1264
|
+
this.send({
|
|
1265
|
+
action: "unsubscribe",
|
|
1266
|
+
channel: "orderbook",
|
|
1267
|
+
outcomeIds: [outcomeId]
|
|
1268
|
+
});
|
|
1269
|
+
this.sendSubscribe([outcomeId]);
|
|
1270
|
+
} else {
|
|
1271
|
+
this.watchdogResnapshotted.add(outcomeId);
|
|
1272
|
+
this.requestResnapshot(outcomeId, true);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
} catch {
|
|
1276
|
+
if (!this.destroyed) this.emitDiagnostic({
|
|
1277
|
+
type: "book_watchdog_error",
|
|
1278
|
+
status: null
|
|
1279
|
+
});
|
|
1280
|
+
} finally {
|
|
1281
|
+
clearTimeout(timeout);
|
|
1282
|
+
if (this.watchdogAbort === abort) this.watchdogAbort = null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
requestResnapshot(outcomeId, force = false) {
|
|
1286
|
+
if (this.resyncing.has(outcomeId) && !force) return;
|
|
1287
|
+
this.resyncing.add(outcomeId);
|
|
1288
|
+
if (this._connected) {
|
|
1289
|
+
this.send({
|
|
1290
|
+
action: "resnapshot",
|
|
1291
|
+
channel: "orderbook",
|
|
1292
|
+
outcomeIds: [outcomeId]
|
|
1293
|
+
});
|
|
1294
|
+
this.pendingResnapshots.delete(outcomeId);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
this.pendingResnapshots.add(outcomeId);
|
|
1298
|
+
this.ensureConnected();
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Schedule a microtask that flushes queued subscribe/unsubscribe frames.
|
|
1302
|
+
* Coalesces multiple same-tick sub/unsub calls into at most one frame per
|
|
1303
|
+
* channel+action pair.
|
|
1304
|
+
*/
|
|
1305
|
+
scheduleFlush() {
|
|
1306
|
+
if (this.flushScheduled) return;
|
|
1307
|
+
this.flushScheduled = true;
|
|
1308
|
+
queueMicrotask(() => {
|
|
1309
|
+
this.flushScheduled = false;
|
|
1310
|
+
if (!this._connected) {
|
|
1311
|
+
this.pendingFlushSubs.clear();
|
|
1312
|
+
this.pendingFlushUnsubs.clear();
|
|
1313
|
+
this.pendingFlushTradeSubs.clear();
|
|
1314
|
+
this.pendingFlushTradeUnsubs.clear();
|
|
1315
|
+
this.pendingFlushArbSubs.clear();
|
|
1316
|
+
this.pendingFlushArbUnsubs.clear();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
this.flushPending();
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
flushPending() {
|
|
1323
|
+
for (const id of [...this.pendingFlushSubs]) if (this.pendingFlushUnsubs.has(id)) {
|
|
1324
|
+
this.pendingFlushSubs.delete(id);
|
|
1325
|
+
this.pendingFlushUnsubs.delete(id);
|
|
1326
|
+
}
|
|
1327
|
+
if (this.pendingFlushSubs.size > 0) {
|
|
1328
|
+
this.sendSubscribe(Array.from(this.pendingFlushSubs));
|
|
1329
|
+
this.pendingFlushSubs.clear();
|
|
1330
|
+
}
|
|
1331
|
+
if (this.pendingFlushUnsubs.size > 0) {
|
|
1332
|
+
this.send({
|
|
1333
|
+
action: "unsubscribe",
|
|
1334
|
+
channel: "orderbook",
|
|
1335
|
+
outcomeIds: Array.from(this.pendingFlushUnsubs)
|
|
1336
|
+
});
|
|
1337
|
+
this.pendingFlushUnsubs.clear();
|
|
1338
|
+
}
|
|
1339
|
+
for (const id of [...this.pendingFlushTradeSubs]) if (this.pendingFlushTradeUnsubs.has(id)) {
|
|
1340
|
+
this.pendingFlushTradeSubs.delete(id);
|
|
1341
|
+
this.pendingFlushTradeUnsubs.delete(id);
|
|
1342
|
+
}
|
|
1343
|
+
if (this.pendingFlushTradeSubs.size > 0) {
|
|
1344
|
+
this.send({
|
|
1345
|
+
action: "subscribe",
|
|
1346
|
+
channel: "trades",
|
|
1347
|
+
outcomeIds: Array.from(this.pendingFlushTradeSubs)
|
|
1348
|
+
});
|
|
1349
|
+
this.pendingFlushTradeSubs.clear();
|
|
1350
|
+
}
|
|
1351
|
+
if (this.pendingFlushTradeUnsubs.size > 0) {
|
|
1352
|
+
this.send({
|
|
1353
|
+
action: "unsubscribe",
|
|
1354
|
+
channel: "trades",
|
|
1355
|
+
outcomeIds: Array.from(this.pendingFlushTradeUnsubs)
|
|
1356
|
+
});
|
|
1357
|
+
this.pendingFlushTradeUnsubs.clear();
|
|
1358
|
+
}
|
|
1359
|
+
for (const id of [...this.pendingFlushArbSubs]) if (this.pendingFlushArbUnsubs.has(id)) {
|
|
1360
|
+
this.pendingFlushArbSubs.delete(id);
|
|
1361
|
+
this.pendingFlushArbUnsubs.delete(id);
|
|
1362
|
+
}
|
|
1363
|
+
if (this.pendingFlushArbSubs.size > 0) {
|
|
1364
|
+
this.send({
|
|
1365
|
+
action: "subscribe",
|
|
1366
|
+
channel: "arb",
|
|
1367
|
+
marketIds: Array.from(this.pendingFlushArbSubs)
|
|
1368
|
+
});
|
|
1369
|
+
this.pendingFlushArbSubs.clear();
|
|
1370
|
+
}
|
|
1371
|
+
if (this.pendingFlushArbUnsubs.size > 0) {
|
|
1372
|
+
this.send({
|
|
1373
|
+
action: "unsubscribe",
|
|
1374
|
+
channel: "arb",
|
|
1375
|
+
marketIds: Array.from(this.pendingFlushArbUnsubs)
|
|
1376
|
+
});
|
|
1377
|
+
this.pendingFlushArbUnsubs.clear();
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
sendSubscribe(outcomeIds) {
|
|
1381
|
+
this.send({
|
|
1382
|
+
action: "subscribe",
|
|
1383
|
+
channel: "orderbook",
|
|
1384
|
+
outcomeIds
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
send(msg) {
|
|
1388
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
|
1389
|
+
}
|
|
1390
|
+
activeOrderbookMarkets() {
|
|
1391
|
+
const markets = [];
|
|
1392
|
+
this.subs.forEach((sub, id) => {
|
|
1393
|
+
if (sub.refCount > 0) markets.push(id);
|
|
1394
|
+
});
|
|
1395
|
+
return markets;
|
|
1396
|
+
}
|
|
1397
|
+
activeTradeMarkets() {
|
|
1398
|
+
const markets = [];
|
|
1399
|
+
this.subs.forEach((sub, id) => {
|
|
1400
|
+
if (sub.tradeRefs > 0) markets.push(id);
|
|
1401
|
+
});
|
|
1402
|
+
return markets;
|
|
1403
|
+
}
|
|
1404
|
+
activeArbMarkets() {
|
|
1405
|
+
return Array.from(this.arbSubs.keys());
|
|
1406
|
+
}
|
|
1407
|
+
emitDiagnostic(event) {
|
|
1408
|
+
this.callbacks.onDiagnostics?.(event);
|
|
1409
|
+
}
|
|
1410
|
+
summarizeTopLevels(levels) {
|
|
1411
|
+
return levels.slice(0, ORDERBOOK_DEBUG_TOP_LEVELS).map((level) => ({
|
|
1412
|
+
price: level.price,
|
|
1413
|
+
size: level.size
|
|
1414
|
+
}));
|
|
1415
|
+
}
|
|
1416
|
+
debugOrderbook(event, payload) {
|
|
1417
|
+
if (!this.isOrderbookDebugEnabled()) return;
|
|
1418
|
+
console.debug(`[AggWebSocket][orderbook] ${event}`, payload);
|
|
1419
|
+
}
|
|
1420
|
+
isOrderbookDebugEnabled() {
|
|
1421
|
+
return this.enableLogs;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
//#endregion
|
|
1425
|
+
Object.defineProperty(exports, "AggWebSocket", {
|
|
1426
|
+
enumerable: true,
|
|
1427
|
+
get: function() {
|
|
1428
|
+
return AggWebSocket;
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
Object.defineProperty(exports, "applyOrderbookDelta", {
|
|
1432
|
+
enumerable: true,
|
|
1433
|
+
get: function() {
|
|
1434
|
+
return applyOrderbookDelta;
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
Object.defineProperty(exports, "computeChecksum", {
|
|
1438
|
+
enumerable: true,
|
|
1439
|
+
get: function() {
|
|
1440
|
+
return computeChecksum;
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
Object.defineProperty(exports, "decodeChartHistory", {
|
|
1444
|
+
enumerable: true,
|
|
1445
|
+
get: function() {
|
|
1446
|
+
return decodeChartHistory;
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
Object.defineProperty(exports, "loadImmutableHistory", {
|
|
1450
|
+
enumerable: true,
|
|
1451
|
+
get: function() {
|
|
1452
|
+
return loadImmutableHistory;
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
Object.defineProperty(exports, "orderbookCrc32", {
|
|
1456
|
+
enumerable: true,
|
|
1457
|
+
get: function() {
|
|
1458
|
+
return orderbookCrc32;
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
Object.defineProperty(exports, "projectedOrderbookChecksum", {
|
|
1462
|
+
enumerable: true,
|
|
1463
|
+
get: function() {
|
|
1464
|
+
return projectedOrderbookChecksum;
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
Object.defineProperty(exports, "readChartHistoryText", {
|
|
1468
|
+
enumerable: true,
|
|
1469
|
+
get: function() {
|
|
1470
|
+
return readChartHistoryText;
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
Object.defineProperty(exports, "snapshotToOrderbook", {
|
|
1474
|
+
enumerable: true,
|
|
1475
|
+
get: function() {
|
|
1476
|
+
return snapshotToOrderbook;
|
|
1477
|
+
}
|
|
1478
|
+
});
|