@nevuamarkets/poly-websockets 0.0.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.
@@ -0,0 +1,338 @@
1
+ import WebSocket from 'ws';
2
+ import Bottleneck from 'bottleneck';
3
+ import { logger } from '../logger';
4
+ import { WebSocketGroup, WebSocketStatus } from '../types/WebSocketSubscriptions';
5
+ import { BookEntry, OrderBookCache } from './OrderBookCache';
6
+ import {
7
+ BookEvent,
8
+ isBookEvent,
9
+ isLastTradePriceEvent,
10
+ isPriceChangeEvent,
11
+ isTickSizeChangeEvent,
12
+ LastTradePriceEvent,
13
+ PriceChangeEvent,
14
+ TickSizeChangeEvent,
15
+ PolymarketWSEvent,
16
+ WebSocketHandlers,
17
+ PolymarketPriceUpdateEvent,
18
+ } from '../types/PolymarketWebSocket';
19
+ import _ from 'lodash';
20
+ import ms from 'ms';
21
+ import { randomInt } from 'crypto';
22
+
23
+ const CLOB_WSS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
24
+
25
+ export class GroupSocket {
26
+ private pingInterval?: NodeJS.Timeout;
27
+
28
+ constructor(
29
+ private group: WebSocketGroup,
30
+ private limiter: Bottleneck,
31
+ private bookCache: OrderBookCache,
32
+ private handlers: WebSocketHandlers,
33
+ ) {}
34
+
35
+ /**
36
+ * Establish the websocket connection using the provided Bottleneck limiter.
37
+ *
38
+ */
39
+ public async connect(): Promise<void> {
40
+ if (this.group.assetIds.size === 0) {
41
+ this.group.status = WebSocketStatus.CLEANUP;
42
+ return;
43
+ }
44
+
45
+ try {
46
+ logger.info({
47
+ message: 'Connecting to CLOB WebSocket',
48
+ groupId: this.group.groupId,
49
+ assetIdsLength: this.group.assetIds.size,
50
+ });
51
+ this.group.wsClient = await this.limiter.schedule({ priority: 0 }, async () => {
52
+ const ws = new WebSocket(CLOB_WSS_URL);
53
+ /*
54
+ This handler will be replaced by the handlers in setupEventHandlers
55
+ */
56
+ ws.on('error', (err) => {
57
+ logger.warn({
58
+ message: 'Error connecting to CLOB WebSocket',
59
+ error: err,
60
+ groupId: this.group.groupId,
61
+ assetIdsLength: this.group.assetIds.size,
62
+ });
63
+ });
64
+ return ws;
65
+ });
66
+ } catch (err) {
67
+ this.group.status = WebSocketStatus.DEAD;
68
+ throw err; // caller responsible for error handler
69
+ }
70
+
71
+ this.setupEventHandlers();
72
+ }
73
+
74
+ private setupEventHandlers() {
75
+ const group = this.group;
76
+ const handlers = this.handlers;
77
+
78
+ /*
79
+ Define handlers within this scope to capture 'this' context
80
+ */
81
+ const handleOpen = async () => {
82
+ if (group.assetIds.size === 0) {
83
+ group.status = WebSocketStatus.CLEANUP;
84
+ return;
85
+ }
86
+
87
+ group.status = WebSocketStatus.ALIVE;
88
+
89
+ group.wsClient!.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
90
+ await handlers.onWSOpen?.(group.groupId, Array.from(group.assetIds));
91
+
92
+ this.pingInterval = setInterval(() => {
93
+ if (group.assetIds.size === 0) {
94
+ clearInterval(this.pingInterval);
95
+ group.status = WebSocketStatus.CLEANUP;
96
+ return;
97
+ }
98
+
99
+ if (!group.wsClient) {
100
+ clearInterval(this.pingInterval);
101
+ group.status = WebSocketStatus.DEAD;
102
+ return;
103
+ }
104
+ group.wsClient.ping();
105
+ }, randomInt(ms('15s'), ms('25s')));
106
+ };
107
+
108
+ const handleMessage = async (data: Buffer) => {
109
+ let events: PolymarketWSEvent[] = [];
110
+ 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[] = [];
124
+
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;
132
+ }
133
+
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)}`));
145
+ }
146
+ }
147
+
148
+ await this.handleBookEvents(bookEvents);
149
+ await this.handleTickEvents(tickEvents);
150
+ await this.handlePriceChangeEvents(priceChangeEvents);
151
+ await this.handleLastTradeEvents(lastTradeEvents);
152
+ };
153
+
154
+ const handlePong = () => {
155
+ group.groupId;
156
+ };
157
+
158
+ const handleError = (err: Error) => {
159
+ group.status = WebSocketStatus.DEAD;
160
+ clearInterval(this.pingInterval);
161
+ handlers.onError?.(new Error(`WebSocket error for group ${group.groupId}: ${err.message}`));
162
+ };
163
+
164
+ const handleClose = async (code: number, reason: Buffer) => {
165
+ group.status = WebSocketStatus.DEAD;
166
+ clearInterval(this.pingInterval);
167
+ await handlers.onWSClose?.(group.groupId, code, reason.toString());
168
+ };
169
+
170
+ if (group.wsClient) {
171
+ // Remove any existing handlers
172
+ group.wsClient.removeAllListeners();
173
+
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
+ }
181
+
182
+ if (group.assetIds.size === 0) {
183
+ group.status = WebSocketStatus.CLEANUP;
184
+ return;
185
+ }
186
+
187
+ if (!group.wsClient) {
188
+ group.status = WebSocketStatus.DEAD;
189
+ return;
190
+ }
191
+ }
192
+
193
+ private async handleBookEvents(bookEvents: BookEvent[]): Promise<void> {
194
+ if (bookEvents.length) {
195
+ for (const event of bookEvents) {
196
+ this.bookCache.replaceBook(event);
197
+ }
198
+ await this.handlers.onBook?.(bookEvents);
199
+ }
200
+ }
201
+
202
+ private async handleTickEvents(tickEvents: TickSizeChangeEvent[]): Promise<void> {
203
+ if (tickEvents.length) {
204
+ await this.handlers.onTickSizeChange?.(tickEvents);
205
+ }
206
+ }
207
+
208
+ private async handlePriceChangeEvents(priceChangeEvents: PriceChangeEvent[]): Promise<void> {
209
+ if (priceChangeEvents.length) {
210
+ await this.handlers.onPriceChange?.(priceChangeEvents);
211
+
212
+ for (const event of priceChangeEvents) {
213
+ try {
214
+ this.bookCache.upsertPriceChange(event);
215
+ } catch (err) {
216
+ logger.warn({
217
+ message: `Skipping price_change: book not found for asset`,
218
+ asset_id: event.asset_id,
219
+ event: event,
220
+ error: err
221
+ });
222
+ continue;
223
+ }
224
+
225
+ let spreadOver10Cents: boolean;
226
+ try {
227
+ spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
228
+ } catch (err) {
229
+ logger.warn({
230
+ message: 'Skipping price_change: error calculating spread',
231
+ asset_id: event.asset_id,
232
+ event: event,
233
+ error: err
234
+ });
235
+ await this.handlers.onError?.(err as Error);
236
+ continue;
237
+ }
238
+
239
+ if (!spreadOver10Cents) {
240
+ let newPrice: string;
241
+ try {
242
+ newPrice = this.bookCache.midpoint(event.asset_id);
243
+ } catch (err) {
244
+ logger.warn({
245
+ message: 'Skipping price_change: error calculating midpoint',
246
+ asset_id: event.asset_id,
247
+ event: event,
248
+ error: err
249
+ });
250
+ await this.handlers.onError?.(err as Error);
251
+ continue;
252
+ }
253
+
254
+ const bookEntry: BookEntry | null = this.bookCache.getBookEntry(event.asset_id);
255
+ if (!bookEntry) {
256
+ logger.warn({
257
+ message: 'Skipping price_change: book not found for asset',
258
+ asset_id: event.asset_id,
259
+ event: event,
260
+ });
261
+ continue;
262
+ }
263
+
264
+ if (newPrice !== bookEntry.price) {
265
+ bookEntry.price = newPrice;
266
+ const priceUpdateEvent: PolymarketPriceUpdateEvent = {
267
+ asset_id: event.asset_id,
268
+ event_type: 'price_update',
269
+ triggeringEvent: event,
270
+ timestamp: event.timestamp,
271
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
272
+ price: newPrice,
273
+ midpoint: bookEntry.midpoint || '',
274
+ spread: bookEntry.spread || '',
275
+ };
276
+ await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ private async handleLastTradeEvents(lastTradeEvents: LastTradePriceEvent[]): Promise<void> {
284
+ if (lastTradeEvents.length) {
285
+ /*
286
+ Note: There is no need to edit the book here. According to the docs, a separate
287
+ book event is sent when a trade affects the book.
288
+
289
+ See: https://docs.polymarket.com/developers/CLOB/websocket/market-channel#book-message
290
+ */
291
+ await this.handlers.onLastTradePrice?.(lastTradeEvents);
292
+
293
+ for (const event of lastTradeEvents) {
294
+ let spreadOver10Cents: boolean;
295
+ try {
296
+ spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
297
+ } catch (err) {
298
+ logger.warn({
299
+ message: 'Skipping last_trade_price: error calculating spread',
300
+ asset_id: event.asset_id,
301
+ event: event,
302
+ error: err
303
+ });
304
+ await this.handlers.onError?.(err as Error);
305
+ continue;
306
+ }
307
+ if (spreadOver10Cents) {
308
+ // Ensure no trailing zeros
309
+ const newPrice = parseFloat(event.price).toString();
310
+
311
+ const bookEntry: BookEntry | null = this.bookCache.getBookEntry(event.asset_id);
312
+ if (!bookEntry) {
313
+ logger.warn({
314
+ message: 'Skipping last_trade_price: book not found for asset',
315
+ asset_id: event.asset_id,
316
+ event: event,
317
+ });
318
+ continue;
319
+ }
320
+ if (newPrice !== bookEntry.price) {
321
+ bookEntry.price = newPrice;
322
+ const priceUpdateEvent: PolymarketPriceUpdateEvent = {
323
+ asset_id: event.asset_id,
324
+ event_type: 'price_update',
325
+ triggeringEvent: event,
326
+ timestamp: event.timestamp,
327
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
328
+ price: newPrice,
329
+ midpoint: bookEntry.midpoint || '',
330
+ spread: bookEntry.spread || '',
331
+ };
332
+ await this.handlers.onPolymarketPriceUpdate?.([priceUpdateEvent]);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,208 @@
1
+ import _ from 'lodash';
2
+ import {
3
+ BookEvent,
4
+ PriceChangeEvent,
5
+ PriceLevel,
6
+ } from '../types/PolymarketWebSocket';
7
+
8
+ /*
9
+ * Shared book cache store – exported so legacy code paths can keep using it
10
+ * until the refactor is complete.
11
+ */
12
+ export interface BookEntry {
13
+ bids: PriceLevel[];
14
+ asks: PriceLevel[];
15
+ price: string | null;
16
+ midpoint: string | null;
17
+ spread: string | null;
18
+ }
19
+
20
+ export
21
+
22
+ function sortDescendingInPlace(bookSide: PriceLevel[]): void {
23
+ bookSide.sort((a, b) => parseFloat(b.price) - parseFloat(a.price));
24
+ }
25
+
26
+ function sortAscendingInPlace(bookSide: PriceLevel[]): void {
27
+ bookSide.sort((a, b) => parseFloat(a.price) - parseFloat(b.price));
28
+ }
29
+
30
+ export class OrderBookCache {
31
+ private bookCache: {
32
+ [assetId: string]: BookEntry
33
+ } = {};
34
+
35
+ constructor() {}
36
+
37
+ /**
38
+ * Replace full book (after a `book` event)
39
+ */
40
+ public replaceBook(event: BookEvent): void {
41
+ let lastPrice = null;
42
+ let lastMidpoint = null;
43
+ let lastSpread = null;
44
+ if (this.bookCache[event.asset_id]) {
45
+ lastPrice = this.bookCache[event.asset_id].price;
46
+ lastMidpoint = this.bookCache[event.asset_id].midpoint;
47
+ lastSpread = this.bookCache[event.asset_id].spread;
48
+ }
49
+
50
+ this.bookCache[event.asset_id] = {
51
+ bids: [...event.bids],
52
+ asks: [...event.asks],
53
+ price: lastPrice,
54
+ midpoint: lastMidpoint,
55
+ spread: lastSpread,
56
+ };
57
+
58
+ /* Polymarket book events are currently sorted as such:
59
+ * - bids (buys) ascending
60
+ * - asks (sells) descending
61
+ *
62
+ * So we maintain this order in the cache.
63
+ */
64
+ sortAscendingInPlace(this.bookCache[event.asset_id].bids);
65
+ sortDescendingInPlace(this.bookCache[event.asset_id].asks);
66
+ }
67
+
68
+ /**
69
+ * Update a cached book from a `price_change` event.
70
+ *
71
+ * Returns true if the book was updated.
72
+ * Throws if the book is not found.
73
+ */
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
+ }
79
+
80
+ for (const change of event.changes) {
81
+ const { price, size, side } = change;
82
+ if (side === 'BUY') {
83
+ const i = book.bids.findIndex(bid => bid.price === price);
84
+ if (i !== -1) {
85
+ book.bids[i].size = size;
86
+ } else {
87
+ book.bids.push({ price, size });
88
+
89
+ // Ensure the bids are sorted ascending
90
+ sortAscendingInPlace(book.bids);
91
+ }
92
+ } else {
93
+ const i = book.asks.findIndex(ask => ask.price === price);
94
+ if (i !== -1) {
95
+ book.asks[i].size = size;
96
+ } else {
97
+ book.asks.push({ price, size });
98
+
99
+ // Ensure the asks are sorted descending
100
+ sortDescendingInPlace(book.asks);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Return `true` if best-bid/best-ask spread exceeds `cents`.
108
+ *
109
+ * Side effect: updates the book's spread
110
+ *
111
+ * Throws if either side of the book is empty.
112
+ */
113
+ public spreadOver(assetId: string, cents = 0.1): boolean {
114
+ const book = this.bookCache[assetId];
115
+ if (!book) throw new Error(`Book for ${assetId} not cached`);
116
+ if (book.asks.length === 0) throw new Error('No asks in book');
117
+ if (book.bids.length === 0) throw new Error('No bids in book');
118
+
119
+ /*
120
+ * Polymarket book events are currently sorted as such:
121
+ * - bids ascending
122
+ * - asks descending
123
+ */
124
+
125
+ const highestBid = book.bids[book.bids.length - 1].price;
126
+ const lowestAsk = book.asks[book.asks.length - 1].price;
127
+
128
+ const highestBidNum = parseFloat(highestBid);
129
+ const lowestAskNum = parseFloat(lowestAsk);
130
+
131
+ const spread = lowestAskNum - highestBidNum;
132
+
133
+ if (isNaN(spread)) {
134
+ throw new Error(`Spread is NaN: lowestAsk '${lowestAsk}' highestBid '${highestBid}'`);
135
+ }
136
+
137
+ /*
138
+ * Update spead, 3 precision decimal places, trim trailing zeros
139
+ */
140
+ book.spread = parseFloat(spread.toFixed(3)).toString();
141
+
142
+ // Should be safe for 0.### - precision values
143
+ return spread > cents;
144
+ }
145
+
146
+ /**
147
+ * Calculate the midpoint of the book, rounded to 3dp, no trailing zeros
148
+ *
149
+ * Side effect: updates the book's midpoint
150
+ *
151
+ * Throws if
152
+ * - the book is not found or missing either bid or ask
153
+ * - the midpoint is NaN.
154
+ */
155
+ public midpoint(assetId: string): string {
156
+ const book = this.bookCache[assetId];
157
+ if (!book) throw new Error(`Book for ${assetId} not cached`);
158
+ if (book.asks.length === 0) throw new Error('No asks in book');
159
+ if (book.bids.length === 0) throw new Error('No bids in book');
160
+
161
+ /*
162
+ * Polymarket book events are currently sorted as such:
163
+ * - bids ascending
164
+ * - asks descending
165
+ */
166
+ const highestBid = book.bids[book.bids.length - 1].price;
167
+ const lowestAsk = book.asks[book.asks.length - 1].price;
168
+
169
+ const highestBidNum = parseFloat(highestBid);
170
+ const lowestAskNum = parseFloat(lowestAsk);
171
+
172
+ const midpoint = (highestBidNum + lowestAskNum) / 2;
173
+
174
+ if (isNaN(midpoint)) {
175
+ throw new Error(`Midpoint is NaN: lowestAsk '${lowestAsk}' highestBid '${highestBid}'`);
176
+ }
177
+
178
+ /*
179
+ * Update midpoint, 3 precision decimal places, trim trailing zeros
180
+ */
181
+ book.midpoint = parseFloat(midpoint.toFixed(3)).toString();
182
+
183
+ return parseFloat(midpoint.toFixed(3)).toString();
184
+ }
185
+
186
+ public clear(assetId?: string): void {
187
+ if (assetId) {
188
+ delete this.bookCache[assetId];
189
+ } else {
190
+ for (const k of Object.keys(this.bookCache)) {
191
+ delete this.bookCache[k];
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Get a book entry by asset id.
198
+ *
199
+ * Return null if the book is not found.
200
+ */
201
+ public getBookEntry(assetId: string): BookEntry | null {
202
+ if (!this.bookCache[assetId]) {
203
+ return null;
204
+ }
205
+ return this.bookCache[assetId];
206
+ }
207
+
208
+ }