@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.
- package/README.md +18 -9
- package/README.zh-CN.md +18 -9
- package/dist/src/clients/data-api.d.ts +25 -0
- package/dist/src/clients/data-api.d.ts.map +1 -1
- package/dist/src/clients/data-api.js +57 -0
- package/dist/src/clients/data-api.js.map +1 -1
- package/dist/src/core/types.d.ts +55 -0
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/realtime/index.d.ts +18 -0
- package/dist/src/realtime/index.d.ts.map +1 -0
- package/dist/src/realtime/index.js +14 -0
- package/dist/src/realtime/index.js.map +1 -0
- package/dist/src/realtime/realtime-data-client.d.ts +274 -0
- package/dist/src/realtime/realtime-data-client.d.ts.map +1 -0
- package/dist/src/realtime/realtime-data-client.js +771 -0
- package/dist/src/realtime/realtime-data-client.js.map +1 -0
- package/dist/src/realtime/types.d.ts +485 -0
- package/dist/src/realtime/types.d.ts.map +1 -0
- package/dist/src/realtime/types.js +36 -0
- package/dist/src/realtime/types.js.map +1 -0
- package/dist/src/services/arbitrage-service.d.ts.map +1 -1
- package/dist/src/services/arbitrage-service.js +2 -1
- package/dist/src/services/arbitrage-service.js.map +1 -1
- package/dist/src/services/dip-arb-service.d.ts.map +1 -1
- package/dist/src/services/dip-arb-service.js +3 -19
- package/dist/src/services/dip-arb-service.js.map +1 -1
- package/dist/src/services/market-service.d.ts +93 -11
- package/dist/src/services/market-service.d.ts.map +1 -1
- package/dist/src/services/market-service.js +189 -22
- package/dist/src/services/market-service.js.map +1 -1
- package/dist/src/services/order-handle.test.d.ts +15 -0
- package/dist/src/services/order-handle.test.d.ts.map +1 -0
- package/dist/src/services/order-handle.test.js +333 -0
- package/dist/src/services/order-handle.test.js.map +1 -0
- package/dist/src/services/order-manager.d.ts +162 -6
- package/dist/src/services/order-manager.d.ts.map +1 -1
- package/dist/src/services/order-manager.js +419 -30
- package/dist/src/services/order-manager.js.map +1 -1
- package/dist/src/services/realtime-service-v2.d.ts +122 -6
- package/dist/src/services/realtime-service-v2.d.ts.map +1 -1
- package/dist/src/services/realtime-service-v2.js +475 -70
- package/dist/src/services/realtime-service-v2.js.map +1 -1
- package/dist/src/services/trading-service.d.ts +129 -1
- package/dist/src/services/trading-service.d.ts.map +1 -1
- package/dist/src/services/trading-service.js +198 -5
- package/dist/src/services/trading-service.js.map +1 -1
- package/package.json +1 -2
- package/dist/src/services/ctf-detector.d.ts +0 -215
- package/dist/src/services/ctf-detector.d.ts.map +0 -1
- package/dist/src/services/ctf-detector.js +0 -420
- 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 '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
this.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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.
|
|
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();
|
|
733
|
+
this.subscriptionMessages.clear();
|
|
734
|
+
this.subscriptionGenerations.clear();
|
|
546
735
|
}
|
|
547
736
|
// ============================================================================
|
|
548
737
|
// Private Methods
|
|
549
738
|
// ============================================================================
|
|
550
|
-
|
|
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.
|
|
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
|
-
|
|
557
|
-
this.
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
616
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
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 '
|
|
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:
|
|
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
|
-
|
|
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
|
|
849
|
-
|
|
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) {
|