@nevuamarkets/poly-websockets 0.1.5 → 0.2.1

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,58 +99,97 @@ 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
- var _a, _b;
90
- let events = [];
116
+ var _a, _b, _c;
91
117
  try {
92
- const parsedData = JSON.parse(data.toString());
93
- events = Array.isArray(parsedData) ? parsedData : [parsedData];
94
- }
95
- catch (err) {
96
- await ((_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`Not JSON: ${data.toString()}`)));
97
- return;
98
- }
99
- events = lodash_1.default.filter(events, (event) => lodash_1.default.size(event.asset_id) > 0);
100
- const bookEvents = [];
101
- const lastTradeEvents = [];
102
- const tickEvents = [];
103
- const priceChangeEvents = [];
104
- for (const event of events) {
105
- /*
106
- Skip events for asset ids that are not in the group to ensure that
107
- we don't get stale events for assets that were removed.
108
- */
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);
118
+ const messageStr = data.toString();
119
+ // Handle PONG messages that might be sent to message handler during handler reattachment
120
+ if (messageStr === 'PONG') {
121
+ return;
117
122
  }
118
- else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
119
- tickEvents.push(event);
123
+ let events = [];
124
+ try {
125
+ const parsedData = JSON.parse(messageStr);
126
+ events = Array.isArray(parsedData) ? parsedData : [parsedData];
120
127
  }
121
- else if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
122
- priceChangeEvents.push(event);
128
+ catch (err) {
129
+ await ((_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`Not JSON: ${messageStr}`)));
130
+ return;
123
131
  }
124
- else {
125
- await ((_b = handlers.onError) === null || _b === void 0 ? void 0 : _b.call(handlers, new Error(`Unknown event: ${JSON.stringify(event)}`)));
132
+ // Filter events to ensure validity
133
+ events = lodash_1.default.filter(events, (event) => {
134
+ if (!event) {
135
+ return false;
136
+ }
137
+ // For price_change events, check that price_changes array exists
138
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
139
+ return event.price_changes && event.price_changes.length > 0;
140
+ }
141
+ // For all other events, check asset_id
142
+ return lodash_1.default.size(event.asset_id) > 0;
143
+ });
144
+ const bookEvents = [];
145
+ const lastTradeEvents = [];
146
+ const tickEvents = [];
147
+ const priceChangeEvents = [];
148
+ for (const event of events) {
149
+ /*
150
+ Skip events for asset ids that are not in the group to ensure that
151
+ we don't get stale events for assets that were removed.
152
+ */
153
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
154
+ // Check if any of the price_changes are for assets in this group
155
+ const relevantChanges = event.price_changes.filter(price_change_item => group.assetIds.has(price_change_item.asset_id));
156
+ if (relevantChanges.length === 0) {
157
+ continue;
158
+ }
159
+ // Only include relevant changes
160
+ priceChangeEvents.push({
161
+ ...event,
162
+ price_changes: relevantChanges
163
+ });
164
+ }
165
+ else {
166
+ // For all other events, check asset_id at root
167
+ if (!group.assetIds.has(event.asset_id)) {
168
+ continue;
169
+ }
170
+ if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
171
+ bookEvents.push(event);
172
+ }
173
+ else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
174
+ lastTradeEvents.push(event);
175
+ }
176
+ else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
177
+ tickEvents.push(event);
178
+ }
179
+ else {
180
+ await ((_b = handlers.onError) === null || _b === void 0 ? void 0 : _b.call(handlers, new Error(`Unknown event: ${JSON.stringify(event)}`)));
181
+ }
182
+ }
126
183
  }
184
+ await this.handleBookEvents(bookEvents);
185
+ await this.handleTickEvents(tickEvents);
186
+ await this.handlePriceChangeEvents(priceChangeEvents);
187
+ await this.handleLastTradeEvents(lastTradeEvents);
188
+ }
189
+ catch (err) {
190
+ // handler-wide error handling
191
+ await ((_c = handlers.onError) === null || _c === void 0 ? void 0 : _c.call(handlers, new Error(`Error handling message: ${err}`)));
127
192
  }
