@nevuamarkets/poly-websockets 0.1.5 → 0.2.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.
@@ -7,6 +7,7 @@ exports.WSSubscriptionManager = void 0;
7
7
  const ms_1 = __importDefault(require("ms"));
8
8
  const lodash_1 = __importDefault(require("lodash"));
9
9
  const bottleneck_1 = __importDefault(require("bottleneck"));
10
+ const PolymarketWebSocket_1 = require("./types/PolymarketWebSocket");
10
11
  const GroupRegistry_1 = require("./modules/GroupRegistry");
11
12
  const OrderBookCache_1 = require("./modules/OrderBookCache");
12
13
  const GroupSocket_1 = require("./modules/GroupSocket");
@@ -86,15 +87,34 @@ class WSSubscriptionManager {
86
87
  async actOnSubscribedEvents(events, action) {
87
88
  // Filter out events that are not subscribed to by any groups
88
89
  events = lodash_1.default.filter(events, (event) => {
89
- const groupIndices = this.groupRegistry.getGroupIndicesForAsset(event.asset_id);
90
- if (groupIndices.length > 1) {
91
- logger_1.logger.warn({
92
- message: 'Found multiple groups for asset',
93
- asset_id: event.asset_id,
94
- group_indices: groupIndices
90
+ // Handle PriceChangeEvent which doesn't have asset_id at root
91
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
92
+ // Check if any of the price_changes are subscribed
93
+ return event.price_changes.some(price_change_item => {
94
+ const groupIndices = this.groupRegistry.getGroupIndicesForAsset(price_change_item.asset_id);
95
+ if (groupIndices.length > 1) {
96
+ logger_1.logger.warn({
97
+ message: 'Found multiple groups for asset',
98
+ asset_id: price_change_item.asset_id,
99
+ group_indices: groupIndices
100
+ });
101
+ }
102
+ return groupIndices.length > 0;
95
103
  });
96
104
  }
97
- return groupIndices.length > 0;
105
+ // For all other events, check asset_id at root
106
+ if ('asset_id' in event) {
107
+ const groupIndices = this.groupRegistry.getGroupIndicesForAsset(event.asset_id);
108
+ if (groupIndices.length > 1) {
109
+ logger_1.logger.warn({
110
+ message: 'Found multiple groups for asset',
111
+ asset_id: event.asset_id,
112
+ group_indices: groupIndices
113
+ });
114
+ }
115
+ return groupIndices.length > 0;
116
+ }
117
+ return false;
98
118
  });
99
119
  await (action === null || action === void 0 ? void 0 : action(events));
100
120
  }
@@ -59,6 +59,11 @@ class GroupSocket {
59
59
  setupEventHandlers() {
60
60
  const group = this.group;
61
61
  const handlers = this.handlers;
62
+ // Capture the current WebSocket instance to avoid race conditions
63
+ const currentWebSocket = group.wsClient;
64
+ if (!currentWebSocket) {
65
+ return;
66
+ }
62
67
  /*
63
68
  Define handlers within this scope to capture 'this' context
64
69
  */
@@ -68,8 +73,25 @@ class GroupSocket {
68
73
  group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
69
74
  return;
70
75
  }
76
+ // Verify this handler is for the current WebSocket instance
77
+ if (currentWebSocket !== group.wsClient) {
78
+ logger_1.logger.warn({
79
+ message: 'handleOpen called for stale WebSocket instance',
80
+ groupId: group.groupId,
81
+ });
82
+ return;
83
+ }
84
+ // Additional safety check for readyState
85
+ if (currentWebSocket.readyState !== ws_1.default.OPEN) {
86
+ logger_1.logger.warn({
87
+ message: 'handleOpen called but WebSocket is not in OPEN state',
88
+ groupId: group.groupId,
89
+ readyState: currentWebSocket.readyState,
90
+ });
91
+ return;
92
+ }
71
93
  group.status = WebSocketSubscriptions_1.WebSocketStatus.ALIVE;
72
- group.wsClient.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
94
+ currentWebSocket.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
73
95
  await ((_a = handlers.onWSOpen) === null || _a === void 0 ? void 0 : _a.call(handlers, group.groupId, Array.from(group.assetIds)));
74
96
  this.pingInterval = setInterval(() => {
75
97
  if (group.assetIds.size === 0) {
@@ -77,26 +99,44 @@ class GroupSocket {
77
99
  group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
78
100
  return;
79
101
  }
80
- if (!group.wsClient) {
102
+ // Verify we're still using the same WebSocket
103
+ if (currentWebSocket !== group.wsClient) {
104
+ clearInterval(this.pingInterval);
105
+ return;
106
+ }
107
+ if (!currentWebSocket || currentWebSocket.readyState !== ws_1.default.OPEN) {
81
108
  clearInterval(this.pingInterval);
82
109
  group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
83
110
  return;
84
111
  }
85
- group.wsClient.ping();
112
+ currentWebSocket.ping();
86
113
  }, (0, crypto_1.randomInt)((0, ms_1.default)('15s'), (0, ms_1.default)('25s')));
87
114
  };
88
115
  const handleMessage = async (data) => {
89
116
  var _a, _b;
117
+ const messageStr = data.toString();
118
+ // Handle PONG messages that might be sent to message handler during handler reattachment
119
+ if (messageStr === 'PONG') {
120
+ return;
121
+ }
90
122
  let events = [];
91
123
  try {
92
- const parsedData = JSON.parse(data.toString());
124
+ const parsedData = JSON.parse(messageStr);
93
125
  events = Array.isArray(parsedData) ? parsedData : [parsedData];
94
126
  }
95
127
  catch (err) {
96
- await ((_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`Not JSON: ${data.toString()}`)));
128
+ await ((_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`Not JSON: ${messageStr}`)));
97
129
  return;
98
130
  }
99
- events = lodash_1.default.filter(events, (event) => lodash_1.default.size(event.asset_id) > 0);
131
+ // Filter events to ensure validity
132
+ events = lodash_1.default.filter(events, (event) => {
133
+ // For price_change events, check that price_changes array exists
134
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
135
+ return event.price_changes && event.price_changes.length > 0;
136
+ }
137
+ // For all other events, check asset_id
138
+ return lodash_1.default.size(event.asset_id) > 0;
139
+ });
100
140
  const bookEvents = [];
101
141
  const lastTradeEvents = [];
102
142
  const tickEvents = [];
@@ -106,23 +146,35 @@ class GroupSocket {
106
146
  Skip events for asset ids that are not in the group to ensure that
107
147
  we don't get stale events for assets that were removed.
108
148
  */
109
- if (!group.assetIds.has(event.asset_id)) {
110
- continue;
111
- }
112
- if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
113
- bookEvents.push(event);
114
- }
115
- else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
116
- lastTradeEvents.push(event);
117
- }
118
- else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
119
- tickEvents.push(event);
120
- }
121
- else if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
122
- priceChangeEvents.push(event);
149
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
150
+ // Check if any of the price_changes are for assets in this group
151
+ const relevantChanges = event.price_changes.filter(price_change_item => group.assetIds.has(price_change_item.asset_id));
152
+ if (relevantChanges.length === 0) {
153
+ continue;
154
+ }
155
+ // Only include relevant changes
156
+ priceChangeEvents.push({
157
+ ...event,
158
+ price_changes: relevantChanges
159
+ });
123
160
  }
124
161
  else {
125
- await ((_b = handlers.onError) === null || _b === void 0 ? void 0 : _b.call(handlers, new Error(`Unknown event: ${JSON.stringify(event)}`)));
162
+ // For all other events, check asset_id at root
163
+ if (!group.assetIds.has(event.asset_id)) {
164
+ continue;
165
+ }
166
+ if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
167
+ bookEvents.push(event);
168
+ }
169
+ else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
170
+ lastTradeEvents.push(event);
171
+ }
172
+ else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
173
+ tickEvents.push(event);
174
+ }
175
+ else {
176
+ await ((_b = handlers.onError) === null || _b === void 0 ? void 0 : _b.call(handlers, new Error(`Unknown event: ${JSON.stringify(event)}`)));
177
+ }
126
178
  }
