@catalyst-team/poly-sdk 0.4.6 → 0.4.7

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 (55) 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 +25 -0
  4. package/dist/src/clients/data-api.d.ts.map +1 -1
  5. package/dist/src/clients/data-api.js +57 -0
  6. package/dist/src/clients/data-api.js.map +1 -1
  7. package/dist/src/core/types.d.ts +55 -0
  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 +4 -2
  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 -6
  44. package/dist/src/services/realtime-service-v2.d.ts.map +1 -1
  45. package/dist/src/services/realtime-service-v2.js +475 -70
  46. package/dist/src/services/realtime-service-v2.js.map +1 -1
  47. package/dist/src/services/trading-service.d.ts +129 -1
  48. package/dist/src/services/trading-service.d.ts.map +1 -1
  49. package/dist/src/services/trading-service.js +198 -5
  50. package/dist/src/services/trading-service.js.map +1 -1
  51. package/package.json +1 -2
  52. package/dist/src/services/ctf-detector.d.ts +0 -215
  53. package/dist/src/services/ctf-detector.d.ts.map +0 -1
  54. package/dist/src/services/ctf-detector.js +0 -420
  55. 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,7 +445,7 @@ export class RealtimeServiceV2 extends EventEmitter {
260
445
  unsubscribe: () => {
261
446
  this.off('userOrder', orderHandler);
262
447
  this.off('userTrade', tradeHandler);
263
- this.sendUnsubscription({ subscriptions });
448
+ this.userCredentials = null;
264
449
  this.subscriptions.delete(subId);
265
450
  },
266
451
  };
@@ -323,19 +508,19 @@ export class RealtimeServiceV2 extends EventEmitter {
323
508
  // Crypto Price Subscriptions
324
509
  // ============================================================================
325
510
  /**
326
- * Subscribe to crypto price updates
327
- * @param symbols - Array of symbols (e.g., ['BTCUSDT', 'ETHUSDT'])
511
+ * Subscribe to crypto price updates (Binance)
512
+ *
513
+ * Uses lowercase symbols: 'btcusdt', 'ethusdt', 'solusdt', 'xrpusdt'
514
+ *
515
+ * @param symbols - Array of lowercase Binance symbols (e.g., ['btcusdt', 'ethusdt'])
328
516
  * @param handlers - Event handlers
329
517
  */
330
518
  subscribeCryptoPrices(symbols, handlers = {}) {
331
519
  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 });