128
- await this.handleBookEvents(bookEvents);
129
- await this.handleTickEvents(tickEvents);
130
- await this.handlePriceChangeEvents(priceChangeEvents);
131
- await this.handleLastTradeEvents(lastTradeEvents);
132
193
  };
133
194
  const handlePong = () => {
134
195
  group.groupId;
@@ -145,24 +206,18 @@ class GroupSocket {
145
206
  clearInterval(this.pingInterval);
146
207
  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
208
  };
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
- }
209
+ // Remove any existing handlers
210
+ currentWebSocket.removeAllListeners();
211
+ // Add the handlers
212
+ currentWebSocket.on('open', handleOpen);
213
+ currentWebSocket.on('message', handleMessage);
214
+ currentWebSocket.on('pong', handlePong);
215
+ currentWebSocket.on('error', handleError);
216
+ currentWebSocket.on('close', handleClose);
158
217
  if (group.assetIds.size === 0) {
159
218
  group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
160
219
  return;
161
220
  }
162
- if (!group.wsClient) {
163
- group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
164
- return;
165
- }
166
221
  }
167
222
  async handleBookEvents(bookEvents) {
168
223
  var _a, _b;
@@ -190,61 +245,64 @@ class GroupSocket {
190
245
  catch (err) {
191
246
  logger_1.logger.debug({
192
247
  message: `Skipping derived future price calculation price_change: book not found for asset`,
193
- asset_id: event.asset_id,
194
- event: event,
195
- error: err === null || err === void 0 ? void 0 : err.message
196
- });
197
- continue;
198
- }
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
248
  event: event,
208
249
  error: err === null || err === void 0 ? void 0 : err.message
209
250
  });
210
251
  continue;
211
252
  }
212
- if (!spreadOver10Cents) {
213
- let newPrice;
253
+ // Handle price updates per asset
254
+ const assetIds = event.price_changes.map(price_change_item => price_change_item.asset_id);
255
+ for (const assetId of assetIds) {
256
+ let spreadOver10Cents;
214
257
  try {
215
- newPrice = this.bookCache.midpoint(event.asset_id);
258
+ spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
216
259
  }
217
260
  catch (err) {
218
261
  logger_1.logger.debug({
219
- message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
220
- asset_id: event.asset_id,
262
+ message: 'Skipping derived future price calculation for price_change: error calculating spread',
263
+ asset_id: assetId,
221
264
  event: event,
222
265
  error: err === null || err === void 0 ? void 0 : err.message
223
266
  });
224
267
  continue;
225
268
  }
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]));
269
+ if (!spreadOver10Cents) {
270
+ let newPrice;
271
+ try {
272
+ newPrice = this.bookCache.midpoint(assetId);
273
+ }
274
+ catch (err) {
275
+ logger_1.logger.debug({
276
+ message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
277
+ asset_id: assetId,
278
+ event: event,
279
+ error: err === null || err === void 0 ? void 0 : err.message
280
+ });
281
+ continue;
282
+ }
283
+ const bookEntry = this.bookCache.getBookEntry(assetId);
284
+ if (!bookEntry) {
285
+ logger_1.logger.debug({
286
+ message: 'Skipping derived future price calculation price_change: book not found for asset',
287
+ asset_id: assetId,
288
+ event: event,
289
+ });
290
+ continue;
291
+ }
292
+ if (newPrice !== bookEntry.price) {
293
+ bookEntry.price = newPrice;
294
+ const priceUpdateEvent = {
295
+ asset_id: assetId,
296
+ event_type: 'price_update',
297
+ triggeringEvent: event,
298
+ timestamp: event.timestamp,
299
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
300
+ price: newPrice,
301
+ midpoint: bookEntry.midpoint || '',
302
+ spread: bookEntry.spread || '',
303
+ };
304
+ await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
305
+ }
248
306
  }
249
307
  }
250
308
  }