127
179
  }
128
180
  await this.handleBookEvents(bookEvents);
@@ -145,24 +197,18 @@ class GroupSocket {
145
197
  clearInterval(this.pingInterval);
146
198
  await ((_a = handlers.onWSClose) === null || _a === void 0 ? void 0 : _a.call(handlers, group.groupId, code, (reason === null || reason === void 0 ? void 0 : reason.toString()) || ''));
147
199
  };
148
- if (group.wsClient) {
149
- // Remove any existing handlers
150
- group.wsClient.removeAllListeners();
151
- // Add the handlers
152
- group.wsClient.on('open', handleOpen);
153
- group.wsClient.on('message', handleMessage);
154
- group.wsClient.on('pong', handlePong);
155
- group.wsClient.on('error', handleError);
156
- group.wsClient.on('close', handleClose);
157
- }
200
+ // Remove any existing handlers
201
+ currentWebSocket.removeAllListeners();
202
+ // Add the handlers
203
+ currentWebSocket.on('open', handleOpen);
204
+ currentWebSocket.on('message', handleMessage);
205
+ currentWebSocket.on('pong', handlePong);
206
+ currentWebSocket.on('error', handleError);
207
+ currentWebSocket.on('close', handleClose);
158
208
  if (group.assetIds.size === 0) {
159
209
  group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
160
210
  return;
161
211
  }
162
- if (!group.wsClient) {
163
- group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
164
- return;
165
- }
166
212
  }
