@tentou-tech/poly-websockets 1.0.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/LICENSE +661 -0
- package/README.md +212 -0
- package/dist/WSSubscriptionManager.d.ts +160 -0
- package/dist/WSSubscriptionManager.js +1020 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +21 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +34 -0
- package/dist/modules/OrderBookCache.d.ts +54 -0
- package/dist/modules/OrderBookCache.js +194 -0
- package/dist/types/PolymarketWebSocket.d.ts +460 -0
- package/dist/types/PolymarketWebSocket.js +86 -0
- package/dist/types/WebSocketSubscriptions.d.ts +32 -0
- package/dist/types/WebSocketSubscriptions.js +12 -0
- package/package.json +54 -0
- package/src/WSSubscriptionManager.ts +1126 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +36 -0
- package/src/modules/OrderBookCache.ts +227 -0
- package/src/types/PolymarketWebSocket.ts +538 -0
- package/src/types/WebSocketSubscriptions.ts +35 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
import ms from 'ms';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { randomInt } from 'crypto';
|
|
5
|
+
import {
|
|
6
|
+
WebSocketHandlers,
|
|
7
|
+
PriceChangeEvent,
|
|
8
|
+
BookEvent,
|
|
9
|
+
LastTradePriceEvent,
|
|
10
|
+
TickSizeChangeEvent,
|
|
11
|
+
BestBidAskEvent,
|
|
12
|
+
PolymarketWSEvent,
|
|
13
|
+
PolymarketPriceUpdateEvent,
|
|
14
|
+
isPriceChangeEvent,
|
|
15
|
+
isBookEvent,
|
|
16
|
+
isLastTradePriceEvent,
|
|
17
|
+
isTickSizeChangeEvent,
|
|
18
|
+
isBestBidAskEvent,
|
|
19
|
+
isNewMarketEvent,
|
|
20
|
+
isMarketResolvedEvent,
|
|
21
|
+
NewMarketEvent,
|
|
22
|
+
MarketResolvedEvent,
|
|
23
|
+
MarketSubscriptionMessage,
|
|
24
|
+
SubscribeMessage,
|
|
25
|
+
UnsubscribeMessage,
|
|
26
|
+
} from './types/PolymarketWebSocket';
|
|
27
|
+
import { SubscriptionManagerOptions, WebSocketConnectionStatus } from './types/WebSocketSubscriptions';
|
|
28
|
+
|
|
29
|
+
import { OrderBookCache, BookEntry } from './modules/OrderBookCache';
|
|
30
|
+
|
|
31
|
+
import { logger } from './logger';
|
|
32
|
+
import _ from 'lodash';
|
|
33
|
+
|
|
34
|
+
// Note: We intentionally use a static reconnection interval rather than exponential backoff.
|
|
35
|
+
// Perhaps change this to exponential backoff in the future.
|
|
36
|
+
const DEFAULT_RECONNECT_INTERVAL_MS = ms('5s');
|
|
37
|
+
const DEFAULT_PENDING_FLUSH_INTERVAL_MS = ms('100ms');
|
|
38
|
+
|
|
39
|
+
const CLOB_WSS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* WebSocket Subscription Manager for Polymarket CLOB WebSocket.
|
|
43
|
+
*
|
|
44
|
+
* Each instance manages a single WebSocket connection and tracks:
|
|
45
|
+
* - Subscribed assets: Successfully subscribed to the WebSocket
|
|
46
|
+
* - Pending assets: Waiting to be subscribed (batched and flushed periodically)
|
|
47
|
+
*
|
|
48
|
+
* Instances are fully independent - no shared state between managers.
|
|
49
|
+
*/
|
|
50
|
+
class WSSubscriptionManager {
|
|
51
|
+
private readonly managerId: string;
|
|
52
|
+
private handlers: WebSocketHandlers;
|
|
53
|
+
private bookCache: OrderBookCache;
|
|
54
|
+
|
|
55
|
+
// WebSocket connection
|
|
56
|
+
private wsClient: WebSocket | null = null;
|
|
57
|
+
private status: WebSocketConnectionStatus = WebSocketConnectionStatus.DISCONNECTED;
|
|
58
|
+
private connecting: boolean = false;
|
|
59
|
+
|
|
60
|
+
// Asset tracking
|
|
61
|
+
private subscribedAssetIds: Set<string> = new Set();
|
|
62
|
+
private pendingSubscribeAssetIds: Set<string> = new Set();
|
|
63
|
+
private pendingUnsubscribeAssetIds: Set<string> = new Set();
|
|
64
|
+
|
|
65
|
+
// Configuration
|
|
66
|
+
private enableCustomFeatures: boolean;
|
|
67
|
+
|
|
68
|
+
// Timers
|
|
69
|
+
private reconnectIntervalMs: number;
|
|
70
|
+
private pendingFlushIntervalMs: number;
|
|
71
|
+
private reconnectInterval?: NodeJS.Timeout;
|
|
72
|
+
private pendingFlushInterval?: NodeJS.Timeout;
|
|
73
|
+
private pingInterval?: NodeJS.Timeout;
|
|
74
|
+
private connectionTimeout?: NodeJS.Timeout;
|
|
75
|
+
|
|
76
|
+
constructor(userHandlers: WebSocketHandlers, options?: SubscriptionManagerOptions) {
|
|
77
|
+
this.managerId = uuidv4();
|
|
78
|
+
this.bookCache = new OrderBookCache();
|
|
79
|
+
|
|
80
|
+
this.reconnectIntervalMs = options?.reconnectAndCleanupIntervalMs || DEFAULT_RECONNECT_INTERVAL_MS;
|
|
81
|
+
this.pendingFlushIntervalMs = options?.pendingFlushIntervalMs || DEFAULT_PENDING_FLUSH_INTERVAL_MS;
|
|
82
|
+
this.enableCustomFeatures = options?.enableCustomFeatures || false;
|
|
83
|
+
|
|
84
|
+
this.handlers = {
|
|
85
|
+
onBook: async (events: BookEvent[]) => {
|
|
86
|
+
await this.actOnSubscribedEvents(events, userHandlers.onBook);
|
|
87
|
+
},
|
|
88
|
+
onLastTradePrice: async (events: LastTradePriceEvent[]) => {
|
|
89
|
+
await this.actOnSubscribedEvents(events, userHandlers.onLastTradePrice);
|
|
90
|
+
},
|
|
91
|
+
onTickSizeChange: async (events: TickSizeChangeEvent[]) => {
|
|
92
|
+
await this.actOnSubscribedEvents(events, userHandlers.onTickSizeChange);
|
|
93
|
+
},
|
|
94
|
+
onPriceChange: async (events: PriceChangeEvent[]) => {
|
|
95
|
+
await this.actOnSubscribedEvents(events, userHandlers.onPriceChange);
|
|
96
|
+
},
|
|
97
|
+
onBestBidAsk: async (events: BestBidAskEvent[]) => {
|
|
98
|
+
await this.actOnSubscribedEvents(events, userHandlers.onBestBidAsk);
|
|
99
|
+
},
|
|
100
|
+
onPolymarketPriceUpdate: async (events: PolymarketPriceUpdateEvent[]) => {
|
|
101
|
+
await this.actOnSubscribedEvents(events, userHandlers.onPolymarketPriceUpdate);
|
|
102
|
+
},
|
|
103
|
+
onNewMarket: userHandlers.onNewMarket,
|
|
104
|
+
onMarketResolved: userHandlers.onMarketResolved,
|
|
105
|
+
onWSClose: userHandlers.onWSClose,
|
|
106
|
+
onWSOpen: userHandlers.onWSOpen,
|
|
107
|
+
onError: userHandlers.onError
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Periodic reconnection check
|
|
111
|
+
this.scheduleReconnectionCheck();
|
|
112
|
+
|
|
113
|
+
// Periodic pending flush
|
|
114
|
+
this.pendingFlushInterval = setInterval(() => {
|
|
115
|
+
this.flushPendingSubscriptions();
|
|
116
|
+
}, this.pendingFlushIntervalMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clears all WebSocket subscriptions and state.
|
|
121
|
+
*
|
|
122
|
+
* This will:
|
|
123
|
+
* 1. Stop all timers
|
|
124
|
+
* 2. Close the WebSocket connection
|
|
125
|
+
* 3. Clear all asset tracking
|
|
126
|
+
* 4. Clear the order book cache
|
|
127
|
+
*/
|
|
128
|
+
public async clearState() {
|
|
129
|
+
// Stop all timers (reconnectInterval is now a timeout, not interval)
|
|
130
|
+
if (this.reconnectInterval) {
|
|
131
|
+
clearTimeout(this.reconnectInterval);
|
|
132
|
+
this.reconnectInterval = undefined;
|
|
133
|
+
}
|
|
134
|
+
if (this.pendingFlushInterval) {
|
|
135
|
+
clearInterval(this.pendingFlushInterval);
|
|
136
|
+
this.pendingFlushInterval = undefined;
|
|
137
|
+
}
|
|
138
|
+
if (this.pingInterval) {
|
|
139
|
+
clearInterval(this.pingInterval);
|
|
140
|
+
this.pingInterval = undefined;
|
|
141
|
+
}
|
|
142
|
+
if (this.connectionTimeout) {
|
|
143
|
+
clearTimeout(this.connectionTimeout);
|
|
144
|
+
this.connectionTimeout = undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Close WebSocket
|
|
148
|
+
if (this.wsClient) {
|
|
149
|
+
this.wsClient.removeAllListeners();
|
|
150
|
+
this.wsClient.close();
|
|
151
|
+
this.wsClient = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clear all asset tracking
|
|
155
|
+
this.subscribedAssetIds.clear();
|
|
156
|
+
this.pendingSubscribeAssetIds.clear();
|
|
157
|
+
this.pendingUnsubscribeAssetIds.clear();
|
|
158
|
+
|
|
159
|
+
// Reset status
|
|
160
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
161
|
+
this.connecting = false;
|
|
162
|
+
|
|
163
|
+
// Clear the order book cache
|
|
164
|
+
this.bookCache.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Filters events to only include those for subscribed assets.
|
|
169
|
+
* Wraps user handler calls in try-catch to prevent user errors from breaking internal logic.
|
|
170
|
+
* Does not call the handler if all events are filtered out.
|
|
171
|
+
*/
|
|
172
|
+
private async actOnSubscribedEvents<T extends PolymarketWSEvent | PolymarketPriceUpdateEvent>(
|
|
173
|
+
events: T[],
|
|
174
|
+
action?: (events: T[]) => Promise<void>
|
|
175
|
+
) {
|
|
176
|
+
events = events.filter((event: T) => {
|
|
177
|
+
if (isPriceChangeEvent(event)) {
|
|
178
|
+
return event.price_changes.some(pc => this.subscribedAssetIds.has(pc.asset_id));
|
|
179
|
+
}
|
|
180
|
+
if ('asset_id' in event) {
|
|
181
|
+
return this.subscribedAssetIds.has(event.asset_id);
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Skip if no events passed the filter
|
|
187
|
+
if (events.length === 0) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wrap user handler calls in try-catch
|
|
192
|
+
try {
|
|
193
|
+
await action?.(events);
|
|
194
|
+
} catch (handlerErr) {
|
|
195
|
+
logger.warn({
|
|
196
|
+
message: 'Error in user event handler',
|
|
197
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
198
|
+
managerId: this.managerId,
|
|
199
|
+
eventCount: events.length,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Adds new subscriptions.
|
|
206
|
+
*
|
|
207
|
+
* Assets are added to a pending queue and will be subscribed when:
|
|
208
|
+
* - The WebSocket connects (initial subscription)
|
|
209
|
+
* - The pending flush timer fires (for new assets on an existing connection)
|
|
210
|
+
*
|
|
211
|
+
* @param assetIdsToAdd - The asset IDs to add subscriptions for.
|
|
212
|
+
*/
|
|
213
|
+
public async addSubscriptions(assetIdsToAdd: string[]) {
|
|
214
|
+
try {
|
|
215
|
+
for (const assetId of assetIdsToAdd) {
|
|
216
|
+
// Remove from pending unsubscribe if it's there (cancel the unsubscription)
|
|
217
|
+
// This must happen BEFORE the subscribed check, so that adding an asset
|
|
218
|
+
// that was pending unsubscribe correctly cancels the unsubscription.
|
|
219
|
+
this.pendingUnsubscribeAssetIds.delete(assetId);
|
|
220
|
+
|
|
221
|
+
// Skip if already subscribed (pending subscribe is safe to re-add due to Set behavior)
|
|
222
|
+
if (this.subscribedAssetIds.has(assetId)) continue;
|
|
223
|
+
|
|
224
|
+
// Add to pending subscribe (no-op if already pending due to Set)
|
|
225
|
+
this.pendingSubscribeAssetIds.add(assetId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Restart intervals if they were cleared (e.g. after clearState)
|
|
229
|
+
this.ensureIntervalsRunning();
|
|
230
|
+
|
|
231
|
+
// Ensure we have a connection
|
|
232
|
+
if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
|
|
233
|
+
await this.connect();
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const msg = `Error adding subscriptions: ${error instanceof Error ? error.message : String(error)}`;
|
|
237
|
+
await this.safeCallErrorHandler(new Error(msg));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Ensures the periodic intervals are running.
|
|
243
|
+
* Called after clearState() when new subscriptions are added.
|
|
244
|
+
*/
|
|
245
|
+
private ensureIntervalsRunning() {
|
|
246
|
+
if (!this.reconnectInterval) {
|
|
247
|
+
this.scheduleReconnectionCheck();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!this.pendingFlushInterval) {
|
|
251
|
+
this.pendingFlushInterval = setInterval(() => {
|
|
252
|
+
this.flushPendingSubscriptions();
|
|
253
|
+
}, this.pendingFlushIntervalMs);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Schedules the next reconnection check.
|
|
259
|
+
* Uses a fixed interval (default 5 seconds) between checks.
|
|
260
|
+
*/
|
|
261
|
+
private scheduleReconnectionCheck() {
|
|
262
|
+
if (this.reconnectInterval) {
|
|
263
|
+
clearTimeout(this.reconnectInterval);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.reconnectInterval = setTimeout(async () => {
|
|
267
|
+
await this.checkReconnection();
|
|
268
|
+
// Schedule next check (only if not cleared)
|
|
269
|
+
if (this.reconnectInterval) {
|
|
270
|
+
this.scheduleReconnectionCheck();
|
|
271
|
+
}
|
|
272
|
+
}, this.reconnectIntervalMs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Safely calls the error handler, catching any exceptions thrown by it.
|
|
277
|
+
* Prevents user handler exceptions from breaking internal logic.
|
|
278
|
+
*/
|
|
279
|
+
private async safeCallErrorHandler(error: Error): Promise<void> {
|
|
280
|
+
try {
|
|
281
|
+
await this.handlers.onError?.(error);
|
|
282
|
+
} catch (handlerErr) {
|
|
283
|
+
logger.warn({
|
|
284
|
+
message: 'Error in onError handler',
|
|
285
|
+
originalError: error.message,
|
|
286
|
+
handlerError: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
287
|
+
managerId: this.managerId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Removes subscriptions.
|
|
294
|
+
*
|
|
295
|
+
* @param assetIdsToRemove - The asset IDs to remove subscriptions for.
|
|
296
|
+
*/
|
|
297
|
+
public async removeSubscriptions(assetIdsToRemove: string[]) {
|
|
298
|
+
try {
|
|
299
|
+
for (const assetId of assetIdsToRemove) {
|
|
300
|
+
// Remove from pending subscribe if it's there
|
|
301
|
+
if (this.pendingSubscribeAssetIds.delete(assetId)) {
|
|
302
|
+
continue; // Was only pending, no need to send unsubscribe
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// If subscribed, add to pending unsubscribe
|
|
306
|
+
if (this.subscribedAssetIds.has(assetId)) {
|
|
307
|
+
this.pendingUnsubscribeAssetIds.add(assetId);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Note: We don't clear the book cache here because the unsubscription
|
|
311
|
+
// hasn't been sent yet. The cache entry will be cleared when the
|
|
312
|
+
// unsubscription is flushed (after the asset is removed from subscribedAssetIds).
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const errMsg = `Error removing subscriptions: ${error instanceof Error ? error.message : String(error)}`;
|
|
316
|
+
await this.safeCallErrorHandler(new Error(errMsg));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Get all currently monitored asset IDs.
|
|
322
|
+
* This includes both successfully subscribed assets and pending subscriptions.
|
|
323
|
+
*
|
|
324
|
+
* @returns Array of asset IDs being monitored.
|
|
325
|
+
*/
|
|
326
|
+
public getAssetIds(): string[] {
|
|
327
|
+
const allAssets = new Set<string>(this.subscribedAssetIds);
|
|
328
|
+
for (const assetId of this.pendingSubscribeAssetIds) {
|
|
329
|
+
allAssets.add(assetId);
|
|
330
|
+
}
|
|
331
|
+
// Exclude pending unsubscribes
|
|
332
|
+
for (const assetId of this.pendingUnsubscribeAssetIds) {
|
|
333
|
+
allAssets.delete(assetId);
|
|
334
|
+
}
|
|
335
|
+
return Array.from(allAssets);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Returns statistics about the current state of the subscription manager.
|
|
340
|
+
*/
|
|
341
|
+
public getStatistics(): {
|
|
342
|
+
openWebSockets: number;
|
|
343
|
+
assetIds: number;
|
|
344
|
+
pendingSubscribeCount: number;
|
|
345
|
+
pendingUnsubscribeCount: number;
|
|
346
|
+
/** @deprecated Use pendingSubscribeCount + pendingUnsubscribeCount instead */
|
|
347
|
+
pendingAssetIds: number;
|
|
348
|
+
} {
|
|
349
|
+
const isOpen = this.wsClient?.readyState === WebSocket.OPEN;
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
openWebSockets: isOpen ? 1 : 0,
|
|
353
|
+
assetIds: this.getAssetIds().length,
|
|
354
|
+
pendingSubscribeCount: this.pendingSubscribeAssetIds.size,
|
|
355
|
+
pendingUnsubscribeCount: this.pendingUnsubscribeAssetIds.size,
|
|
356
|
+
pendingAssetIds: this.pendingSubscribeAssetIds.size + this.pendingUnsubscribeAssetIds.size,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Flush pending subscriptions and unsubscriptions to the WebSocket.
|
|
362
|
+
*
|
|
363
|
+
* SUBSCRIPTION PROTOCOL NOTE:
|
|
364
|
+
* The Polymarket WebSocket protocol does NOT send any confirmation or acknowledgment
|
|
365
|
+
* messages for subscribe/unsubscribe operations. The server silently processes these
|
|
366
|
+
* requests. We optimistically assume success after sending. If the server rejects
|
|
367
|
+
* a request (e.g., invalid asset ID), events for those assets simply won't arrive -
|
|
368
|
+
* there is no error response to handle.
|
|
369
|
+
*
|
|
370
|
+
* This means:
|
|
371
|
+
* - We cannot definitively know if a subscription succeeded
|
|
372
|
+
* - We cannot definitively know if an unsubscription succeeded
|
|
373
|
+
* - The only indication of failure is the absence of expected events
|
|
374
|
+
*/
|
|
375
|
+
private flushPendingSubscriptions() {
|
|
376
|
+
if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Process unsubscriptions first
|
|
381
|
+
if (this.pendingUnsubscribeAssetIds.size > 0) {
|
|
382
|
+
const toUnsubscribe = Array.from(this.pendingUnsubscribeAssetIds);
|
|
383
|
+
|
|
384
|
+
const message: UnsubscribeMessage = {
|
|
385
|
+
operation: 'unsubscribe',
|
|
386
|
+
assets_ids: toUnsubscribe,
|
|
387
|
+
custom_feature_enabled: this.enableCustomFeatures,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
|
|
391
|
+
// or acknowledgment message for subscribe/unsubscribe operations. The server
|
|
392
|
+
// silently accepts the request. We optimistically assume success after sending.
|
|
393
|
+
// If the server rejects the request, events for those assets simply won't arrive
|
|
394
|
+
// (there is no error response to handle).
|
|
395
|
+
try {
|
|
396
|
+
this.wsClient.send(JSON.stringify(message));
|
|
397
|
+
// Remove from subscribed, clear pending, and clear book cache for unsubscribed assets
|
|
398
|
+
for (const assetId of toUnsubscribe) {
|
|
399
|
+
this.subscribedAssetIds.delete(assetId);
|
|
400
|
+
this.bookCache.clear(assetId);
|
|
401
|
+
}
|
|
402
|
+
this.pendingUnsubscribeAssetIds.clear();
|
|
403
|
+
|
|
404
|
+
logger.info({
|
|
405
|
+
message: `Unsubscribed from ${toUnsubscribe.length} asset(s)`,
|
|
406
|
+
managerId: this.managerId,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
logger.warn({
|
|
410
|
+
message: 'Failed to send unsubscribe message',
|
|
411
|
+
error: error instanceof Error ? error.message : String(error),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Process subscriptions
|
|
417
|
+
if (this.pendingSubscribeAssetIds.size > 0) {
|
|
418
|
+
const toSubscribe = Array.from(this.pendingSubscribeAssetIds);
|
|
419
|
+
|
|
420
|
+
const message: SubscribeMessage = {
|
|
421
|
+
operation: 'subscribe',
|
|
422
|
+
assets_ids: toSubscribe,
|
|
423
|
+
custom_feature_enabled: this.enableCustomFeatures,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
|
|
427
|
+
// or acknowledgment message for subscribe/unsubscribe operations. The server
|
|
428
|
+
// silently accepts the request. We optimistically assume success after sending.
|
|
429
|
+
// If the server rejects the request, events for those assets simply won't arrive
|
|
430
|
+
// (there is no error response to handle).
|
|
431
|
+
try {
|
|
432
|
+
this.wsClient.send(JSON.stringify(message));
|
|
433
|
+
// Move to subscribed and clear pending
|
|
434
|
+
for (const assetId of toSubscribe) {
|
|
435
|
+
this.subscribedAssetIds.add(assetId);
|
|
436
|
+
}
|
|
437
|
+
this.pendingSubscribeAssetIds.clear();
|
|
438
|
+
|
|
439
|
+
logger.info({
|
|
440
|
+
message: `Subscribed to ${toSubscribe.length} asset(s)`,
|
|
441
|
+
managerId: this.managerId,
|
|
442
|
+
});
|
|
443
|
+
} catch (error) {
|
|
444
|
+
logger.warn({
|
|
445
|
+
message: 'Failed to send subscribe message',
|
|
446
|
+
error: error instanceof Error ? error.message : String(error),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Close WebSocket if no assets remain and no pending unsubscriptions
|
|
452
|
+
// (pendingUnsubscribeAssetIds is already cleared at this point)
|
|
453
|
+
if (this.subscribedAssetIds.size === 0 &&
|
|
454
|
+
this.pendingSubscribeAssetIds.size === 0 &&
|
|
455
|
+
this.pendingUnsubscribeAssetIds.size === 0) {
|
|
456
|
+
this.closeWebSocket();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Closes the WebSocket connection and cleans up related resources.
|
|
462
|
+
*/
|
|
463
|
+
private closeWebSocket() {
|
|
464
|
+
if (this.wsClient) {
|
|
465
|
+
logger.info({
|
|
466
|
+
message: 'Closing WebSocket - no assets to monitor',
|
|
467
|
+
managerId: this.managerId,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
this.wsClient.removeAllListeners();
|
|
471
|
+
this.wsClient.close();
|
|
472
|
+
this.wsClient = null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
476
|
+
this.connecting = false;
|
|
477
|
+
|
|
478
|
+
if (this.pingInterval) {
|
|
479
|
+
clearInterval(this.pingInterval);
|
|
480
|
+
this.pingInterval = undefined;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Check if we need to reconnect.
|
|
486
|
+
* Note: Assets are moved to pending in handleClose/handleError handlers,
|
|
487
|
+
* so this method only needs to check if reconnection is needed.
|
|
488
|
+
*/
|
|
489
|
+
private async checkReconnection() {
|
|
490
|
+
// If we have pending assets but no connection, reconnect
|
|
491
|
+
const hasPendingAssets = this.pendingSubscribeAssetIds.size > 0;
|
|
492
|
+
const isDisconnected = !this.wsClient || this.wsClient.readyState !== WebSocket.OPEN;
|
|
493
|
+
|
|
494
|
+
if (hasPendingAssets && isDisconnected && !this.connecting) {
|
|
495
|
+
logger.info({
|
|
496
|
+
message: 'Reconnection check - attempting to reconnect',
|
|
497
|
+
managerId: this.managerId,
|
|
498
|
+
pendingCount: this.pendingSubscribeAssetIds.size,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Clear stale book cache data - will be repopulated after reconnection
|
|
502
|
+
this.bookCache.clear();
|
|
503
|
+
|
|
504
|
+
await this.connect();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Establish the WebSocket connection.
|
|
510
|
+
*/
|
|
511
|
+
private async connect(): Promise<void> {
|
|
512
|
+
if (this.connecting) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (this.wsClient?.readyState === WebSocket.OPEN) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// No assets to subscribe to
|
|
520
|
+
if (this.pendingSubscribeAssetIds.size === 0 && this.subscribedAssetIds.size === 0) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
this.connecting = true;
|
|
525
|
+
this.status = WebSocketConnectionStatus.CONNECTING;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
logger.info({
|
|
529
|
+
message: 'Connecting to CLOB WebSocket',
|
|
530
|
+
managerId: this.managerId,
|
|
531
|
+
pendingAssetCount: this.pendingSubscribeAssetIds.size,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
this.wsClient = new WebSocket(CLOB_WSS_URL);
|
|
535
|
+
|
|
536
|
+
// Set up event handlers immediately (handlers are set up before any events can fire)
|
|
537
|
+
this.setupEventHandlers();
|
|
538
|
+
|
|
539
|
+
// Connection timeout
|
|
540
|
+
this.connectionTimeout = setTimeout(async () => {
|
|
541
|
+
if (this.connecting && this.wsClient && this.wsClient.readyState !== WebSocket.OPEN) {
|
|
542
|
+
logger.warn({
|
|
543
|
+
message: 'WebSocket connection timeout',
|
|
544
|
+
managerId: this.managerId,
|
|
545
|
+
});
|
|
546
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
547
|
+
this.connecting = false;
|
|
548
|
+
if (this.wsClient) {
|
|
549
|
+
this.wsClient.removeAllListeners();
|
|
550
|
+
this.wsClient.close();
|
|
551
|
+
this.wsClient = null;
|
|
552
|
+
}
|
|
553
|
+
// Notify error handler about the timeout
|
|
554
|
+
await this.safeCallErrorHandler(new Error('WebSocket connection timeout after 30s'));
|
|
555
|
+
}
|
|
556
|
+
}, ms('30s'));
|
|
557
|
+
|
|
558
|
+
} catch (err) {
|
|
559
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
560
|
+
this.connecting = false;
|
|
561
|
+
throw err;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Sets up event handlers for the WebSocket connection.
|
|
567
|
+
*/
|
|
568
|
+
private setupEventHandlers() {
|
|
569
|
+
const ws = this.wsClient;
|
|
570
|
+
if (!ws) return;
|
|
571
|
+
|
|
572
|
+
const handleOpen = async () => {
|
|
573
|
+
this.status = WebSocketConnectionStatus.CONNECTED;
|
|
574
|
+
this.connecting = false;
|
|
575
|
+
|
|
576
|
+
if (this.connectionTimeout) {
|
|
577
|
+
clearTimeout(this.connectionTimeout);
|
|
578
|
+
this.connectionTimeout = undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Send an empty MarketSubscriptionMessage as the initial handshake.
|
|
582
|
+
// The Polymarket WebSocket protocol requires a 'market' type message as the first message.
|
|
583
|
+
// We send it with an empty assets_ids array, and then use 'subscribe' operation messages
|
|
584
|
+
// for all actual subscriptions (via flushPendingSubscriptions) to keep the subscription
|
|
585
|
+
// logic consistent in one place.
|
|
586
|
+
try {
|
|
587
|
+
const initMessage: MarketSubscriptionMessage = {
|
|
588
|
+
assets_ids: [],
|
|
589
|
+
type: 'market',
|
|
590
|
+
custom_feature_enabled: this.enableCustomFeatures,
|
|
591
|
+
};
|
|
592
|
+
ws.send(JSON.stringify(initMessage));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logger.warn({
|
|
595
|
+
message: 'Failed to send initial market message',
|
|
596
|
+
error: error instanceof Error ? error.message : String(error),
|
|
597
|
+
managerId: this.managerId,
|
|
598
|
+
});
|
|
599
|
+
// Close and let reconnection logic handle retry
|
|
600
|
+
// Wrap in try-catch as close() can throw in edge cases
|
|
601
|
+
try {
|
|
602
|
+
ws.close();
|
|
603
|
+
} catch (closeErr) {
|
|
604
|
+
logger.debug({
|
|
605
|
+
message: 'Error closing WebSocket after init message failure (safe to ignore)',
|
|
606
|
+
error: closeErr instanceof Error ? closeErr.message : String(closeErr),
|
|
607
|
+
managerId: this.managerId,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
const pendingAssets = Array.from(this.pendingSubscribeAssetIds);
|
|
615
|
+
|
|
616
|
+
// Safely call open handler
|
|
617
|
+
try {
|
|
618
|
+
await this.handlers.onWSOpen?.(this.managerId, pendingAssets);
|
|
619
|
+
} catch (handlerErr) {
|
|
620
|
+
logger.warn({
|
|
621
|
+
message: 'Error in onWSOpen handler',
|
|
622
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
623
|
+
managerId: this.managerId,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Immediately flush pending subscriptions now that we're connected
|
|
628
|
+
this.flushPendingSubscriptions();
|
|
629
|
+
|
|
630
|
+
// Start ping interval with jitter per-ping
|
|
631
|
+
const basePingIntervalMs = ms('20s');
|
|
632
|
+
this.pingInterval = setInterval(() => {
|
|
633
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
634
|
+
if (this.pingInterval) {
|
|
635
|
+
clearInterval(this.pingInterval);
|
|
636
|
+
this.pingInterval = undefined;
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// Add jitter by randomly delaying the ping within a ±5s window
|
|
641
|
+
const jitterMs = randomInt(0, ms('5s'));
|
|
642
|
+
setTimeout(() => {
|
|
643
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
644
|
+
try {
|
|
645
|
+
ws.ping();
|
|
646
|
+
} catch (pingErr) {
|
|
647
|
+
// Ping can fail if the socket is closing or in a bad state.
|
|
648
|
+
// This is not critical - the socket will be cleaned up on close/error events.
|
|
649
|
+
logger.debug({
|
|
650
|
+
message: 'Ping failed (safe to ignore)',
|
|
651
|
+
error: pingErr instanceof Error ? pingErr.message : String(pingErr),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}, jitterMs);
|
|
656
|
+
}, basePingIntervalMs);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const handleMessage = async (data: Buffer) => {
|
|
660
|
+
try {
|
|
661
|
+
const messageStr = data.toString();
|
|
662
|
+
const normalizedMessageStr = messageStr.trim().toUpperCase();
|
|
663
|
+
|
|
664
|
+
if (normalizedMessageStr === 'PONG') {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let events: PolymarketWSEvent[] = [];
|
|
669
|
+
try {
|
|
670
|
+
const parsedData: any = JSON.parse(messageStr);
|
|
671
|
+
events = Array.isArray(parsedData) ? parsedData : [parsedData];
|
|
672
|
+
} catch (err) {
|
|
673
|
+
await this.safeCallErrorHandler(new Error(`Not JSON: ${messageStr}`));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
events = _.filter(events, (event: PolymarketWSEvent) => {
|
|
678
|
+
if (!event) return false;
|
|
679
|
+
if (isPriceChangeEvent(event)) {
|
|
680
|
+
return event.price_changes && event.price_changes.length > 0;
|
|
681
|
+
}
|
|
682
|
+
// Market-level events don't have asset_id, so include them
|
|
683
|
+
if (isNewMarketEvent(event) || isMarketResolvedEvent(event)) {
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
// Asset-level events must have an asset_id
|
|
687
|
+
return 'asset_id' in event && _.size(event.asset_id) > 0;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const bookEvents: BookEvent[] = [];
|
|
691
|
+
const lastTradeEvents: LastTradePriceEvent[] = [];
|
|
692
|
+
const tickEvents: TickSizeChangeEvent[] = [];
|
|
693
|
+
const priceChangeEvents: PriceChangeEvent[] = [];
|
|
694
|
+
const bestBidAskEvents: BestBidAskEvent[] = [];
|
|
695
|
+
const newMarketEvents: NewMarketEvent[] = [];
|
|
696
|
+
const marketResolvedEvents: MarketResolvedEvent[] = [];
|
|
697
|
+
|
|
698
|
+
for (const event of events) {
|
|
699
|
+
if (isPriceChangeEvent(event)) {
|
|
700
|
+
const relevantChanges = event.price_changes.filter(
|
|
701
|
+
pc => this.subscribedAssetIds.has(pc.asset_id)
|
|
702
|
+
);
|
|
703
|
+
if (relevantChanges.length === 0) continue;
|
|
704
|
+
priceChangeEvents.push({
|
|
705
|
+
...event,
|
|
706
|
+
price_changes: relevantChanges
|
|
707
|
+
});
|
|
708
|
+
} else if (isNewMarketEvent(event)) {
|
|
709
|
+
// Market-level events bypass asset filtering
|
|
710
|
+
newMarketEvents.push(event);
|
|
711
|
+
} else if (isMarketResolvedEvent(event)) {
|
|
712
|
+
// Market-level events bypass asset filtering
|
|
713
|
+
marketResolvedEvents.push(event);
|
|
714
|
+
} else {
|
|
715
|
+
// Safely check asset_id existence
|
|
716
|
+
const assetId = 'asset_id' in event ? event.asset_id : undefined;
|
|
717
|
+
if (!assetId || !this.subscribedAssetIds.has(assetId)) continue;
|
|
718
|
+
|
|
719
|
+
if (isBookEvent(event)) {
|
|
720
|
+
bookEvents.push(event);
|
|
721
|
+
} else if (isLastTradePriceEvent(event)) {
|
|
722
|
+
lastTradeEvents.push(event);
|
|
723
|
+
} else if (isTickSizeChangeEvent(event)) {
|
|
724
|
+
tickEvents.push(event);
|
|
725
|
+
} else if (isBestBidAskEvent(event)) {
|
|
726
|
+
bestBidAskEvents.push(event);
|
|
727
|
+
} else {
|
|
728
|
+
await this.safeCallErrorHandler(new Error(`Unknown event: ${JSON.stringify(event)}`));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Wrap each handler call in try-catch to prevent one failure
|
|
734
|
+
// from breaking the entire event loop
|
|
735
|
+
try {
|
|
736
|
+
await this.handleBookEvents(bookEvents);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
logger.warn({
|
|
739
|
+
message: 'Error in handleBookEvents',
|
|
740
|
+
error: err instanceof Error ? err.message : String(err),
|
|
741
|
+
managerId: this.managerId,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
await this.handleTickEvents(tickEvents);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
logger.warn({
|
|
749
|
+
message: 'Error in handleTickEvents',
|
|
750
|
+
error: err instanceof Error ? err.message : String(err),
|
|
751
|
+
managerId: this.managerId,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
await this.handlePriceChangeEvents(priceChangeEvents);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
logger.warn({
|
|
759
|
+
message: 'Error in handlePriceChangeEvents',
|
|
760
|
+
error: err instanceof Error ? err.message : String(err),
|
|
761
|
+
managerId: this.managerId,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
await this.handleLastTradeEvents(lastTradeEvents);
|
|
767
|
+
} catch (err) {
|
|
768
|
+
logger.warn({
|
|
769
|
+
message: 'Error in handleLastTradeEvents',
|
|
770
|
+
error: err instanceof Error ? err.message : String(err),
|
|
771
|
+
managerId: this.managerId,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
await this.handleBestBidAskEvents(bestBidAskEvents);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
logger.warn({
|
|
779
|
+
message: 'Error in handleBestBidAskEvents',
|
|
780
|
+
error: err instanceof Error ? err.message : String(err),
|
|
781
|
+
managerId: this.managerId,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
await this.handleNewMarketEvents(newMarketEvents);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
logger.warn({
|
|
789
|
+
message: 'Error in handleNewMarketEvents',
|
|
790
|
+
error: err instanceof Error ? err.message : String(err),
|
|
791
|
+
managerId: this.managerId,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
await this.handleMarketResolvedEvents(marketResolvedEvents);
|
|
797
|
+
} catch (err) {
|
|
798
|
+
logger.warn({
|
|
799
|
+
message: 'Error in handleMarketResolvedEvents',
|
|
800
|
+
error: err instanceof Error ? err.message : String(err),
|
|
801
|
+
managerId: this.managerId,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
} catch (err) {
|
|
805
|
+
await this.safeCallErrorHandler(new Error(`Error handling message: ${err}`));
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const handlePong = () => {
|
|
810
|
+
// Pong received - connection is alive
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const handleError = async (err: Error) => {
|
|
814
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
815
|
+
this.connecting = false;
|
|
816
|
+
|
|
817
|
+
// Clean up WebSocket reference - close the socket before nullifying
|
|
818
|
+
// Wrap in try-catch because close() can throw if socket is in a bad state
|
|
819
|
+
if (this.wsClient) {
|
|
820
|
+
this.wsClient.removeAllListeners();
|
|
821
|
+
try {
|
|
822
|
+
this.wsClient.close();
|
|
823
|
+
} catch (closeErr) {
|
|
824
|
+
logger.debug({
|
|
825
|
+
message: 'Error closing WebSocket in error handler (safe to ignore)',
|
|
826
|
+
error: closeErr instanceof Error ? closeErr.message : String(closeErr),
|
|
827
|
+
managerId: this.managerId,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
this.wsClient = null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (this.pingInterval) {
|
|
834
|
+
clearInterval(this.pingInterval);
|
|
835
|
+
this.pingInterval = undefined;
|
|
836
|
+
}
|
|
837
|
+
if (this.connectionTimeout) {
|
|
838
|
+
clearTimeout(this.connectionTimeout);
|
|
839
|
+
this.connectionTimeout = undefined;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Move subscribed assets back to pending for re-subscription,
|
|
843
|
+
// but skip assets that user wanted to unsubscribe (preserve user intent)
|
|
844
|
+
for (const assetId of this.subscribedAssetIds) {
|
|
845
|
+
if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
|
|
846
|
+
this.pendingSubscribeAssetIds.add(assetId);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
this.subscribedAssetIds.clear();
|
|
850
|
+
|
|
851
|
+
// Clear pending unsubscribes - they were either:
|
|
852
|
+
// 1. Successfully excluded from re-subscription (above), or
|
|
853
|
+
// 2. Were never subscribed anyway (user's intent is preserved)
|
|
854
|
+
this.pendingUnsubscribeAssetIds.clear();
|
|
855
|
+
|
|
856
|
+
// Clear the book cache - data is stale and will be repopulated on reconnection
|
|
857
|
+
this.bookCache.clear();
|
|
858
|
+
|
|
859
|
+
// Safely call error handler
|
|
860
|
+
try {
|
|
861
|
+
await this.handlers.onError?.(new Error(`WebSocket error: ${err.message}`));
|
|
862
|
+
} catch (handlerErr) {
|
|
863
|
+
logger.warn({
|
|
864
|
+
message: 'Error in onError handler',
|
|
865
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
866
|
+
managerId: this.managerId,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const handleClose = async (code: number, reason?: Buffer) => {
|
|
872
|
+
this.status = WebSocketConnectionStatus.DISCONNECTED;
|
|
873
|
+
this.connecting = false;
|
|
874
|
+
|
|
875
|
+
// Clean up WebSocket reference
|
|
876
|
+
if (this.wsClient) {
|
|
877
|
+
this.wsClient.removeAllListeners();
|
|
878
|
+
this.wsClient = null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (this.pingInterval) {
|
|
882
|
+
clearInterval(this.pingInterval);
|
|
883
|
+
this.pingInterval = undefined;
|
|
884
|
+
}
|
|
885
|
+
if (this.connectionTimeout) {
|
|
886
|
+
clearTimeout(this.connectionTimeout);
|
|
887
|
+
this.connectionTimeout = undefined;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Move subscribed assets back to pending for re-subscription,
|
|
891
|
+
// but skip assets that user wanted to unsubscribe (preserve user intent)
|
|
892
|
+
for (const assetId of this.subscribedAssetIds) {
|
|
893
|
+
if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
|
|
894
|
+
this.pendingSubscribeAssetIds.add(assetId);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
this.subscribedAssetIds.clear();
|
|
898
|
+
|
|
899
|
+
// Clear pending unsubscribes - they were either:
|
|
900
|
+
// 1. Successfully excluded from re-subscription (above), or
|
|
901
|
+
// 2. Were never subscribed anyway (user's intent is preserved)
|
|
902
|
+
this.pendingUnsubscribeAssetIds.clear();
|
|
903
|
+
|
|
904
|
+
// Clear the book cache - data is stale and will be repopulated on reconnection
|
|
905
|
+
this.bookCache.clear();
|
|
906
|
+
|
|
907
|
+
// Safely call close handler
|
|
908
|
+
try {
|
|
909
|
+
await this.handlers.onWSClose?.(this.managerId, code, reason?.toString() || '');
|
|
910
|
+
} catch (handlerErr) {
|
|
911
|
+
logger.warn({
|
|
912
|
+
message: 'Error in onWSClose handler',
|
|
913
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
914
|
+
managerId: this.managerId,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
ws.removeAllListeners();
|
|
920
|
+
ws.on('open', handleOpen);
|
|
921
|
+
ws.on('message', handleMessage);
|
|
922
|
+
ws.on('pong', handlePong);
|
|
923
|
+
ws.on('error', handleError);
|
|
924
|
+
ws.on('close', handleClose);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Handles book events by updating the cache and notifying listeners.
|
|
929
|
+
*/
|
|
930
|
+
private async handleBookEvents(bookEvents: BookEvent[]): Promise<void> {
|
|
931
|
+
if (bookEvents.length) {
|
|
932
|
+
for (const event of bookEvents) {
|
|
933
|
+
this.bookCache.replaceBook(event);
|
|
934
|
+
}
|
|
935
|
+
await this.handlers.onBook?.(bookEvents);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Handles tick size change events by notifying listeners.
|
|
941
|
+
*/
|
|
942
|
+
private async handleTickEvents(tickEvents: TickSizeChangeEvent[]): Promise<void> {
|
|
943
|
+
if (tickEvents.length) {
|
|
944
|
+
await this.handlers.onTickSizeChange?.(tickEvents);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Handles price change events.
|
|
950
|
+
*/
|
|
951
|
+
private async handlePriceChangeEvents(priceChangeEvents: PriceChangeEvent[]): Promise<void> {
|
|
952
|
+
if (priceChangeEvents.length) {
|
|
953
|
+
await this.handlers.onPriceChange?.(priceChangeEvents);
|
|
954
|
+
|
|
955
|
+
for (const event of priceChangeEvents) {
|
|
956
|
+
try {
|
|
957
|
+
this.bookCache.upsertPriceChange(event);
|
|
958
|
+
} catch (err: any) {
|
|
959
|
+
logger.debug({
|
|
960
|
+
message: `Skipping derived future price calculation price_change: book not found for asset`,
|
|
961
|
+
event: event,
|
|
962
|
+
error: err?.message
|
|
963
|
+
});
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const assetIds: string[] = event.price_changes.map(pc => pc.asset_id);
|
|
968
|
+
|
|
969
|
+
for (const assetId of assetIds) {
|
|
970
|
+
let spreadOver10Cents: boolean;
|
|
971
|
+
try {
|
|
972
|
+
spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
|
|
973
|
+
} catch (err: any) {
|
|
974
|
+
logger.debug({
|
|
975
|
+
message: 'Skipping derived future price calculation for price_change: error calculating spread',
|
|
976
|
+
asset_id: assetId,
|
|
977
|
+
event: event,
|
|
978
|
+
error: err?.message
|
|
979
|
+
});
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (!spreadOver10Cents) {
|
|
984
|
+
let newPrice: string;
|
|
985
|
+
try {
|
|
986
|
+
newPrice = this.bookCache.midpoint(assetId);
|
|
987
|
+
} catch (err: any) {
|
|
988
|
+
logger.debug({
|
|
989
|
+
message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
|
|
990
|
+
asset_id: assetId,
|
|
991
|
+
event: event,
|
|
992
|
+
error: err?.message
|
|
993
|
+
});
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const bookEntry: BookEntry | null = this.bookCache.getBookEntry(assetId);
|
|
998
|
+
if (!bookEntry) {
|
|
999
|
+
logger.debug({
|
|
1000
|
+
message: 'Skipping derived future price calculation price_change: book not found for asset',
|
|
1001
|
+
asset_id: assetId,
|
|
1002
|
+
event: event,
|
|
1003
|
+
});
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (newPrice !== bookEntry.price) {
|
|
1008
|
+
bookEntry.price = newPrice;
|
|
1009
|
+
const priceUpdateEvent: PolymarketPriceUpdateEvent = {
|
|
1010
|
+
asset_id: assetId,
|
|
1011
|
+
event_type: 'price_update',
|
|
1012
|
+
triggeringEvent: event,
|
|
1013
|
+
timestamp: event.timestamp,
|
|
1014
|
+
book: { bids: bookEntry.bids, asks: bookEntry.asks },
|
|
1015
|
+
price: newPrice,
|
|
1016
|
+
midpoint: bookEntry.midpoint || '',
|
|
1017
|
+
spread: bookEntry.spread || '',
|
|
1018
|
+
};
|
|
1019
|
+
await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Handles last trade price events.
|
|
1029
|
+
*/
|
|
1030
|
+
private async handleLastTradeEvents(lastTradeEvents: LastTradePriceEvent[]): Promise<void> {
|
|
1031
|
+
if (lastTradeEvents.length) {
|
|
1032
|
+
await this.handlers.onLastTradePrice?.(lastTradeEvents);
|
|
1033
|
+
|
|
1034
|
+
for (const event of lastTradeEvents) {
|
|
1035
|
+
let spreadOver10Cents: boolean;
|
|
1036
|
+
try {
|
|
1037
|
+
spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
|
|
1038
|
+
} catch (err: any) {
|
|
1039
|
+
logger.debug({
|
|
1040
|
+
message: 'Skipping derived future price calculation for last_trade_price: error calculating spread',
|
|
1041
|
+
asset_id: event.asset_id,
|
|
1042
|
+
event: event,
|
|
1043
|
+
error: err?.message
|
|
1044
|
+
});
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (spreadOver10Cents) {
|
|
1049
|
+
const newPrice = parseFloat(event.price).toString();
|
|
1050
|
+
|
|
1051
|
+
const bookEntry: BookEntry | null = this.bookCache.getBookEntry(event.asset_id);
|
|
1052
|
+
if (!bookEntry) {
|
|
1053
|
+
logger.debug({
|
|
1054
|
+
message: 'Skipping derived future price calculation last_trade_price: book not found for asset',
|
|
1055
|
+
asset_id: event.asset_id,
|
|
1056
|
+
event: event,
|
|
1057
|
+
});
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (newPrice !== bookEntry.price) {
|
|
1062
|
+
bookEntry.price = newPrice;
|
|
1063
|
+
const priceUpdateEvent: PolymarketPriceUpdateEvent = {
|
|
1064
|
+
asset_id: event.asset_id,
|
|
1065
|
+
event_type: 'price_update',
|
|
1066
|
+
triggeringEvent: event,
|
|
1067
|
+
timestamp: event.timestamp,
|
|
1068
|
+
book: { bids: bookEntry.bids, asks: bookEntry.asks },
|
|
1069
|
+
price: newPrice,
|
|
1070
|
+
midpoint: bookEntry.midpoint || '',
|
|
1071
|
+
spread: bookEntry.spread || '',
|
|
1072
|
+
};
|
|
1073
|
+
await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Handles best bid/ask events.
|
|
1082
|
+
*/
|
|
1083
|
+
private async handleBestBidAskEvents(bestBidAskEvents: BestBidAskEvent[]): Promise<void> {
|
|
1084
|
+
if (bestBidAskEvents.length) {
|
|
1085
|
+
await this.handlers.onBestBidAsk?.(bestBidAskEvents);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Handles new market events.
|
|
1091
|
+
* These events bypass asset-level filtering as they are market-level events.
|
|
1092
|
+
*/
|
|
1093
|
+
private async handleNewMarketEvents(newMarketEvents: NewMarketEvent[]): Promise<void> {
|
|
1094
|
+
if (newMarketEvents.length) {
|
|
1095
|
+
try {
|
|
1096
|
+
await this.handlers.onNewMarket?.(newMarketEvents);
|
|
1097
|
+
} catch (handlerErr) {
|
|
1098
|
+
logger.warn({
|
|
1099
|
+
message: 'Error in onNewMarket handler',
|
|
1100
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
1101
|
+
managerId: this.managerId,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Handles market resolved events.
|
|
1109
|
+
* These events bypass asset-level filtering as they are market-level events.
|
|
1110
|
+
*/
|
|
1111
|
+
private async handleMarketResolvedEvents(marketResolvedEvents: MarketResolvedEvent[]): Promise<void> {
|
|
1112
|
+
if (marketResolvedEvents.length) {
|
|
1113
|
+
try {
|
|
1114
|
+
await this.handlers.onMarketResolved?.(marketResolvedEvents);
|
|
1115
|
+
} catch (handlerErr) {
|
|
1116
|
+
logger.warn({
|
|
1117
|
+
message: 'Error in onMarketResolved handler',
|
|
1118
|
+
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
|
|
1119
|
+
managerId: this.managerId,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export { WSSubscriptionManager, WebSocketHandlers };
|