@@ -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.1",
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,59 +121,99 @@ 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) => {
109
- let events: PolymarketWSEvent[] = [];
110
140
  try {
111
- const parsedData: any = JSON.parse(data.toString());
112
- events = Array.isArray(parsedData) ? parsedData : [parsedData];
113
- } catch (err) {
114
- await handlers.onError?.(new Error(`Not JSON: ${data.toString()}`));
115
- return;
116
- }
117
-
118
- events = _.filter(events, (event: PolymarketWSEvent) => _.size(event.asset_id) > 0);
119
-
120
- const bookEvents: BookEvent[] = [];
121
- const lastTradeEvents: LastTradePriceEvent[] = [];
122
- const tickEvents: TickSizeChangeEvent[] = [];
123
- const priceChangeEvents: PriceChangeEvent[] = [];
141
+ const messageStr = data.toString();
124
142
 
125
- for (const event of events) {
126
- /*
127
- Skip events for asset ids that are not in the group to ensure that
128
- we don't get stale events for assets that were removed.
129
- */
130
- if (!group.assetIds.has(event.asset_id)) {
131
- continue;
143
+ // Handle PONG messages that might be sent to message handler during handler reattachment
144
+ if (messageStr === 'PONG') {
145
+ return;
132
146
  }
133
147
 
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);
148
+ let events: PolymarketWSEvent[] = [];
149
+ try {
150
+ const parsedData: any = JSON.parse(messageStr);
151
+ events = Array.isArray(parsedData) ? parsedData : [parsedData];
152
+ } catch (err) {
153
+ await handlers.onError?.(new Error(`Not JSON: ${messageStr}`));
154
+ return;
142
155
  }
143
- else {
144
- await handlers.onError?.(new Error(`Unknown event: ${JSON.stringify(event)}`));
156
+
157
+ // Filter events to ensure validity
158
+ events = _.filter(events, (event: PolymarketWSEvent) => {
159
+ if (!event) {
160
+ return false;
161
+ }
162
+ // For price_change events, check that price_changes array exists
163
+ if (isPriceChangeEvent(event)) {
164
+ return event.price_changes && event.price_changes.length > 0;
165
+ }
166
+ // For all other events, check asset_id
167
+ return _.size(event.asset_id) > 0;
168
+ });
169
+
170
+ const bookEvents: BookEvent[] = [];
171
+ const lastTradeEvents: LastTradePriceEvent[] = [];
172
+ const tickEvents: TickSizeChangeEvent[] = [];
173
+ const priceChangeEvents: PriceChangeEvent[] = [];
174
+
175
+ for (const event of events) {
176
+ /*
177
+ Skip events for asset ids that are not in the group to ensure that
178
+ we don't get stale events for assets that were removed.
179
+ */
180
+ if (isPriceChangeEvent(event)) {
181
+ // Check if any of the price_changes are for assets in this group
182
+ const relevantChanges = event.price_changes.filter(price_change_item => group.assetIds.has(price_change_item.asset_id));
183
+ if (relevantChanges.length === 0) {
184
+ continue;
185
+ }
186
+ // Only include relevant changes
187
+ priceChangeEvents.push({
188
+ ...event,
189
+ price_changes: relevantChanges
190
+ });
191
+ } else {
192
+ // For all other events, check asset_id at root
193
+ if (!group.assetIds.has(event.asset_id!)) {
194
+ continue;
195
+ }
196
+
197
+ if (isBookEvent(event)) {
198
+ bookEvents.push(event);
199
+ } else if (isLastTradePriceEvent(event)) {
200
+ lastTradeEvents.push(event);
201
+ } else if (isTickSizeChangeEvent(event)) {
202
+ tickEvents.push(event);
203
+ } else {
204
+ await handlers.onError?.(new Error(`Unknown event: ${JSON.stringify(event)}`));
205
+ }
206
+ }
145
207
  }
146
- }
147
208
 
148
- await this.handleBookEvents(bookEvents);
149
- await this.handleTickEvents(tickEvents);
150
- await this.handlePriceChangeEvents(priceChangeEvents);
151
- await this.handleLastTradeEvents(lastTradeEvents);
209
+ await this.handleBookEvents(bookEvents);
210
+ await this.handleTickEvents(tickEvents);
211
+ await this.handlePriceChangeEvents(priceChangeEvents);
212
+ await this.handleLastTradeEvents(lastTradeEvents);
213
+ } catch (err) {
214
+ // handler-wide error handling
215
+ await handlers.onError?.(new Error(`Error handling message: ${err}`));
216
+ }
152
217
  };
