@catalyst-team/poly-sdk 0.4.6 → 0.5.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.
Files changed (59) hide show
  1. package/README.md +18 -9
  2. package/README.zh-CN.md +18 -9
  3. package/dist/src/clients/data-api.d.ts +26 -0
  4. package/dist/src/clients/data-api.d.ts.map +1 -1
  5. package/dist/src/clients/data-api.js +58 -0
  6. package/dist/src/clients/data-api.js.map +1 -1
  7. package/dist/src/core/types.d.ts +56 -1
  8. package/dist/src/core/types.d.ts.map +1 -1
  9. package/dist/src/index.d.ts +6 -5
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +6 -4
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/realtime/index.d.ts +18 -0
  14. package/dist/src/realtime/index.d.ts.map +1 -0
  15. package/dist/src/realtime/index.js +14 -0
  16. package/dist/src/realtime/index.js.map +1 -0
  17. package/dist/src/realtime/realtime-data-client.d.ts +274 -0
  18. package/dist/src/realtime/realtime-data-client.d.ts.map +1 -0
  19. package/dist/src/realtime/realtime-data-client.js +771 -0
  20. package/dist/src/realtime/realtime-data-client.js.map +1 -0
  21. package/dist/src/realtime/types.d.ts +485 -0
  22. package/dist/src/realtime/types.d.ts.map +1 -0
  23. package/dist/src/realtime/types.js +36 -0
  24. package/dist/src/realtime/types.js.map +1 -0
  25. package/dist/src/services/arbitrage-service.d.ts.map +1 -1
  26. package/dist/src/services/arbitrage-service.js +2 -1
  27. package/dist/src/services/arbitrage-service.js.map +1 -1
  28. package/dist/src/services/dip-arb-service.d.ts.map +1 -1
  29. package/dist/src/services/dip-arb-service.js +3 -19
  30. package/dist/src/services/dip-arb-service.js.map +1 -1
  31. package/dist/src/services/market-service.d.ts +93 -11
  32. package/dist/src/services/market-service.d.ts.map +1 -1
  33. package/dist/src/services/market-service.js +189 -22
  34. package/dist/src/services/market-service.js.map +1 -1
  35. package/dist/src/services/order-handle.test.d.ts +15 -0
  36. package/dist/src/services/order-handle.test.d.ts.map +1 -0
  37. package/dist/src/services/order-handle.test.js +333 -0
  38. package/dist/src/services/order-handle.test.js.map +1 -0
  39. package/dist/src/services/order-manager.d.ts +162 -6
  40. package/dist/src/services/order-manager.d.ts.map +1 -1
  41. package/dist/src/services/order-manager.js +419 -30
  42. package/dist/src/services/order-manager.js.map +1 -1
  43. package/dist/src/services/realtime-service-v2.d.ts +122 -68
  44. package/dist/src/services/realtime-service-v2.d.ts.map +1 -1
  45. package/dist/src/services/realtime-service-v2.js +475 -144
  46. package/dist/src/services/realtime-service-v2.js.map +1 -1
  47. package/dist/src/services/smart-money-service.d.ts +33 -10
  48. package/dist/src/services/smart-money-service.d.ts.map +1 -1
  49. package/dist/src/services/smart-money-service.js +180 -73
  50. package/dist/src/services/smart-money-service.js.map +1 -1
  51. package/dist/src/services/trading-service.d.ts +129 -1
  52. package/dist/src/services/trading-service.d.ts.map +1 -1
  53. package/dist/src/services/trading-service.js +198 -5
  54. package/dist/src/services/trading-service.js.map +1 -1
  55. package/package.json +1 -2
  56. package/dist/src/services/ctf-detector.d.ts +0 -215
  57. package/dist/src/services/ctf-detector.d.ts.map +0 -1
  58. package/dist/src/services/ctf-detector.js +0 -420
  59. package/dist/src/services/ctf-detector.js.map +0 -1
@@ -13,18 +13,43 @@
13
13
  * - rfq: request_*, quote_*
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
- import { RealTimeDataClient, ConnectionStatus, } from '@polymarket/real-time-data-client';
16
+ import { RealTimeDataClient, ConnectionStatus, WS_ENDPOINTS, } from '../realtime/index.js';
17
17
  // ============================================================================
18
18
  // RealtimeServiceV2 Implementation
19
19
  // ============================================================================