167
213
  async handleBookEvents(bookEvents) {
168
214
  var _a, _b;
@@ -190,61 +236,64 @@ class GroupSocket {
190
236
  catch (err) {
191
237
  logger_1.logger.debug({
192
238
  message: `Skipping derived future price calculation price_change: book not found for asset`,
193
- asset_id: event.asset_id,
194
239
  event: event,
195
240
  error: err === null || err === void 0 ? void 0 : err.message
196
241
  });
197
242
  continue;
198
243
  }
199
- let spreadOver10Cents;
200
- try {
201
- spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
202
- }
203
- catch (err) {
204
- logger_1.logger.debug({
205
- message: 'Skipping derived future price calculation for price_change: error calculating spread',
206
- asset_id: event.asset_id,
207
- event: event,
208
- error: err === null || err === void 0 ? void 0 : err.message
209
- });
210
- continue;
211
- }
212
- if (!spreadOver10Cents) {
213
- let newPrice;
244
+ // Handle price updates per asset
245
+ const assetIds = event.price_changes.map(price_change_item => price_change_item.asset_id);
246
+ for (const assetId of assetIds) {
247
+ let spreadOver10Cents;
214
248
  try {
215
- newPrice = this.bookCache.midpoint(event.asset_id);
249
+ spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
216
250
  }
217
251
  catch (err) {
218
252
  logger_1.logger.debug({
219
- message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
220
- asset_id: event.asset_id,
253
+ message: 'Skipping derived future price calculation for price_change: error calculating spread',
254
+ asset_id: assetId,
221
255
  event: event,
222
256
  error: err === null || err === void 0 ? void 0 : err.message
223
257
  });
224
258
  continue;
225
259
  }
226
- const bookEntry = this.bookCache.getBookEntry(event.asset_id);
227
- if (!bookEntry) {
228
- logger_1.logger.debug({
229
- message: 'Skipping derived future price calculation price_change: book not found for asset',
230
- asset_id: event.asset_id,
231
- event: event,
232
- });
233
- continue;
234
- }
235
- if (newPrice !== bookEntry.price) {
236
- bookEntry.price = newPrice;
237
- const priceUpdateEvent = {
238
- asset_id: event.asset_id,
239
- event_type: 'price_update',
240
- triggeringEvent: event,
241
- timestamp: event.timestamp,
242
- book: { bids: bookEntry.bids, asks: bookEntry.asks },
243
- price: newPrice,
244
- midpoint: bookEntry.midpoint || '',
245
- spread: bookEntry.spread || '',
246
- };
247
- await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
260
+ if (!spreadOver10Cents) {
261
+ let newPrice;
262
+ try {
263
+ newPrice = this.bookCache.midpoint(assetId);
264
+ }
265
+ catch (err) {
266
+ logger_1.logger.debug({
267
+ message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
268
+ asset_id: assetId,
269
+ event: event,
270
+ error: err === null || err === void 0 ? void 0 : err.message
271
+ });
272
+ continue;
273
+ }
274
+ const bookEntry = this.bookCache.getBookEntry(assetId);
275
+ if (!bookEntry) {
276
+ logger_1.logger.debug({
277
+ message: 'Skipping derived future price calculation price_change: book not found for asset',
278
+ asset_id: assetId,
279
+ event: event,
280
+ });
281
+ continue;
282
+ }
283
+ if (newPrice !== bookEntry.price) {
284
+ bookEntry.price = newPrice;
285
+ const priceUpdateEvent = {
286
+ asset_id: assetId,
287
+ event_type: 'price_update',
288
+ triggeringEvent: event,
289
+ timestamp: event.timestamp,
290
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
291
+ price: newPrice,
292
+ midpoint: bookEntry.midpoint || '',
293
+ spread: bookEntry.spread || '',
294
+ };
295
+ await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
296
+ }
248
297
  }
249
298
  }
250
299
  }
@@ -47,12 +47,13 @@ class OrderBookCache {
47
47
  * Throws if the book is not found.
48
48
  */
49
49
  upsertPriceChange(event) {
50
- const book = this.bookCache[event.asset_id];
51
- if (!book) {
52
- throw new Error(`Book not found for asset ${event.asset_id}`);
53
- }
54
- for (const change of event.changes) {
55
- const { price, size, side } = change;
50
+ // Iterate through price_changes array
51
+ for (const priceChange of event.price_changes) {
52
+ const book = this.bookCache[priceChange.asset_id];
53
+ if (!book) {
54
+ throw new Error(`Book not found for asset ${priceChange.asset_id}`);
55
+ }
56
+ const { price, size, side } = priceChange;
56
57
  if (side === 'BUY') {
57
58
  const i = book.bids.findIndex(bid => bid.price === price);
58
59
  if (i !== -1) {
@@ -7,31 +7,44 @@ export type PriceLevel = {
7
7
  price: string;
8
8
  size: string;
9
9
  };
10
+ /**
11
+ * Represents a single price change item
12
+ */
13
+ export type PriceChangeItem = {
14
+ asset_id: string;
15
+ price: string;
16
+ size: string;
17
+ side: 'BUY' | 'SELL';
18
+ hash: string;
19
+ best_bid: string;
20
+ best_ask: string;
21
+ };
10
22
  /**
11
23
  * Represents a price_change event from Polymarket WebSocket
12
- * @example
24
+ *
25
+ * Schema example:
13
26
  * {
14
- * asset_id: "39327269875426915204597944387916069897800289788920336317845465327697809453999",
15
- * changes: [
16
- * { price: "0.044", side: "SELL", size: "611" }
27
+ * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
28
+ * price_changes: [
29
+ * {
30
+ * asset_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
31
+ * price: "0.5",
32
+ * size: "200",
33
+ * side: "BUY",
34
+ * hash: "56621a121a47ed9333273e21c83b660cff37ae50",
35
+ * best_bid: "0.5",
36
+ * best_ask: "1"
37
+ * }
17
38
  * ],
18
- * event_type: "price_change",
19
- * hash: "a0b7cadf869fc288dbbf65704996fe818cc97d6a",
20
- * market: "0x5412ae25e97078f814157de948459d59c6221b4c4c495fdd57b536543ad36729",
21
- * timestamp: "1749371014925"
39
+ * timestamp: "1757908892351",
40
+ * event_type: "price_change"
22
41
  * }
23
42
  */
24
43
  export type PriceChangeEvent = {
25
- asset_id: string;
26
- changes: {
27
- price: string;
28
- side: string;
29
- size: string;
30
- }[];
31
44
  event_type: 'price_change';
32
- hash: string;
33
45
  market: string;
34
46
  timestamp: string;
47
+ price_changes: PriceChangeItem[];
35
48
  };
36
49
  /**
37
50
  * Represents a Polymarket book
@@ -149,14 +162,20 @@ export type TickSizeChangeEvent = {
149
162
  *
150
163
  * @example PriceChangeEvent
151
164
  * {
152
- * asset_id: "39327269875426915204597944387916069897800289788920336317845465327697809453999",
153
- * changes: [
154
- * { price: "0.044", side: "SELL", size: "611" }
165
+ * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
166
+ * price_changes: [
167
+ * {
168
+ * asset_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
169
+ * price: "0.5",
170
+ * size: "200",
171
+ * side: "BUY",
172
+ * hash: "56621a121a47ed9333273e21c83b660cff37ae50",
173
+ * best_bid: "0.5",
174
+ * best_ask: "1"
175
+ * }
155
176
  * ],
156
- * event_type: "price_change",
157
- * hash: "a0b7cadf869fc288dbbf65704996fe818cc97d6a",
158
- * market: "0x5412ae25e97078f814157de948459d59c6221b4c4c495fdd57b536543ad36729",
159
- * timestamp: "1749371014925"
177
+ * timestamp: "1757908892351",
178
+ * event_type: "price_change"
160
179
  * }
161
180
  *
162
181
  * @example TickSizeChangeEvent
@@ -212,7 +231,7 @@ export type WebSocketHandlers = {
212
231
  * console.log(event.bids);
213
232
  * }
214
233
  */
215
- export declare function isBookEvent(event: PolymarketWSEvent): event is BookEvent;
234
+ export declare function isBookEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is BookEvent;
216
235
  /**
217
236
  * Type guard to check if an event is a LastTradePriceEvent
218
237
  * @example
@@ -221,7 +240,7 @@ export declare function isBookEvent(event: PolymarketWSEvent): event is BookEven
221
240
  * console.log(event.side);
222
241
  * }
223
242
  */
224
- export declare function isLastTradePriceEvent(event: PolymarketWSEvent): event is LastTradePriceEvent;
243
+ export declare function isLastTradePriceEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is LastTradePriceEvent;
225
244
  /**
226
245
  * Type guard to check if an event is a PriceChangeEvent
227
246
  * @example
@@ -230,7 +249,7 @@ export declare function isLastTradePriceEvent(event: PolymarketWSEvent): event i
230
249
  * console.log(event.changes);
231
250
  * }
232
251
  */
233
- export declare function isPriceChangeEvent(event: PolymarketWSEvent): event is PriceChangeEvent;
252
+ export declare function isPriceChangeEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is PriceChangeEvent;
234
253
  /**
235
254
  * Type guard to check if an event is a TickSizeChangeEvent
236
255
  * @example
@@ -239,4 +258,4 @@ export declare function isPriceChangeEvent(event: PolymarketWSEvent): event is P
239
258
  * console.log(event.old_tick_size);
240
259
  * }
241
260
  */
242
- export declare function isTickSizeChangeEvent(event: PolymarketWSEvent): event is TickSizeChangeEvent;
261
+ export declare function isTickSizeChangeEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is TickSizeChangeEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nevuamarkets/poly-websockets",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Plug-and-play Polymarket WebSocket price alerts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,8 @@ import {
8
8
  LastTradePriceEvent,
9
9
  TickSizeChangeEvent,
10
10
  PolymarketWSEvent,
11
- PolymarketPriceUpdateEvent
11
+ PolymarketPriceUpdateEvent,
12
+ isPriceChangeEvent
12
13
  } from './types/PolymarketWebSocket';
13
14
  import { SubscriptionManagerOptions } from './types/WebSocketSubscriptions';
14
15
 
@@ -111,16 +112,37 @@ class WSSubscriptionManager {
111
112
 
112
113
  // Filter out events that are not subscribed to by any groups
113
114
  events = _.filter(events, (event: T) => {
114
- const groupIndices = this.groupRegistry.getGroupIndicesForAsset(event.asset_id);
115
-
116
- if (groupIndices.length > 1) {
117
- logger.warn({
118
- message: 'Found multiple groups for asset',
119
- asset_id: event.asset_id,
120
- group_indices: groupIndices
115
+ // Handle PriceChangeEvent which doesn't have asset_id at root
116
+ if (isPriceChangeEvent(event)) {
117
+ // Check if any of the price_changes are subscribed
118
+ return event.price_changes.some(price_change_item => {
119
+ const groupIndices = this.groupRegistry.getGroupIndicesForAsset(price_change_item.asset_id);
120
+ if (groupIndices.length > 1) {
121
+ logger.warn({
122
+ message: 'Found multiple groups for asset',
123
+ asset_id: price_change_item.asset_id,
124
+ group_indices: groupIndices
125
+ });
126
+ }
127
+ return groupIndices.length > 0;
121
128
  });
122
129
  }
123
- return groupIndices.length > 0;
130
+
131
+ // For all other events, check asset_id at root
132
+ if ('asset_id' in event) {
133
+ const groupIndices = this.groupRegistry.getGroupIndicesForAsset(event.asset_id);
134
+
135
+ if (groupIndices.length > 1) {
136
+ logger.warn({
137
+ message: 'Found multiple groups for asset',
138
+ asset_id: event.asset_id,
139
+ group_indices: groupIndices
140
+ });
141
+ }
142
+ return groupIndices.length > 0;
143
+ }
144
+
145
+ return false;
124
146
  });
125
147
 
126
148
  await action?.(events);
@@ -74,6 +74,12 @@ export class GroupSocket {
74
74
  private setupEventHandlers() {
75
75
  const group = this.group;
76
76
  const handlers = this.handlers;
77
+
78
+ // Capture the current WebSocket instance to avoid race conditions
79
+ const currentWebSocket = group.wsClient;
80
+ if (!currentWebSocket) {
81
+ return;
82
+ }
77
83
 
78
84
  /*
79
85
  Define handlers within this scope to capture 'this' context
@@ -84,9 +90,28 @@ export class GroupSocket {
84
90
  return;
85
91
  }
86
92
 
93
+ // Verify this handler is for the current WebSocket instance
94
+ if (currentWebSocket !== group.wsClient) {
95
+ logger.warn({
96
+ message: 'handleOpen called for stale WebSocket instance',
97
+ groupId: group.groupId,
98
+ });
99
+ return;
100
+ }
101
+
102
+ // Additional safety check for readyState
103
+ if (currentWebSocket.readyState !== WebSocket.OPEN) {
104
+ logger.warn({
105
+ message: 'handleOpen called but WebSocket is not in OPEN state',
106
+ groupId: group.groupId,
107
+ readyState: currentWebSocket.readyState,
108
+ });
109
+ return;
110
+ }
111
+
87
112
  group.status = WebSocketStatus.ALIVE;
88
113
 
89
- group.wsClient!.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
114
+ currentWebSocket.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
90
115
  await handlers.onWSOpen?.(group.groupId, Array.from(group.assetIds));
91
116
 
92
117
  this.pingInterval = setInterval(() => {
@@ -96,26 +121,47 @@ export class GroupSocket {
96
121
  return;
97
122
  }
98
123
 
99
- if (!group.wsClient) {
124
+ // Verify we're still using the same WebSocket
125
+ if (currentWebSocket !== group.wsClient) {
126
+ clearInterval(this.pingInterval);
127
+ return;
128
+ }
129
+
130
+ if (!currentWebSocket || currentWebSocket.readyState !== WebSocket.OPEN) {
100
131
  clearInterval(this.pingInterval);
101
132
  group.status = WebSocketStatus.DEAD;
102
133
  return;
103
134
  }
104
- group.wsClient.ping();
135
+ currentWebSocket.ping();
105
136
  }, randomInt(ms('15s'), ms('25s')));
106
137
  };
107
138
 
108
139
  const handleMessage = async (data: Buffer) => {
140
+ const messageStr = data.toString();
141
+
142
+ // Handle PONG messages that might be sent to message handler during handler reattachment
143
+ if (messageStr === 'PONG') {
144
+ return;
145
+ }
146
+
109
147
  let events: PolymarketWSEvent[] = [];
110
148
  try {
111
- const parsedData: any = JSON.parse(data.toString());
149
+ const parsedData: any = JSON.parse(messageStr);
112
150
  events = Array.isArray(parsedData) ? parsedData : [parsedData];
113
151
  } catch (err) {
114
- await handlers.onError?.(new Error(`Not JSON: ${data.toString()}`));
152
+ await handlers.onError?.(new Error(`Not JSON: ${messageStr}`));
115
153
  return;
116
154
  }
117
155
 
118
- events = _.filter(events, (event: PolymarketWSEvent) => _.size(event.asset_id) > 0);
156
+ // Filter events to ensure validity
157
+ events = _.filter(events, (event: PolymarketWSEvent) => {
158
+ // For price_change events, check that price_changes array exists
159
+ if (isPriceChangeEvent(event)) {
160
+ return event.price_changes && event.price_changes.length > 0;
161
+ }
162
+ // For all other events, check asset_id
163
+ return _.size(event.asset_id) > 0;
164
+ });
119
165
 
120
166
  const bookEvents: BookEvent[] = [];
121
167
  const lastTradeEvents: LastTradePriceEvent[] = [];
@@ -127,21 +173,32 @@ export class GroupSocket {
127
173
  Skip events for asset ids that are not in the group to ensure that
128
174
  we don't get stale events for assets that were removed.
129
175
  */
130
- if (!group.assetIds.has(event.asset_id)) {
131
- continue;
132
- }
176
+ if (isPriceChangeEvent(event)) {
177
+ // Check if any of the price_changes are for assets in this group
178
+ const relevantChanges = event.price_changes.filter(price_change_item => group.assetIds.has(price_change_item.asset_id));
179
+ if (relevantChanges.length === 0) {
180
+ continue;
181
+ }
182
+ // Only include relevant changes
183
+ priceChangeEvents.push({
184
+ ...event,
185
+ price_changes: relevantChanges
186
+ });
187
+ } else {
188
+ // For all other events, check asset_id at root
189
+ if (!group.assetIds.has(event.asset_id!)) {
190
+ continue;
191
+ }
133
192
 
134
- if (isBookEvent(event)) {
135
- bookEvents.push(event);
136
- } else if (isLastTradePriceEvent(event)) {
137
- lastTradeEvents.push(event);
138
- } else if (isTickSizeChangeEvent(event)) {
139
- tickEvents.push(event);
140
- } else if (isPriceChangeEvent(event)) {
141
- priceChangeEvents.push(event);
142
- }
143
- else {
144
- await handlers.onError?.(new Error(`Unknown event: ${JSON.stringify(event)}`));
193
+ if (isBookEvent(event)) {
194
+ bookEvents.push(event);
195
+ } else if (isLastTradePriceEvent(event)) {
196
+ lastTradeEvents.push(event);
197
+ } else if (isTickSizeChangeEvent(event)) {
198
+ tickEvents.push(event);
199
+ } else {
200
+ await handlers.onError?.(new Error(`Unknown event: ${JSON.stringify(event)}`));
201
+ }
145
202
  }
146
203
  }
147
204
 
@@ -167,27 +224,20 @@ export class GroupSocket {
167
224
  await handlers.onWSClose?.(group.groupId, code, reason?.toString() || '');
168
225
  };
169
226
 
170
- if (group.wsClient) {
171
- // Remove any existing handlers
172
- group.wsClient.removeAllListeners();
227
+ // Remove any existing handlers
228
+ currentWebSocket.removeAllListeners();
173
229
 
174
- // Add the handlers
175
- group.wsClient.on('open', handleOpen);
176
- group.wsClient.on('message', handleMessage);
177
- group.wsClient.on('pong', handlePong);
178
- group.wsClient.on('error', handleError);
179
- group.wsClient.on('close', handleClose);
180
- }
230
+ // Add the handlers
231
+ currentWebSocket.on('open', handleOpen);
232
+ currentWebSocket.on('message', handleMessage);
233
+ currentWebSocket.on('pong', handlePong);
234
+ currentWebSocket.on('error', handleError);
235
+ currentWebSocket.on('close', handleClose);
181
236
 
182
237
  if (group.assetIds.size === 0) {
183
238
  group.status = WebSocketStatus.CLEANUP;
184
239
  return;
185
240
  }
186
-
187
- if (!group.wsClient) {
188
- group.status = WebSocketStatus.DEAD;
189
- return;
190
- }
191
241
  }
192
242
 
193
243
  private async handleBookEvents(bookEvents: BookEvent[]): Promise<void> {
@@ -215,63 +265,67 @@ export class GroupSocket {
215
265
  } catch (err: any) {
216
266
  logger.debug({
217
267
  message: `Skipping derived future price calculation price_change: book not found for asset`,
218
- asset_id: event.asset_id,
219
268
  event: event,
220
269
  error: err?.message
221
270
  });
222
271
  continue;
223
272
  }
224
273
 
225
- let spreadOver10Cents: boolean;
226
- try {
227
- spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
228
- } catch (err: any) {
229
- logger.debug({
230
- message: 'Skipping derived future price calculation for price_change: error calculating spread',
231
- asset_id: event.asset_id,
232
- event: event,
233
- error: err?.message
234
- });
235
- continue;
236
- }
274
+ // Handle price updates per asset
275
+ const assetIds: string[] = event.price_changes.map(price_change_item => price_change_item.asset_id);
237
276
 
238
- if (!spreadOver10Cents) {
239
- let newPrice: string;
277
+ for (const assetId of assetIds) {
278
+ let spreadOver10Cents: boolean;
240
279
  try {
241
- newPrice = this.bookCache.midpoint(event.asset_id);
280
+ spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
242
281
  } catch (err: any) {
243
282
  logger.debug({
244
- message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
245
- asset_id: event.asset_id,
283
+ message: 'Skipping derived future price calculation for price_change: error calculating spread',
284
+ asset_id: assetId,
246
285
  event: event,
247
286
  error: err?.message
248
287
  });
249
288
  continue;
250
289
  }
251
290
 
252
- const bookEntry: BookEntry | null = this.bookCache.getBookEntry(event.asset_id);
253
- if (!bookEntry) {
254
- logger.debug({
255
- message: 'Skipping derived future price calculation price_change: book not found for asset',
256
- asset_id: event.asset_id,
257
- event: event,
258
- });
259
- continue;
260
- }
261
-
262
- if (newPrice !== bookEntry.price) {
263
- bookEntry.price = newPrice;
264
- const priceUpdateEvent: PolymarketPriceUpdateEvent = {
265
- asset_id: event.asset_id,
266
- event_type: 'price_update',
267
- triggeringEvent: event,
268
- timestamp: event.timestamp,
269
- book: { bids: bookEntry.bids, asks: bookEntry.asks },
270
- price: newPrice,
271
- midpoint: bookEntry.midpoint || '',
272
- spread: bookEntry.spread || '',
273
- };
274
- await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
291
+ if (!spreadOver10Cents) {
292
+ let newPrice: string;
293
+ try {
294
+ newPrice = this.bookCache.midpoint(assetId);
295
+ } catch (err: any) {
296
+ logger.debug({
297
+ message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
298
+ asset_id: assetId,
299
+ event: event,
300
+ error: err?.message
301
+ });
302
+ continue;
303
+ }
304
+
305
+ const bookEntry: BookEntry | null = this.bookCache.getBookEntry(assetId);
306
+ if (!bookEntry) {
307
+ logger.debug({
308
+ message: 'Skipping derived future price calculation price_change: book not found for asset',
309
+ asset_id: assetId,
310
+ event: event,
311
+ });
312
+ continue;
313
+ }
314
+
315
+ if (newPrice !== bookEntry.price) {
316
+ bookEntry.price = newPrice;
317
+ const priceUpdateEvent: PolymarketPriceUpdateEvent = {
318
+ asset_id: assetId,
319
+ event_type: 'price_update',
320
+ triggeringEvent: event,
321
+ timestamp: event.timestamp,
322
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
323
+ price: newPrice,
324
+ midpoint: bookEntry.midpoint || '',
325
+ spread: bookEntry.spread || '',
326
+ };
327
+ await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
328
+ }
275
329
  }
276
330
  }
277
331
  }
@@ -72,13 +72,14 @@ export class OrderBookCache {
72
72
  * Throws if the book is not found.
73
73
  */
74
74
  public upsertPriceChange(event: PriceChangeEvent): void {
75
- const book = this.bookCache[event.asset_id];
76
- if (!book) {
77
- throw new Error(`Book not found for asset ${event.asset_id}`);
78
- }
75
+ // Iterate through price_changes array
76
+ for (const priceChange of event.price_changes) {
77
+ const book = this.bookCache[priceChange.asset_id];
78
+ if (!book) {
79
+ throw new Error(`Book not found for asset ${priceChange.asset_id}`);
80
+ }
79
81
 
80
- for (const change of event.changes) {
81
- const { price, size, side } = change;
82
+ const { price, size, side } = priceChange;
82
83
  if (side === 'BUY') {
83
84
  const i = book.bids.findIndex(bid => bid.price === price);
84
85
  if (i !== -1) {
@@ -8,27 +8,45 @@ export type PriceLevel = {
8
8
  size: string;
9
9
  };
10
10
 
11
+ /**
12
+ * Represents a single price change item
13
+ */
14
+ export type PriceChangeItem = {
15
+ asset_id: string;
16
+ price: string;
17
+ size: string;
18
+ side: 'BUY' | 'SELL';
19
+ hash: string;
20
+ best_bid: string;
21
+ best_ask: string;
22
+ };
23
+
11
24
  /**
12
25
  * Represents a price_change event from Polymarket WebSocket
13
- * @example
26
+ *
27
+ * Schema example:
14
28
  * {
15
- * asset_id: "39327269875426915204597944387916069897800289788920336317845465327697809453999",
16
- * changes: [
17
- * { price: "0.044", side: "SELL", size: "611" }
29
+ * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
30
+ * price_changes: [
31
+ * {
32
+ * asset_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
33
+ * price: "0.5",
34
+ * size: "200",
35
+ * side: "BUY",
36
+ * hash: "56621a121a47ed9333273e21c83b660cff37ae50",
37
+ * best_bid: "0.5",
38
+ * best_ask: "1"
39
+ * }
18
40
  * ],
19
- * event_type: "price_change",
20
- * hash: "a0b7cadf869fc288dbbf65704996fe818cc97d6a",
21
- * market: "0x5412ae25e97078f814157de948459d59c6221b4c4c495fdd57b536543ad36729",
22
- * timestamp: "1749371014925"
41
+ * timestamp: "1757908892351",
42
+ * event_type: "price_change"
23
43
  * }
24
44
  */
25
45
  export type PriceChangeEvent = {
26
- asset_id: string;
27
- changes: { price: string; side: string; size: string }[];
28
46
  event_type: 'price_change';
29
- hash: string;
30
47
  market: string;
31
48
  timestamp: string;
49
+ price_changes: PriceChangeItem[];
32
50
  };
33
51
 
34
52
  /**
@@ -151,14 +169,20 @@ export type TickSizeChangeEvent = {
151
169
  *
152
170
  * @example PriceChangeEvent
153
171
  * {
154
- * asset_id: "39327269875426915204597944387916069897800289788920336317845465327697809453999",
155
- * changes: [
156
- * { price: "0.044", side: "SELL", size: "611" }
172
+ * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
173
+ * price_changes: [
174
+ * {
175
+ * asset_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
176
+ * price: "0.5",
177
+ * size: "200",
178
+ * side: "BUY",
179
+ * hash: "56621a121a47ed9333273e21c83b660cff37ae50",
180
+ * best_bid: "0.5",
181
+ * best_ask: "1"
182
+ * }
157
183
  * ],
158
- * event_type: "price_change",
159
- * hash: "a0b7cadf869fc288dbbf65704996fe818cc97d6a",
160
- * market: "0x5412ae25e97078f814157de948459d59c6221b4c4c495fdd57b536543ad36729",
161
- * timestamp: "1749371014925"
184
+ * timestamp: "1757908892351",
185
+ * event_type: "price_change"
162
186
  * }
163
187
  *
164
188
  * @example TickSizeChangeEvent
@@ -239,7 +263,7 @@ export type WebSocketHandlers = {
239
263
  * console.log(event.bids);
240
264
  * }
241
265
  */
242
- export function isBookEvent(event: PolymarketWSEvent): event is BookEvent {
266
+ export function isBookEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is BookEvent {
243
267
  return event?.event_type === 'book';
244
268
  }
245
269
 
@@ -251,7 +275,7 @@ export function isBookEvent(event: PolymarketWSEvent): event is BookEvent {
251
275
  * console.log(event.side);
252
276
  * }
253
277
  */
254
- export function isLastTradePriceEvent(event: PolymarketWSEvent): event is LastTradePriceEvent {
278
+ export function isLastTradePriceEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is LastTradePriceEvent {
255
279
  return event?.event_type === 'last_trade_price';
256
280
  }
257
281
 
@@ -263,7 +287,7 @@ export function isLastTradePriceEvent(event: PolymarketWSEvent): event is LastTr
263
287
  * console.log(event.changes);
264
288
  * }
265
289
  */
266
- export function isPriceChangeEvent(event: PolymarketWSEvent): event is PriceChangeEvent {
290
+ export function isPriceChangeEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is PriceChangeEvent {
267
291
  return event?.event_type === 'price_change';
268
292
  }
269
293
 
@@ -275,6 +299,6 @@ export function isPriceChangeEvent(event: PolymarketWSEvent): event is PriceChan
275
299
  * console.log(event.old_tick_size);
276
300
  * }
277
301
  */
278
- export function isTickSizeChangeEvent(event: PolymarketWSEvent): event is TickSizeChangeEvent {
302
+ export function isTickSizeChangeEvent(event: PolymarketWSEvent | PolymarketPriceUpdateEvent): event is TickSizeChangeEvent {
279
303
  return event?.event_type === 'tick_size_change';
280
- }
304
+ }