153
218
 
154
219
  const handlePong = () => {
@@ -167,27 +232,20 @@ export class GroupSocket {
167
232
  await handlers.onWSClose?.(group.groupId, code, reason?.toString() || '');
168
233
  };
169
234
 
170
- if (group.wsClient) {
171
- // Remove any existing handlers
172
- group.wsClient.removeAllListeners();
235
+ // Remove any existing handlers
236
+ currentWebSocket.removeAllListeners();
173
237
 
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
- }
238
+ // Add the handlers
239
+ currentWebSocket.on('open', handleOpen);
240
+ currentWebSocket.on('message', handleMessage);
241
+ currentWebSocket.on('pong', handlePong);
242
+ currentWebSocket.on('error', handleError);
243
+ currentWebSocket.on('close', handleClose);
181
244
 
182
245
  if (group.assetIds.size === 0) {
183
246
  group.status = WebSocketStatus.CLEANUP;
184
247
  return;
185
248
  }
186
-
187
- if (!group.wsClient) {
188
- group.status = WebSocketStatus.DEAD;
189
- return;
190
- }
191
249
  }
192
250
 
193
251
  private async handleBookEvents(bookEvents: BookEvent[]): Promise<void> {
@@ -215,63 +273,67 @@ export class GroupSocket {
215
273
  } catch (err: any) {
216
274
  logger.debug({
217
275
  message: `Skipping derived future price calculation price_change: book not found for asset`,
218
- asset_id: event.asset_id,
219
276
  event: event,
220
277
  error: err?.message
221
278
  });
222
279
  continue;
223
280
  }
224
281
 
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
- }
282
+ // Handle price updates per asset
283
+ const assetIds: string[] = event.price_changes.map(price_change_item => price_change_item.asset_id);
237
284
 
238
- if (!spreadOver10Cents) {
239
- let newPrice: string;
285
+ for (const assetId of assetIds) {
286
+ let spreadOver10Cents: boolean;
240
287
  try {
241
- newPrice = this.bookCache.midpoint(event.asset_id);
288
+ spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
242
289
  } catch (err: any) {
243
290
  logger.debug({
244
- message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
245
- asset_id: event.asset_id,
291
+ message: 'Skipping derived future price calculation for price_change: error calculating spread',
292
+ asset_id: assetId,
246
293
  event: event,
247
294
  error: err?.message
248
295
  });
249
296
  continue;
250
297
  }
251
298
 
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]);
299
+ if (!spreadOver10Cents) {
300
+ let newPrice: string;
301
+ try {
302
+ newPrice = this.bookCache.midpoint(assetId);
303
+ } catch (err: any) {
304
+ logger.debug({
305
+ message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
306
+ asset_id: assetId,
307
+ event: event,
308
+ error: err?.message
309
+ });
310
+ continue;
311
+ }
312
+
313
+ const bookEntry: BookEntry | null = this.bookCache.getBookEntry(assetId);
314
+ if (!bookEntry) {
315
+ logger.debug({
316
+ message: 'Skipping derived future price calculation price_change: book not found for asset',
317
+ asset_id: assetId,
318
+ event: event,
319
+ });
320
+ continue;
321
+ }
322
+
323
+ if (newPrice !== bookEntry.price) {
324
+ bookEntry.price = newPrice;
325
+ const priceUpdateEvent: PolymarketPriceUpdateEvent = {
326
+ asset_id: assetId,
327
+ event_type: 'price_update',
328
+ triggeringEvent: event,
329
+ timestamp: event.timestamp,
330
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
331
+ price: newPrice,
332
+ midpoint: bookEntry.midpoint || '',
333
+ spread: bookEntry.spread || '',
334
+ };
335
+ await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
336
+ }
275
337
  }
276
338
  }
277
339
  }
@@ -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
+ }