520
+ // Use custom RealTimeDataClient method
521
+ if (this.cryptoClient) {
522
+ this.cryptoClient.subscribeCryptoPrices(symbols);
523
+ }
339
524
  const handler = (price) => {
340
525
  if (symbols.includes(price.symbol)) {
341
526
  handlers.onPrice?.(price);
@@ -348,7 +533,9 @@ export class RealtimeServiceV2 extends EventEmitter {
348
533
  type: 'update',
349
534
  unsubscribe: () => {
350
535
  this.off('cryptoPrice', handler);
351
- this.sendUnsubscription({ subscriptions });
536
+ if (this.cryptoClient) {
537
+ this.cryptoClient.unsubscribeCryptoPrices(symbols);
538
+ }
352
539
  this.subscriptions.delete(subId);
353
540
  },
354
541
  };
@@ -357,18 +544,18 @@ export class RealtimeServiceV2 extends EventEmitter {
357
544
  }
358
545
  /**
359
546
  * Subscribe to Chainlink crypto prices
360
- * @param symbols - Array of symbols (e.g., ['ETH/USD', 'BTC/USD'])
547
+ *
548
+ * Uses lowercase slash-separated symbols: 'btc/usd', 'eth/usd', 'sol/usd', 'xrp/usd'
549
+ *
550
+ * @param symbols - Array of lowercase Chainlink symbols (e.g., ['btc/usd', 'eth/usd'])
551
+ * @param handlers - Event handlers
361
552
  */
362
553
  subscribeCryptoChainlinkPrices(symbols, handlers = {}) {
363
554
  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
555
+ // Use custom RealTimeDataClient method
556
+ if (this.cryptoClient) {
557
+ this.cryptoClient.subscribeCryptoChainlinkPrices(symbols);
558
+ }
372
559
  const handler = (price) => {
373
560
  if (symbols.includes(price.symbol)) {
374
561
  handlers.onPrice?.(price);
@@ -381,9 +568,10 @@ export class RealtimeServiceV2 extends EventEmitter {
381
568
  type: 'update',
382
569
  unsubscribe: () => {
383
570
  this.off('cryptoChainlinkPrice', handler);
384
- this.sendUnsubscription({ subscriptions });
571
+ if (this.cryptoClient) {
572
+ this.cryptoClient.unsubscribeCryptoChainlinkPrices(symbols);
573
+ }
385
574
  this.subscriptions.delete(subId);
386
- this.subscriptionMessages.delete(subId); // Remove from reconnection list
387
575
  },
388
576
  };
389
577
  this.subscriptions.set(subId, subscription);
@@ -542,21 +730,85 @@ export class RealtimeServiceV2 extends EventEmitter {
542
730
  sub.unsubscribe();
543
731
  }
544
732
  this.subscriptions.clear();
545
- this.subscriptionMessages.clear(); // Clear reconnection list
733
+ this.subscriptionMessages.clear();
734
+ this.subscriptionGenerations.clear();
546
735
  }
547
736
  // ============================================================================
548
737
  // Private Methods
549
738
  // ============================================================================
550
- handleConnect(client) {
739
+ /**
740
+ * Schedule a subscription refresh after a short delay.
741
+ *
742
+ * Problem: When subscriptions are sent right after connection, the server sometimes
743
+ * only sends the initial snapshot but no subsequent updates. This appears to be a
744
+ * server-side timing issue where the subscription "window" closes before updates flow.
745
+ *
746
+ * Solution: Re-send the subscription after 3 seconds. Polymarket's server apparently
747
+ * accepts duplicate subscriptions and refreshes the stream. Unsubscribe doesn't work
748
+ * (returns "Invalid request body"), so we just re-subscribe.
749
+ *
750
+ * Important: We do NOT cancel existing timers. If multiple subscriptions are added
751
+ * within the 3-second window, they all get added to pendingRefreshSubIds and will
752
+ * be refreshed together when the first timer fires. This ensures all markets get
753
+ * refreshed, not just the last one.
754
+ */
755
+ scheduleSubscriptionRefresh(subId) {
756
+ this.pendingRefreshSubIds.add(subId);
757
+ // Only create a new timer if one doesn't exist
758
+ // Don't cancel existing timer - it will refresh all pending subscriptions
759
+ if (this.subscriptionRefreshTimer) {
760
+ this.log(`Subscription ${subId} added to pending refresh (timer already scheduled)`);
761
+ return;
762
+ }
763
+ // Schedule refresh after 3 seconds (enough time for initial snapshot to arrive)
764
+ this.subscriptionRefreshTimer = setTimeout(() => {
765
+ this.subscriptionRefreshTimer = null;
766
+ if (!this.client || !this.connected || this.pendingRefreshSubIds.size === 0) {
767
+ this.pendingRefreshSubIds.clear();
768
+ return;
769
+ }
770
+ this.log(`Refreshing ${this.pendingRefreshSubIds.size} subscriptions (re-send)...`);
771
+ for (const pendingSubId of this.pendingRefreshSubIds) {
772
+ const msg = this.subscriptionMessages.get(pendingSubId);
773
+ if (msg) {
774
+ this.log(`Refresh: ${pendingSubId} - re-subscribe`);
775
+ this.client.subscribe(msg);
776
+ }
777
+ }
778
+ this.pendingRefreshSubIds.clear();
779
+ }, 3000);
780
+ }
781
+ cancelSubscriptionRefresh() {
782
+ if (this.subscriptionRefreshTimer) {
783
+ clearTimeout(this.subscriptionRefreshTimer);
784
+ this.subscriptionRefreshTimer = null;
785
+ }
786
+ this.pendingRefreshSubIds.clear();
787
+ }
788
+ handleConnect(_client) {
551
789
  this.connected = true;
552
- this.log('Connected to WebSocket server');
790
+ this.connectionGeneration++;
791
+ this.log(`Connected to WebSocket server (generation ${this.connectionGeneration})`);
792
+ // Resolve the connect() promise if waiting
793
+ if (this.connectResolve) {
794
+ this.connectResolve();
795
+ this.connectResolve = undefined;
796
+ }
553
797
  // Re-subscribe to all active subscriptions on reconnect
798
+ // Delay subscriptions by 1 second to let the connection stabilize.
799
+ // This helps avoid the "snapshot only, no updates" bug.
554
800
  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
- }
801
+ this.log(`Re-subscribing to ${this.subscriptionMessages.size} subscriptions (delayed 1s)...`);
802
+ setTimeout(() => {
803
+ if (!this.client || !this.connected)
804
+ return;
805
+ for (const [subId, msg] of this.subscriptionMessages) {
806
+ this.log(`Re-subscribing: ${subId}`);
807
+ this.client?.subscribe(msg);
808
+ // Update generation so unsubscribe knows it's valid on this connection
809
+ this.subscriptionGenerations.set(subId, this.connectionGeneration);
810
+ }
811
+ }, 1000);
560
812
  }
561
813
  this.emit('connected');
562
814
  }
@@ -564,6 +816,8 @@ export class RealtimeServiceV2 extends EventEmitter {
564
816
  this.log(`Connection status: ${status}`);
565
817
  if (status === ConnectionStatus.DISCONNECTED) {
566
818
  this.connected = false;
819
+ this.cancelSubscriptionRefresh();
820
+ this.cancelMarketSubscriptionBatch();
567
821
  this.emit('disconnected');
568
822
  }
569
823
  else if (status === ConnectionStatus.CONNECTED) {
@@ -571,7 +825,50 @@ export class RealtimeServiceV2 extends EventEmitter {
571
825
  }
572
826
  this.emit('statusChange', status);
573
827
  }
574
- handleMessage(client, message) {
828
+ handleUserConnect(_client) {
829
+ this.userConnected = true;
830
+ this.log('Connected to user channel WebSocket');
831
+ // Re-subscribe with stored credentials if available
832
+ if (this.userCredentials) {
833
+ this.log('Re-subscribing to user events with stored credentials');
834
+ setTimeout(() => {
835
+ if (this.userClient && this.userConnected && this.userCredentials) {
836
+ this.userClient.subscribeUser(this.userCredentials);
837
+ }
838
+ }, 1000);
839
+ }
840
+ this.emit('userConnected');
841
+ }
842
+ handleUserChannelMessage(_client, message) {
843
+ this.log(`User channel received: ${message.topic}:${message.type}`);
844
+ const payload = message.payload;
845
+ if (message.topic === 'clob_user') {
846
+ this.handleUserMessage(message.type, payload, message.timestamp);
847
+ }
848
+ else {
849
+ this.log(`Unexpected topic on user channel: ${message.topic}`);
850
+ }
851
+ }
852
+ handleCryptoConnect(_client) {
853
+ this.cryptoConnected = true;
854
+ this.log('Connected to crypto prices WebSocket');
855
+ this.emit('cryptoConnected');
856
+ }
857
+ handleCryptoMessage(_client, message) {
858
+ this.log(`Crypto received: ${message.topic}:${message.type}`);
859
+ const payload = message.payload;
860
+ switch (message.topic) {
861
+ case 'crypto_prices':
862
+ this.handleCryptoPriceMessage(payload, message.timestamp);
863
+ break;
864
+ case 'crypto_prices_chainlink':
865
+ this.handleCryptoChainlinkPriceMessage(payload, message.timestamp);
866
+ break;
867
+ default:
868
+ this.log(`Unknown crypto topic: ${message.topic}`);
869
+ }
870
+ }
871
+ handleMessage(_client, message) {
575
872
  this.log(`Received: ${message.topic}:${message.type}`);
576
873
  const payload = message.payload;
577
874
  switch (message.topic) {
@@ -603,35 +900,95 @@ export class RealtimeServiceV2 extends EventEmitter {
603
900
  this.log(`Unknown topic: ${message.topic}`);
604
901
  }
605
902
  }
903
+ /**
904
+ * Handle market channel messages
905
+ * @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
906
+ *
907
+ * Market channel events:
908
+ * - book: Orderbook snapshot - triggered on subscribe or when trades affect orderbook
909
+ * - price_change: Price level change - triggered when order placed or cancelled
910
+ * - last_trade_price: Trade execution - triggered when maker/taker orders match
911
+ * - tick_size_change: Tick size adjustment - triggered when price > 0.96 or < 0.04
912
+ * - best_bid_ask: Best prices update (feature-flagged) - triggered on best price change
913
+ * - new_market: Market created (feature-flagged) - triggered on market creation
914
+ * - market_resolved: Market resolved (feature-flagged) - triggered on market resolution
915
+ */
606
916
  handleMarketMessage(type, payload, timestamp) {
607
917
  switch (type) {
918
+ case 'book': // New format from custom RealTimeDataClient
608
919
  case 'agg_orderbook': {
609
- const book = this.parseOrderbook(payload, timestamp);
610
- this.bookCache.set(book.assetId, book);
611
- this.emit('orderbook', book);
920
+ // book event: Orderbook snapshot with bids/asks
921
+ const items = Array.isArray(payload) ? payload : [payload];
922
+ for (const item of items) {
923
+ const book = this.parseOrderbook(item, timestamp);
924
+ if (book.assetId) {
925
+ this.bookCache.set(book.assetId, book);
926
+ this.emit('orderbook', book);
927
+ }
928
+ }
612
929
  break;
613
930
  }
614
931
  case 'price_change': {
615
- const change = this.parsePriceChange(payload, timestamp);
616
- this.emit('priceChange', change);
932
+ const items = Array.isArray(payload) ? payload : [payload];
933
+ for (const item of items) {
934
+ const change = this.parsePriceChange(item, timestamp);
935
+ if (change.assetId) {
936
+ this.emit('priceChange', change);
937
+ }
938
+ }
617
939
  break;
618
940
  }
619
941
  case 'last_trade_price': {
620
- const trade = this.parseLastTrade(payload, timestamp);
621
- this.lastTradeCache.set(trade.assetId, trade);
622
- this.emit('lastTrade', trade);
942
+ const items = Array.isArray(payload) ? payload : [payload];
943
+ for (const item of items) {
944
+ const trade = this.parseLastTrade(item, timestamp);
945
+ if (trade.assetId) {
946
+ this.lastTradeCache.set(trade.assetId, trade);
947
+ this.emit('lastTrade', trade);
948
+ }
949
+ }
623
950
  break;
624
951
  }
625
952
  case 'tick_size_change': {
953
+ // tick_size_change event: Tick size adjustment (price > 0.96 or < 0.04)
954
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
626
955
  const change = this.parseTickSizeChange(payload, timestamp);
627
956
  this.emit('tickSizeChange', change);
628
957
  break;
629
958
  }
630
- case 'market_created':
959
+ case 'best_bid_ask': {
960
+ // best_bid_ask event: Best prices changed (feature-flagged)
961
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
962
+ const bestPrices = {
963
+ assetId: payload.asset_id || '',
964
+ market: payload.market || '',
965
+ bestBid: Number(payload.best_bid) || 0,
966
+ bestAsk: Number(payload.best_ask) || 0,
967
+ spread: Number(payload.spread) || 0,
968
+ timestamp,
969
+ };
970
+ this.emit('bestBidAsk', bestPrices);
971
+ break;
972
+ }
973
+ case 'new_market':
974
+ case 'market_created': {
975
+ // new_market event: Market creation (feature-flagged)
976
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
977
+ const event = {
978
+ conditionId: payload.market || payload.condition_id || '',
979
+ type: 'created',
980
+ data: payload,
981
+ timestamp,
982
+ };
983
+ this.emit('marketEvent', event);
984
+ break;
985
+ }
631
986
  case 'market_resolved': {
987
+ // market_resolved event: Market resolution (feature-flagged)
988
+ // @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
632
989
  const event = {
633
- conditionId: payload.condition_id || '',
634
- type: type === 'market_created' ? 'created' : 'resolved',
990
+ conditionId: payload.market || payload.condition_id || '',
991
+ type: 'resolved',
635
992
  data: payload,
636
993
  timestamp,
637
994
  };
@@ -640,8 +997,18 @@ export class RealtimeServiceV2 extends EventEmitter {
640
997
  }
641
998
  }
642
999
  }
1000
+ /**
1001
+ * Handle user channel messages
1002
+ * @see https://docs.polymarket.com/developers/CLOB/websocket/user-channel
1003
+ *
1004
+ * User channel events:
1005
+ * - order: Emitted when order placed (PLACEMENT), partially matched (UPDATE), or cancelled (CANCELLATION)
1006
+ * - trade: Emitted when market order matches, limit order included in trade, or status changes
1007
+ * Status values: MATCHED, MINED, CONFIRMED, RETRYING, FAILED
1008
+ */
643
1009
  handleUserMessage(type, payload, timestamp) {
644
1010
  if (type === 'order') {
1011
+ // order event: Order placed (PLACEMENT), updated (UPDATE), or cancelled (CANCELLATION)
645
1012
  const order = {
646
1013
  orderId: payload.order_id || '',
647
1014
  market: payload.market || '',
@@ -649,13 +1016,26 @@ export class RealtimeServiceV2 extends EventEmitter {
649
1016
  side: payload.side,
650
1017
  price: Number(payload.price) || 0,
651
1018
  originalSize: Number(payload.original_size) || 0,
652
- matchedSize: Number(payload.matched_size) || 0,
1019
+ sizeMatched: Number(payload.size_matched) || 0, // API field: size_matched
653
1020
  eventType: payload.event_type,
654
1021
  timestamp,
655
1022
  };
656
1023
  this.emit('userOrder', order);
657
1024
  }
658
1025
  else if (type === 'trade') {
1026
+ // trade event: Trade status updates (MATCHED, MINED, CONFIRMED, RETRYING, FAILED)
1027
+ // Parse maker_orders array if present
1028
+ let makerOrders;
1029
+ if (Array.isArray(payload.maker_orders)) {
1030
+ makerOrders = payload.maker_orders.map(m => ({
1031
+ orderId: m.order_id || '',
1032
+ matchedAmount: Number(m.matched_amount) || 0,
1033
+ price: Number(m.price) || 0,
1034
+ assetId: m.asset_id,
1035
+ outcome: m.outcome,
1036
+ owner: m.owner,
1037
+ }));
1038
+ }
659
1039
  const trade = {
660
1040
  tradeId: payload.trade_id || '',
661
1041
  market: payload.market || '',
@@ -666,6 +1046,9 @@ export class RealtimeServiceV2 extends EventEmitter {
666
1046
  status: payload.status,
667
1047
  timestamp,
668
1048
  transactionHash: payload.transaction_hash,
1049
+ // New fields for order-trade linking
1050
+ takerOrderId: payload.taker_order_id,
1051
+ makerOrders,
669
1052
  };
670
1053
  this.emit('userTrade', trade);
671
1054
  }
@@ -795,12 +1178,14 @@ export class RealtimeServiceV2 extends EventEmitter {
795
1178
  };
796
1179
  }
797
1180
  parseLastTrade(payload, timestamp) {
1181
+ const feeRateBps = payload.fee_rate_bps !== undefined ? Number(payload.fee_rate_bps) : undefined;
798
1182
  return {
799
1183
  assetId: payload.asset_id || '',
800
1184
  price: parseFloat(payload.price) || 0,
801
1185
  side: payload.side || 'BUY',
802
1186
  size: parseFloat(payload.size) || 0,
803
1187
  timestamp: this.normalizeTimestamp(payload.timestamp) || timestamp,
1188
+ feeRateBps: feeRateBps !== undefined && !isNaN(feeRateBps) ? feeRateBps : undefined,
804
1189
  };
805
1190
  }
806
1191
  parseTickSizeChange(payload, timestamp) {
@@ -838,16 +1223,36 @@ export class RealtimeServiceV2 extends EventEmitter {
838
1223
  }
839
1224
  sendSubscription(msg) {
840
1225
  if (this.client && this.connected) {
1226
+ // Log subscription details (redact credentials)
1227
+ const loggableSubs = msg.subscriptions.map(s => ({
1228
+ topic: s.topic,
1229
+ type: s.type,
1230
+ filters: s.filters,
1231
+ hasAuth: !!s.clob_auth,
1232
+ }));
1233
+ this.log(`Sending subscription: ${JSON.stringify(loggableSubs)}`);
841
1234
  this.client.subscribe(msg);
842
1235
  }
843
1236
  else {
844
1237
  this.log('Cannot subscribe: not connected');
845
1238
  }
846
1239
  }
847
- sendUnsubscription(msg) {
848
- if (this.client && this.connected) {
849
- this.client.unsubscribe(msg);
1240
+ sendUnsubscription(msg, subId) {
1241
+ if (!this.client || !this.connected)
1242
+ return;
1243
+ // If subId is provided, only send unsubscribe if subscription is on current connection.
1244
+ // After reconnect, stale subscriptions may not exist on the server (expired markets),
1245
+ // so sending unsubscribe would trigger "Invalid request body" errors.
1246
+ if (subId) {
1247
+ const subGeneration = this.subscriptionGenerations.get(subId);
1248
+ if (subGeneration !== undefined && subGeneration !== this.connectionGeneration) {
1249
+ this.log(`Skipping unsubscribe for ${subId}: stale (gen ${subGeneration} vs current ${this.connectionGeneration})`);
1250
+ this.subscriptionGenerations.delete(subId);
1251
+ return;
1252
+ }
1253
+ this.subscriptionGenerations.delete(subId);
850
1254
  }
1255
+ this.client.unsubscribe(msg);
851
1256
  }
852
1257
  log(message) {
853
1258
  if (this.config.debug) {