20
20
  export class RealtimeServiceV2 extends EventEmitter {
21
21
  client = null;
22
+ /** Separate client for user channel (uses USER endpoint) */
23
+ userClient = null;
24
+ /** Separate client for crypto prices (uses LIVE_DATA endpoint) */
25
+ cryptoClient = null;
22
26
  config;
23
27
  subscriptions = new Map();
24
28
  subscriptionIdCounter = 0;
25
29
  connected = false;
30
+ userConnected = false;
31
+ cryptoConnected = false;
32
+ connectResolve;
33
+ // Subscription refresh timer: re-sends subscriptions shortly after they're added
34
+ // This fixes a bug where initial subscriptions on a fresh connection only receive
35
+ // the snapshot but no updates. Re-sending them "wakes up" the server.
36
+ subscriptionRefreshTimer = null;
37
+ // Track subscriptions that need to be refreshed (newly added on this connection)
38
+ pendingRefreshSubIds = new Set();
39
+ // Connection generation counter: incremented on each new connection.
40
+ // Used to avoid sending unsubscribe for stale subscriptions after reconnection.
41
+ connectionGeneration = 0;
42
+ // Tracks which generation each subscription was last (re-)subscribed on
43
+ subscriptionGenerations = new Map();
26
44
  // Store subscription messages for reconnection
27
45
  subscriptionMessages = new Map();
46
+ // Store user credentials for reconnection
47
+ userCredentials = null;
48
+ // Accumulated market token IDs - we merge all markets into a single subscription
49
+ // to avoid server overwriting previous subscriptions with same topic+type
50
+ accumulatedMarketTokenIds = new Set();
51
+ // Timer to batch market subscription updates
52
+ marketSubscriptionBatchTimer = null;
28
53
  // Caches
29
54
  priceCache = new Map();
30
55
  bookCache = new Map();
@@ -43,31 +68,124 @@ export class RealtimeServiceV2 extends EventEmitter {
43
68
  /**
44
69
  * Connect to WebSocket server
45
70
  */
46
- connect() {
71
+ async connect() {
47
72
  if (this.client) {
48
73
  this.log('Already connected or connecting');
49
- return this;
74
+ return;
50
75
  }
76
+ // Promises to track when all clients connect
77
+ const mainConnectPromise = new Promise((resolve) => {
78
+ this.connectResolve = resolve;
79
+ });
80
+ let userConnectResolve;
81
+ const userConnectPromise = new Promise((resolve) => {
82
+ userConnectResolve = resolve;
83
+ });
84
+ let cryptoConnectResolve;
85
+ const cryptoConnectPromise = new Promise((resolve) => {
86
+ cryptoConnectResolve = resolve;
87
+ });
88
+ // Main client for MARKET/USER channels
51
89
  this.client = new RealTimeDataClient({
52
90
  onConnect: this.handleConnect.bind(this),
53
91
  onMessage: this.handleMessage.bind(this),
54
92
  onStatusChange: this.handleStatusChange.bind(this),
55
93
  autoReconnect: this.config.autoReconnect,
56
94
  pingInterval: this.config.pingInterval,
95
+ debug: this.config.debug,
96
+ });
97
+ // User client for USER channel (clob_user events)
98
+ this.userClient = new RealTimeDataClient({
99
+ url: WS_ENDPOINTS.USER,
100
+ onConnect: (client) => {
101
+ this.handleUserConnect(client);
102
+ userConnectResolve();
103
+ },
104
+ onMessage: this.handleUserChannelMessage.bind(this),
105
+ onStatusChange: (status) => {
106
+ this.log(`User client status: ${status}`);
107
+ this.userConnected = status === ConnectionStatus.CONNECTED;
108
+ },
109
+ autoReconnect: this.config.autoReconnect,
110
+ pingInterval: this.config.pingInterval,
111
+ debug: this.config.debug,
112
+ });
113
+ // Crypto client for LIVE_DATA channel (crypto_prices)
114
+ this.cryptoClient = new RealTimeDataClient({
115
+ url: WS_ENDPOINTS.LIVE_DATA,
116
+ onConnect: (client) => {
117
+ this.handleCryptoConnect(client);
118
+ cryptoConnectResolve();
119
+ },
120
+ onMessage: this.handleCryptoMessage.bind(this),
121
+ onStatusChange: (status) => {
122
+ this.log(`Crypto client status: ${status}`);
123
+ this.cryptoConnected = status === ConnectionStatus.CONNECTED;
124
+ },
125
+ autoReconnect: this.config.autoReconnect,
126
+ pingInterval: this.config.pingInterval,
127
+ debug: this.config.debug,
57
128
  });
58
129
  this.client.connect();
59
- return this;
130
+ this.userClient.connect();
131
+ this.cryptoClient.connect();
132
+ // Wait for all clients to connect (with timeout)
133
+ const timeout = 10_000;
134
+ const timeoutPromise = new Promise((_, reject) => {
135
+ setTimeout(() => reject(new Error('WebSocket connection timeout (10s)')), timeout);
136
+ });
137
+ try {
138
+ await Promise.race([
139
+ Promise.all([mainConnectPromise, userConnectPromise, cryptoConnectPromise]),
140
+ timeoutPromise,
141
+ ]);
142
+ this.log('All WebSocket clients connected, ready to subscribe');
143
+ }
144
+ catch (error) {
145
+ // If timeout, check which clients connected
146
+ const status = {
147
+ main: this.connected,
148
+ user: this.userConnected,
149
+ crypto: this.cryptoConnected,
150
+ };
151
+ this.log(`WebSocket connection warning: ${error}. Status: ${JSON.stringify(status)}`);
152
+ // Continue anyway if main client is connected (minimum required)
153
+ if (!this.connected) {
154
+ throw error;
155
+ }
156
+ }
60
157
  }
61
158
  /**
62
159
  * Disconnect from WebSocket server
63
160
  */
64
161
  disconnect() {
162
+ this.cancelSubscriptionRefresh();
163
+ this.cancelMarketSubscriptionBatch();
65
164
  if (this.client) {
66
165
  this.client.disconnect();
67
166
  this.client = null;
68
167
  this.connected = false;
69
- this.subscriptions.clear();
70
- this.subscriptionMessages.clear(); // Clear reconnection list
168
+ }
169
+ if (this.userClient) {
170
+ this.userClient.disconnect();
171
+ this.userClient = null;
172
+ this.userConnected = false;
173
+ }
174
+ if (this.cryptoClient) {
175
+ this.cryptoClient.disconnect();
176
+ this.cryptoClient = null;
177
+ this.cryptoConnected = false;
178
+ }
179
+ this.subscriptions.clear();
180
+ this.subscriptionMessages.clear();
181
+ this.subscriptionGenerations.clear();
182
+ this.accumulatedMarketTokenIds.clear();
183
+ this.userCredentials = null;
184
+ }
185
+ cancelMarketSubscriptionBatch() {
186
+ if (this.marketSubscriptionBatchTimer) {
187
+ clearTimeout(this.marketSubscriptionBatchTimer);
188
+ this.marketSubscriptionBatchTimer = null;
71
189
  }
72
190
  }
73
191
  /**
@@ -83,21 +201,22 @@ export class RealtimeServiceV2 extends EventEmitter {
83
201
  * Subscribe to market data (orderbook, prices, trades)
84
202
  * @param tokenIds - Array of token IDs to subscribe to
85
203
  * @param handlers - Event handlers
204
+ *
205
+ * IMPORTANT: This method uses an accumulation strategy. Instead of sending
206
+ * separate subscription messages for each market, we accumulate all token IDs
207
+ * and send a single merged subscription. This prevents the server from
208
+ * overwriting previous subscriptions (which happens when multiple messages
209
+ * have the same topic+type but different filters).
86
210
  */
87
211
  subscribeMarkets(tokenIds, handlers = {}) {
88
212
  const subId = `market_${++this.subscriptionIdCounter}`;
89
- const filterStr = JSON.stringify(tokenIds);
90
- // Subscribe to all market data types
91
- const subscriptions = [
92
- { topic: 'clob_market', type: 'agg_orderbook', filters: filterStr },
93
- { topic: 'clob_market', type: 'price_change', filters: filterStr },
94
- { topic: 'clob_market', type: 'last_trade_price', filters: filterStr },
95
- { topic: 'clob_market', type: 'tick_size_change', filters: filterStr },
96
- ];
97
- const subMsg = { subscriptions };
98
- this.sendSubscription(subMsg);
99
- this.subscriptionMessages.set(subId, subMsg); // Store for reconnection
100
- // Register handlers
213
+ // Add new token IDs to accumulated set
214
+ for (const tokenId of tokenIds) {
215
+ this.accumulatedMarketTokenIds.add(tokenId);
216
+ }
217
+ // Schedule a batched subscription update (debounced)
218
+ this.scheduleMergedMarketSubscription();
219
+ // Register handlers (filtered by this subscription's tokenIds)
101
220
  const orderbookHandler = (book) => {
102
221
  if (tokenIds.includes(book.assetId)) {
103
222
  handlers.onOrderbook?.(book);
@@ -118,10 +237,16 @@ export class RealtimeServiceV2 extends EventEmitter {
118
237
  handlers.onTickSizeChange?.(change);
119
238
  }
120
239
  };
240
+ const bestBidAskHandler = (bba) => {
241
+ if (tokenIds.includes(bba.assetId)) {
242
+ handlers.onBestBidAsk?.(bba);
243
+ }
244
+ };
121
245
  this.on('orderbook', orderbookHandler);
122
246
  this.on('priceChange', priceChangeHandler);
123
247
  this.on('lastTrade', lastTradeHandler);
124
248
  this.on('tickSizeChange', tickSizeHandler);
249
+ this.on('bestBidAsk', bestBidAskHandler);
125
250
  const subscription = {
126
251
  id: subId,
127
252
  topic: 'clob_market',
@@ -132,14 +257,64 @@ export class RealtimeServiceV2 extends EventEmitter {
132
257
  this.off('priceChange', priceChangeHandler);
133
258
  this.off('lastTrade', lastTradeHandler);
134
259
  this.off('tickSizeChange', tickSizeHandler);
135
- this.sendUnsubscription({ subscriptions });
260
+ this.off('bestBidAsk', bestBidAskHandler);
261
+ // Remove these token IDs from accumulated set
262
+ for (const tokenId of tokenIds) {
263
+ this.accumulatedMarketTokenIds.delete(tokenId);
264
+ }
265
+ // Re-subscribe with remaining tokens (or send empty to clear)
266
+ this.scheduleMergedMarketSubscription();
136
267
  this.subscriptions.delete(subId);
137
- this.subscriptionMessages.delete(subId); // Remove from reconnection list
138
268
  },
139
269
  };
140
270
  this.subscriptions.set(subId, subscription);
141
271
  return subscription;
142
272
  }
273
+ /**
274
+ * Schedule a merged market subscription update.
275
+ * Debounces multiple rapid subscription changes into a single WebSocket message.
276
+ */
277
+ scheduleMergedMarketSubscription() {
278
+ // Clear existing timer
279
+ if (this.marketSubscriptionBatchTimer) {
280
+ clearTimeout(this.marketSubscriptionBatchTimer);
281
+ }
282
+ // Schedule subscription send after a short delay (100ms) to batch rapid changes
283
+ this.marketSubscriptionBatchTimer = setTimeout(() => {
284
+ this.marketSubscriptionBatchTimer = null;
285
+ this.sendMergedMarketSubscription();
286
+ }, 100);
287
+ }
288
+ /**
289
+ * Send a single merged subscription containing all accumulated market token IDs.
290
+ */
291
+ sendMergedMarketSubscription() {
292
+ if (!this.client || !this.connected) {
293
+ this.log('Cannot send merged subscription: not connected');
294
+ return;
295
+ }
296
+ const allTokenIds = Array.from(this.accumulatedMarketTokenIds);
297
+ if (allTokenIds.length === 0) {
298
+ this.log('No market tokens to subscribe to');
299
+ return;
300
+ }
301
+ const filterStr = JSON.stringify(allTokenIds);
302
+ const subscriptions = [
303
+ { topic: 'clob_market', type: 'agg_orderbook', filters: filterStr },
304
+ { topic: 'clob_market', type: 'price_change', filters: filterStr },
305
+ { topic: 'clob_market', type: 'last_trade_price', filters: filterStr },
306
+ { topic: 'clob_market', type: 'tick_size_change', filters: filterStr },
307
+ { topic: 'clob_market', type: 'best_bid_ask', filters: filterStr },
308
+ ];
309
+ const subMsg = { subscriptions };
310
+ this.log(`Sending merged market subscription with ${allTokenIds.length} tokens`);
311
+ this.client.subscribe(subMsg);
312
+ // Store for reconnection (use a fixed key for the merged subscription)
313
+ this.subscriptionMessages.set('__merged_market__', subMsg);
314
+ this.subscriptionGenerations.set('__merged_market__', this.connectionGeneration);
315
+ // Schedule refresh to ensure we receive updates (not just snapshot)
316
+ this.scheduleSubscriptionRefresh('__merged_market__');
317
+ }
143
318
  /**
144
319
  * Subscribe to a single market (YES + NO tokens)
145
320
  * Also emits derived price updates compatible with old API
@@ -240,15 +415,25 @@ export class RealtimeServiceV2 extends EventEmitter {
240
415
  // ============================================================================
241
416
  /**
242
417
  * Subscribe to user order and trade events
418
+ *
419
+ * User channel requires a separate WebSocket endpoint (USER endpoint).
420
+ * Subscription format: { type: 'USER', auth: { apiKey, secret, passphrase } }
421
+ *
243
422
  * @param credentials - CLOB API credentials
244
423
  * @param handlers - Event handlers
245
424
  */
246
425
  subscribeUserEvents(credentials, handlers = {}) {
247
426
  const subId = `user_${++this.subscriptionIdCounter}`;
248
- const subscriptions = [
249
- { topic: 'clob_user', type: '*', clob_auth: credentials },
250
- ];
251
- this.sendSubscription({ subscriptions });
427
+ // Store credentials for reconnection
428
+ this.userCredentials = credentials;
429
+ // Send subscription using the user client
430
+ if (this.userClient && this.userConnected) {
431
+ this.log('Sending user subscription via user client');
432
+ this.userClient.subscribeUser(credentials);
433
+ }
434
+ else {
435
+ this.log('User client not connected, will subscribe on connect');
436
+ }
252
437
  const orderHandler = (order) => handlers.onOrder?.(order);
253
438
  const tradeHandler = (trade) => handlers.onTrade?.(trade);
254
439
  this.on('userOrder', orderHandler);
@@ -260,82 +445,30 @@ export class RealtimeServiceV2 extends EventEmitter {
260
445
  unsubscribe: () => {
261
446
  this.off('userOrder', orderHandler);
262
447
  this.off('userTrade', tradeHandler);
263
- this.sendUnsubscription({ subscriptions });
264
- this.subscriptions.delete(subId);
265
- },
266
- };
267
- this.subscriptions.set(subId, subscription);
268
- return subscription;
269
- }
270
- // ============================================================================
271
- // Activity Subscriptions (trades, orders_matched)
272
- // ============================================================================
273
- /**
274
- * Subscribe to trading activity for a market or event
275
- * @param filter - Event or market slug (optional - if empty, subscribes to all activity)
276
- * @param handlers - Event handlers
277
- */
278
- subscribeActivity(filter = {}, handlers = {}) {
279
- const subId = `activity_${++this.subscriptionIdCounter}`;
280
- // Build filter object with snake_case keys (as expected by the server)
281
- // Only include filters if we have actual filter values
282
- const hasFilter = filter.eventSlug || filter.marketSlug;
283
- const filterObj = {};
284
- if (filter.eventSlug)
285
- filterObj.event_slug = filter.eventSlug;
286
- if (filter.marketSlug)
287
- filterObj.market_slug = filter.marketSlug;
288
- // Create subscription objects - only include filters field if we have filters
289
- const subscriptions = hasFilter
290
- ? [
291
- { topic: 'activity', type: 'trades', filters: JSON.stringify(filterObj) },
292
- { topic: 'activity', type: 'orders_matched', filters: JSON.stringify(filterObj) },
293
- ]
294
- : [
295
- { topic: 'activity', type: 'trades' },
296
- { topic: 'activity', type: 'orders_matched' },
297
- ];
298
- this.sendSubscription({ subscriptions });
299
- const handler = (trade) => handlers.onTrade?.(trade);
300
- this.on('activityTrade', handler);
301
- const subscription = {
302
- id: subId,
303
- topic: 'activity',
304
- type: '*',
305
- unsubscribe: () => {
306
- this.off('activityTrade', handler);
307
- this.sendUnsubscription({ subscriptions });
448
+ this.userCredentials = null;
308
449
  this.subscriptions.delete(subId);
309
450
  },
310
451
  };
311
452
  this.subscriptions.set(subId, subscription);
312
453
  return subscription;
313
454
  }
314
- /**
315
- * Subscribe to ALL trading activity across all markets (no filtering)
316
- * This is useful for Copy Trading - monitoring Smart Money across the platform
317
- * @param handlers - Event handlers
318
- */
319
- subscribeAllActivity(handlers = {}) {
320
- return this.subscribeActivity({}, handlers);
321
- }
322
455
  // ============================================================================
323
456
  // Crypto Price Subscriptions
324
457
  // ============================================================================
325
458
  /**
326
- * Subscribe to crypto price updates
327
- * @param symbols - Array of symbols (e.g., ['BTCUSDT', 'ETHUSDT'])
459
+ * Subscribe to crypto price updates (Binance)
460
+ *
461
+ * Uses lowercase symbols: 'btcusdt', 'ethusdt', 'solusdt', 'xrpusdt'
462
+ *
463
+ * @param symbols - Array of lowercase Binance symbols (e.g., ['btcusdt', 'ethusdt'])
328
464
  * @param handlers - Event handlers
329
465
  */
330
466
  subscribeCryptoPrices(symbols, handlers = {}) {
331
467
  const subId = `crypto_${++this.subscriptionIdCounter}`;
332
- // Subscribe to each symbol
333
- const subscriptions = symbols.map(symbol => ({
334
- topic: 'crypto_prices',
335
- type: 'update',
336
- filters: JSON.stringify({ symbol }),
337
- }));
338
- this.sendSubscription({ subscriptions });
468
+ // Use custom RealTimeDataClient method
469
+ if (this.cryptoClient) {
470
+ this.cryptoClient.subscribeCryptoPrices(symbols);
471
+ }
339
472
  const handler = (price) => {
340
473
  if (symbols.includes(price.symbol)) {
341
474
  handlers.onPrice?.(price);
@@ -348,7 +481,9 @@ export class RealtimeServiceV2 extends EventEmitter {
348
481
  type: 'update',
349
482
  unsubscribe: () => {
350
483
  this.off('cryptoPrice', handler);
351
- this.sendUnsubscription({ subscriptions });
484
+ if (this.cryptoClient) {
485
+ this.cryptoClient.unsubscribeCryptoPrices(symbols);
486
+ }
352
487
  this.subscriptions.delete(subId);
353
488
  },
354
489
  };
@@ -357,18 +492,18 @@ export class RealtimeServiceV2 extends EventEmitter {
357
492
  }
358
493
  /**
359
494
  * Subscribe to Chainlink crypto prices
360
- * @param symbols - Array of symbols (e.g., ['ETH/USD', 'BTC/USD'])
495
+ *
496
+ * Uses lowercase slash-separated symbols: 'btc/usd', 'eth/usd', 'sol/usd', 'xrp/usd'
497
+ *
498
+ * @param symbols - Array of lowercase Chainlink symbols (e.g., ['btc/usd', 'eth/usd'])
499
+ * @param handlers - Event handlers
361
500
  */
362
501
  subscribeCryptoChainlinkPrices(symbols, handlers = {}) {
363
502
  const subId = `crypto_chainlink_${++this.subscriptionIdCounter}`;
364
- const subscriptions = symbols.map(symbol => ({
365
- topic: 'crypto_prices_chainlink',
366
- type: 'update',
367
- filters: JSON.stringify({ symbol }),
368
- }));
369
- const subMsg = { subscriptions };
370
- this.sendSubscription(subMsg);
371
- this.subscriptionMessages.set(subId, subMsg); // Store for reconnection
503
+ // Use custom RealTimeDataClient method
504
+ if (this.cryptoClient) {
505
+ this.cryptoClient.subscribeCryptoChainlinkPrices(symbols);
506
+ }
372
507
  const handler = (price) => {
373
508
  if (symbols.includes(price.symbol)) {
374
509
  handlers.onPrice?.(price);
@@ -381,9 +516,10 @@ export class RealtimeServiceV2 extends EventEmitter {
381
516
  type: 'update',
382
517
  unsubscribe: () => {
383
518
  this.off('cryptoChainlinkPrice', handler);
384
- this.sendUnsubscription({ subscriptions });
519
+ if (this.cryptoClient) {
520
+ this.cryptoClient.unsubscribeCryptoChainlinkPrices(symbols);
521
+ }
385
522
  this.subscriptions.delete(subId);
386
- this.subscriptionMessages.delete(subId); // Remove from reconnection list
387
523
  },
388
524
  };
389
525
  this.subscriptions.set(subId, subscription);
@@ -542,21 +678,85 @@ export class RealtimeServiceV2 extends EventEmitter {
542
678
  sub.unsubscribe();
543
679
  }
544
680
  this.subscriptions.clear();
545
- this.subscriptionMessages.clear(); // Clear reconnection list
681
+ this.subscriptionMessages.clear();
682
+ this.subscriptionGenerations.clear();
546
683
  }
547
684
  // ============================================================================
548
685
  // Private Methods
549
686
  // ============================================================================
550
- handleConnect(client) {
687
+ /**
688
+ * Schedule a subscription refresh after a short delay.
689
+ *
690
+ * Problem: When subscriptions are sent right after connection, the server sometimes
691
+ * only sends the initial snapshot but no subsequent updates. This appears to be a
692
+ * server-side timing issue where the subscription "window" closes before updates flow.
693
+ *
694
+ * Solution: Re-send the subscription after 3 seconds. Polymarket's server apparently
695
+ * accepts duplicate subscriptions and refreshes the stream. Unsubscribe doesn't work
696
+ * (returns "Invalid request body"), so we just re-subscribe.
697
+ *
698
+ * Important: We do NOT cancel existing timers. If multiple subscriptions are added
699
+ * within the 3-second window, they all get added to pendingRefreshSubIds and will
700
+ * be refreshed together when the first timer fires. This ensures all markets get
701
+ * refreshed, not just the last one.
702
+ */
703
+ scheduleSubscriptionRefresh(subId) {
704
+ this.pendingRefreshSubIds.add(subId);
705
+ // Only create a new timer if one doesn't exist
706
+ // Don't cancel existing timer - it will refresh all pending subscriptions
707
+ if (this.subscriptionRefreshTimer) {
708
+ this.log(`Subscription ${subId} added to pending refresh (timer already scheduled)`);
709
+ return;
710
+ }
711
+ // Schedule refresh after 3 seconds (enough time for initial snapshot to arrive)
712
+ this.subscriptionRefreshTimer = setTimeout(() => {
713
+ this.subscriptionRefreshTimer = null;
714
+ if (!this.client || !this.connected || this.pendingRefreshSubIds.size === 0) {
715
+ this.pendingRefreshSubIds.clear();
716
+ return;
717
+ }
718
+ this.log(`Refreshing ${this.pendingRefreshSubIds.size} subscriptions (re-send)...`);
719
+ for (const pendingSubId of this.pendingRefreshSubIds) {
720
+ const msg = this.subscriptionMessages.get(pendingSubId);
721
+ if (msg) {
722
+ this.log(`Refresh: ${pendingSubId} - re-subscribe`);
723
+ this.client.subscribe(msg);
724
+ }
725
+ }
726
+ this.pendingRefreshSubIds.clear();
727
+ }, 3000);
728
+ }
729
+ cancelSubscriptionRefresh() {
730
+ if (this.subscriptionRefreshTimer) {
731
+ clearTimeout(this.subscriptionRefreshTimer);
732
+ this.subscriptionRefreshTimer = null;
733
+ }
734
+ this.pendingRefreshSubIds.clear();
735
+ }
736
+ handleConnect(_client) {
551
737
  this.connected = true;
552
- this.log('Connected to WebSocket server');
738
+ this.connectionGeneration++;
739
+ this.log(`Connected to WebSocket server (generation ${this.connectionGeneration})`);
740
+ // Resolve the connect() promise if waiting
741
+ if (this.connectResolve) {
742
+ this.connectResolve();
743
+ this.connectResolve = undefined;
744
+ }
553
745
  // Re-subscribe to all active subscriptions on reconnect
746
+ // Delay subscriptions by 1 second to let the connection stabilize.
747
+ // This helps avoid the "snapshot only, no updates" bug.
554
748
  if (this.subscriptionMessages.size > 0) {
555
- this.log(`Re-subscribing to ${this.subscriptionMessages.size} subscriptions...`);
556
- for (const [subId, msg] of this.subscriptionMessages) {
557
- this.log(`Re-subscribing: ${subId}`);
558
- this.client?.subscribe(msg);
559
- }
749
+ this.log(`Re-subscribing to ${this.subscriptionMessages.size} subscriptions (delayed 1s)...`);
750
+ setTimeout(() => {
751
+ if (!this.client || !this.connected)
752
+ return;
753
+ for (const [subId, msg] of this.subscriptionMessages) {
754
+ this.log(`Re-subscribing: ${subId}`);
755
+ this.client?.subscribe(msg);
756
+ // Update generation so unsubscribe knows it's valid on this connection
757
+ this.subscriptionGenerations.set(subId, this.connectionGeneration);
758
+ }
759
+ }, 1000);
560
760
  }
561
761
  this.emit('connected');
562
762
  }
@@ -564,6 +764,8 @@ export class RealtimeServiceV2 extends EventEmitter {
564
764
  this.log(`Connection status: ${status}`);
565
765
  if (status === ConnectionStatus.DISCONNECTED) {
566
766
  this.connected = false;
767
+ this.cancelSubscriptionRefresh();
768
+ this.cancelMarketSubscriptionBatch();
567
769
  this.emit('disconnected');
568
770
  }
569
771
  else if (status === ConnectionStatus.CONNECTED) {
@@ -571,7 +773,50 @@ export class RealtimeServiceV2 extends EventEmitter {
571
773
  }
572
774
  this.emit('statusChange', status);
573
775
  }
574
- handleMessage(client, message) {
776
+ handleUserConnect(_client) {
777
+ this.userConnected = true;
778
+ this.log('Connected to user channel WebSocket');
779
+ // Re-subscribe with stored credentials if available
780
+ if (this.userCredentials) {
781
+ this.log('Re-subscribing to user events with stored credentials');
782
+ setTimeout(() => {
783
+ if (this.userClient && this.userConnected && this.userCredentials) {
784
+ this.userClient.subscribeUser(this.userCredentials);
785
+ }
786
+ }, 1000);
787
+ }
788
+ this.emit('userConnected');
789
+ }
790
+ handleUserChannelMessage(_client, message) {
791
+ this.log(`User channel received: ${message.topic}:${message.type}`);
792
+ const payload = message.payload;
793
+ if (message.topic === 'clob_user') {
794
+ this.handleUserMessage(message.type, payload, message.timestamp);
795
+ }
796
+ else {
797
+ this.log(`Unexpected topic on user channel: ${message.topic}`);
798
+ }
799
+ }
800
+ handleCryptoConnect(_client) {
801
+ this.cryptoConnected = true;
802
+ this.log('Connected to crypto prices WebSocket');
803
+ this.emit('cryptoConnected');
804
+ }
805
+ handleCryptoMessage(_client, message) {
806
+ this.log(`Crypto received: ${message.topic}:${message.type}`);
807
+ const payload = message.payload;
808
+ switch (message.topic) {
809
+ case 'crypto_prices':
810
+ this.handleCryptoPriceMessage(payload, message.timestamp);
811
+ break;
812
+ case 'crypto_prices_chainlink':
813
+ this.handleCryptoChainlinkPriceMessage(payload, message.timestamp);
814
+ break;
815
+ default:
816
+ this.log(`Unknown crypto topic: ${message.topic}`);
817
+ }
818
+ }
819
+ handleMessage(_client, message) {
575
820
  this.log(`Received: ${message.topic}:${message.type}`);
576
821
  const payload = message.payload;
577
822
  switch (message.topic) {
@@ -581,9 +826,6 @@ export class RealtimeServiceV2 extends EventEmitter {
581
826
  case 'clob_user':
582
827
  this.handleUserMessage(message.type, payload, message.timestamp);
583
828
  break;
584
- case 'activity':
585
- this.handleActivityMessage(message.type, payload, message.timestamp);
586
- break;
587
829
  case 'crypto_prices':
588
830
  this.handleCryptoPriceMessage(payload, message.timestamp);
589
831
  break;
@@ -603,35 +845,95 @@ export class RealtimeServiceV2 extends EventEmitter {
603
845
  this.log(`Unknown topic: ${message.topic}`);
604
846
  }
605
847
  }
848
+ /**
849
+ * Handle market channel messages
850
+ * @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
851
+ *
852
+ * Market channel events:
853
+ * - book: Orderbook snapshot - triggered on subscribe or when trades affect orderbook
854
+ * - price_change: Price level change - triggered when order placed or cancelled
855
+ * - last_trade_price: Trade execution - triggered when maker/taker orders match
856
+ * - tick_size_change: Tick size adjustment - triggered when price > 0.96 or < 0.04
857
+ * - best_bid_ask: Best prices update (feature-flagged) - triggered on best price change
858
+ * - new_market: Market created (feature-flagged) - triggered on market creation
859
+ * - market_resolved: Market resolved (feature-flagged) - triggered on market resolution
860
+ */
606
861
  handleMarketMessage(type, payload, timestamp) {
607
862
  switch (type) {
863
+ case 'book': // New format from custom RealTimeDataClient
608
864
  case 'agg_orderbook': {
609
- const book = this.parseOrderbook(payload, timestamp);
610
- this.bookCache.set(book.assetId, book);
611
- this.emit('orderbook', book);
865
+ // book event: Orderbook snapshot with bids/asks
866
+ const items = Array.isArray(payload) ? payload : [payload];
867
+ for (const item of items) {
868
+ const book = this.parseOrderbook(item, timestamp);
869
+ if (book.assetId) {
870
+ this.bookCache.set(book.assetId, book);
871
+ this.emit('orderbook', book);
872
+ }
873
+ }
612
874
  break;
613
875
  }
614
876
  case 'price_change': {
615
- const change = this.parsePriceChange(payload, timestamp);
616
- this.emit('priceChange', change);
877
+ const items = Array.isArray(payload) ? payload : [payload];
878
+ for (const item of items) {
879
+ const change = this.parsePriceChange(item, timestamp);
880
+ if (change.assetId) {
881
+ this.emit('priceChange', change);
882
+ }
883
+ }
617
884
  break;
618
885
  }
619
886
  case 'last_trade_price': {
620
- const trade = this.parseLastTrade(payload, timestamp);
621
- this.lastTradeCache.set(trade.assetId, trade);
622
- this.emit('lastTrade', trade);
887
+ const items = Array.isArray(payload) ? payload : [payload];
888
+ for (const item of items) {
889
+ const trade = this.parseLastTrade(item, timestamp);
890
+ if (trade.assetId) {
891
+ this.lastTradeCache.set(trade.assetId, trade);
892
+ this.emit('lastTrade', trade);
893
+ }
894
+ }
623
895
  break;
624
896
  }
625
897
  case 'tick_size_change': {
898
+ // tick_size_change event: Tick size adjustment (price > 0.96 or < 0.04)
899
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
626
900
  const change = this.parseTickSizeChange(payload, timestamp);
627
901
  this.emit('tickSizeChange', change);
628
902
  break;
629
903
  }
630
- case 'market_created':
904
+ case 'best_bid_ask': {
905
+ // best_bid_ask event: Best prices changed (feature-flagged)
906
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
907
+ const bestPrices = {
908
+ assetId: payload.asset_id || '',
909
+ market: payload.market || '',
910
+ bestBid: Number(payload.best_bid) || 0,
911
+ bestAsk: Number(payload.best_ask) || 0,
912
+ spread: Number(payload.spread) || 0,
913
+ timestamp,
914
+ };
915
+ this.emit('bestBidAsk', bestPrices);
916
+ break;
917
+ }
918
+ case 'new_market':
919
+ case 'market_created': {
920
+ // new_market event: Market creation (feature-flagged)
921
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
922
+ const event = {
923
+ conditionId: payload.market || payload.condition_id || '',
924
+ type: 'created',
925
+ data: payload,
926
+ timestamp,
927
+ };
928
+ this.emit('marketEvent', event);
929
+ break;
930
+ }
631
931
  case 'market_resolved': {
932
+ // market_resolved event: Market resolution (feature-flagged)
933
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
632
934
  const event = {
633
- conditionId: payload.condition_id || '',
634
- type: type === 'market_created' ? 'created' : 'resolved',
935
+ conditionId: payload.market || payload.condition_id || '',
936
+ type: 'resolved',
635
937
  data: payload,
636
938
  timestamp,
637
939
  };
@@ -640,8 +942,18 @@ export class RealtimeServiceV2 extends EventEmitter {
640
942
  }
641
943
  }
642
944
  }
945
+ /**
946
+ * Handle user channel messages
947
+ * @see https://docs.polymarket.com/developers/CLOB/websocket/user-channel
948
+ *
949
+ * User channel events:
950
+ * - order: Emitted when order placed (PLACEMENT), partially matched (UPDATE), or cancelled (CANCELLATION)
951
+ * - trade: Emitted when market order matches, limit order included in trade, or status changes
952
+ * Status values: MATCHED, MINED, CONFIRMED, RETRYING, FAILED
953
+ */
643
954
  handleUserMessage(type, payload, timestamp) {
644
955
  if (type === 'order') {
956
+ // order event: Order placed (PLACEMENT), updated (UPDATE), or cancelled (CANCELLATION)
645
957
  const order = {
646
958
  orderId: payload.order_id || '',
647
959
  market: payload.market || '',
@@ -649,13 +961,26 @@ export class RealtimeServiceV2 extends EventEmitter {
649
961
  side: payload.side,
650
962
  price: Number(payload.price) || 0,
651
963
  originalSize: Number(payload.original_size) || 0,
652
- matchedSize: Number(payload.matched_size) || 0,
964
+ sizeMatched: Number(payload.size_matched) || 0, // API field: size_matched
653
965
  eventType: payload.event_type,
654
966
  timestamp,
655
967
  };
656
968
  this.emit('userOrder', order);
657
969
  }
658
970
  else if (type === 'trade') {
971
+ // trade event: Trade status updates (MATCHED, MINED, CONFIRMED, RETRYING, FAILED)
972
+ // Parse maker_orders array if present
973
+ let makerOrders;
974
+ if (Array.isArray(payload.maker_orders)) {
975
+ makerOrders = payload.maker_orders.map(m => ({
976
+ orderId: m.order_id || '',
977
+ matchedAmount: Number(m.matched_amount) || 0,
978
+ price: Number(m.price) || 0,
979
+ assetId: m.asset_id,
980
+ outcome: m.outcome,
981
+ owner: m.owner,
982
+ }));
983
+ }
659
984
  const trade = {
660
985
  tradeId: payload.trade_id || '',
661
986
  market: payload.market || '',
@@ -666,29 +991,13 @@ export class RealtimeServiceV2 extends EventEmitter {
666
991
  status: payload.status,
667
992
  timestamp,
668
993
  transactionHash: payload.transaction_hash,
994
+ // New fields for order-trade linking
995
+ takerOrderId: payload.taker_order_id,
996
+ makerOrders,
669
997
  };
670
998
  this.emit('userTrade', trade);
671
999
  }
672
1000
  }
673
- handleActivityMessage(type, payload, timestamp) {
674
- const trade = {
675
- asset: payload.asset || '',
676
- conditionId: payload.conditionId || '',
677
- eventSlug: payload.eventSlug || '',
678
- marketSlug: payload.slug || '',
679
- outcome: payload.outcome || '',
680
- price: Number(payload.price) || 0,
681
- side: payload.side,
682
- size: Number(payload.size) || 0,
683
- timestamp: this.normalizeTimestamp(payload.timestamp) || timestamp,
684
- transactionHash: payload.transactionHash || '',
685
- trader: {
686
- name: payload.name,
687
- address: payload.proxyWallet,
688
- },
689
- };
690
- this.emit('activityTrade', trade);
691
- }
692
1001
  handleCryptoPriceMessage(payload, timestamp) {
693
1002
  const price = {
694
1003
  symbol: payload.symbol || '',
@@ -795,12 +1104,14 @@ export class RealtimeServiceV2 extends EventEmitter {
795
1104
  };
796
1105
  }
797
1106
  parseLastTrade(payload, timestamp) {
1107
+ const feeRateBps = payload.fee_rate_bps !== undefined ? Number(payload.fee_rate_bps) : undefined;
798
1108
  return {
799
1109
  assetId: payload.asset_id || '',
800
1110
  price: parseFloat(payload.price) || 0,
801
1111
  side: payload.side || 'BUY',
802
1112
  size: parseFloat(payload.size) || 0,
803
1113
  timestamp: this.normalizeTimestamp(payload.timestamp) || timestamp,
1114
+ feeRateBps: feeRateBps !== undefined && !isNaN(feeRateBps) ? feeRateBps : undefined,
804
1115
  };
805
1116
  }
806
1117
  parseTickSizeChange(payload, timestamp) {
@@ -838,16 +1149,36 @@ export class RealtimeServiceV2 extends EventEmitter {
838
1149
  }
839
1150
  sendSubscription(msg) {
840
1151
  if (this.client && this.connected) {
1152
+ // Log subscription details (redact credentials)
1153
+ const loggableSubs = msg.subscriptions.map(s => ({
1154
+ topic: s.topic,
1155
+ type: s.type,
1156
+ filters: s.filters,
1157
+ hasAuth: !!s.clob_auth,
1158
+ }));
1159
+ this.log(`Sending subscription: ${JSON.stringify(loggableSubs)}`);
841
1160
  this.client.subscribe(msg);
842
1161
  }
843
1162
  else {
844
1163
  this.log('Cannot subscribe: not connected');
845
1164
  }
846
1165
  }
847
- sendUnsubscription(msg) {
848
- if (this.client && this.connected) {
849
- this.client.unsubscribe(msg);
1166
+ sendUnsubscription(msg, subId) {
1167
+ if (!this.client || !this.connected)
1168
+ return;
1169
+ // If subId is provided, only send unsubscribe if subscription is on current connection.
1170
+ // After reconnect, stale subscriptions may not exist on the server (expired markets),
1171
+ // so sending unsubscribe would trigger "Invalid request body" errors.
1172
+ if (subId) {
1173
+ const subGeneration = this.subscriptionGenerations.get(subId);
1174
+ if (subGeneration !== undefined && subGeneration !== this.connectionGeneration) {
1175
+ this.log(`Skipping unsubscribe for ${subId}: stale (gen ${subGeneration} vs current ${this.connectionGeneration})`);
1176
+ this.subscriptionGenerations.delete(subId);
1177
+ return;
1178
+ }
1179
+ this.subscriptionGenerations.delete(subId);
850
1180
  }
1181
+ this.client.unsubscribe(msg);
851
1182
  }
852
1183
  log(message) {
853
1184
  if (this.config.debug) {