@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
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RealTimeDataClient
|
|
3
|
+
*
|
|
4
|
+
* Custom WebSocket client for Polymarket real-time data.
|
|
5
|
+
* Replaces @polymarket/real-time-data-client with proper:
|
|
6
|
+
* - RFC 6455 ping/pong mechanism
|
|
7
|
+
* - Exponential backoff reconnection
|
|
8
|
+
* - Full subscription management
|
|
9
|
+
*
|
|
10
|
+
* WebSocket Protocol (from official docs):
|
|
11
|
+
* - URL: wss://ws-subscriptions-clob.polymarket.com/ws/market
|
|
12
|
+
* - Initial subscription: { type: "MARKET", assets_ids: ["token_id_1", ...] }
|
|
13
|
+
* - Dynamic subscribe: { operation: "subscribe", assets_ids: ["token_id_1"] }
|
|
14
|
+
* - Dynamic unsubscribe: { operation: "unsubscribe", assets_ids: ["token_id_1"] }
|
|
15
|
+
*
|
|
16
|
+
* Event types: book, price_change, last_trade_price, tick_size_change, best_bid_ask
|
|
17
|
+
*
|
|
18
|
+
* @see https://docs.polymarket.com/developers/CLOB/websocket/wss-overview
|
|
19
|
+
* @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
|
|
20
|
+
*/
|
|
21
|
+
import WebSocket from 'ws';
|
|
22
|
+
import { ConnectionStatus, WS_ENDPOINTS, } from './types.js';
|
|
23
|
+
// Default to market channel
|
|
24
|
+
const DEFAULT_URL = WS_ENDPOINTS.MARKET;
|
|
25
|
+
const DEFAULT_PING_INTERVAL = 30_000; // 30 seconds
|
|
26
|
+
const DEFAULT_PONG_TIMEOUT = 10_000; // 10 seconds
|
|
27
|
+
const DEFAULT_RECONNECT_DELAY = 1_000; // 1 second
|
|
28
|
+
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
|
|
29
|
+
export class RealTimeDataClient {
|
|
30
|
+
ws = null;
|
|
31
|
+
config;
|
|
32
|
+
status = ConnectionStatus.DISCONNECTED;
|
|
33
|
+
reconnectAttempts = 0;
|
|
34
|
+
reconnectTimer = null;
|
|
35
|
+
pingTimer = null;
|
|
36
|
+
pongTimer = null;
|
|
37
|
+
pongReceived = true;
|
|
38
|
+
intentionalDisconnect = false;
|
|
39
|
+
constructor(config = {}) {
|
|
40
|
+
// Determine URL: explicit URL > channel-based URL > default
|
|
41
|
+
let url = config.url;
|
|
42
|
+
if (!url) {
|
|
43
|
+
if (config.channel === 'USER') {
|
|
44
|
+
url = WS_ENDPOINTS.USER;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
url = WS_ENDPOINTS.MARKET;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.config = {
|
|
51
|
+
url,
|
|
52
|
+
autoReconnect: config.autoReconnect ?? true,
|
|
53
|
+
pingInterval: config.pingInterval ?? DEFAULT_PING_INTERVAL,
|
|
54
|
+
reconnectDelay: config.reconnectDelay ?? DEFAULT_RECONNECT_DELAY,
|
|
55
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
|
56
|
+
pongTimeout: config.pongTimeout ?? DEFAULT_PONG_TIMEOUT,
|
|
57
|
+
debug: config.debug ?? false,
|
|
58
|
+
onConnect: config.onConnect,
|
|
59
|
+
onMessage: config.onMessage,
|
|
60
|
+
onStatusChange: config.onStatusChange,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Connect to the WebSocket server
|
|
65
|
+
*/
|
|
66
|
+
connect() {
|
|
67
|
+
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
|
|
68
|
+
this.log('Already connected or connecting');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.intentionalDisconnect = false;
|
|
72
|
+
this.setStatus(ConnectionStatus.CONNECTING);
|
|
73
|
+
this.log(`Connecting to ${this.config.url}`);
|
|
74
|
+
this.ws = new WebSocket(this.config.url);
|
|
75
|
+
this.ws.on('open', this.handleOpen.bind(this));
|
|
76
|
+
this.ws.on('message', this.handleMessage.bind(this));
|
|
77
|
+
this.ws.on('close', this.handleClose.bind(this));
|
|
78
|
+
this.ws.on('error', this.handleError.bind(this));
|
|
79
|
+
this.ws.on('pong', this.handlePong.bind(this));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Disconnect from the WebSocket server
|
|
83
|
+
*/
|
|
84
|
+
disconnect() {
|
|
85
|
+
this.log('Disconnecting...');
|
|
86
|
+
this.intentionalDisconnect = true;
|
|
87
|
+
this.cleanup();
|
|
88
|
+
if (this.ws) {
|
|
89
|
+
this.ws.close(1000, 'Client disconnect');
|
|
90
|
+
this.ws = null;
|
|
91
|
+
}
|
|
92
|
+
this.setStatus(ConnectionStatus.DISCONNECTED);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Subscribe to market data
|
|
96
|
+
*
|
|
97
|
+
* For initial subscription (when first connecting or adding new tokens):
|
|
98
|
+
* { type: "MARKET", assets_ids: ["token_id_1", "token_id_2"] }
|
|
99
|
+
*
|
|
100
|
+
* For adding tokens to existing subscription:
|
|
101
|
+
* { operation: "subscribe", assets_ids: ["token_id_3"] }
|
|
102
|
+
*
|
|
103
|
+
* @deprecated Use subscribeMarket() for the new API format
|
|
104
|
+
*/
|
|
105
|
+
subscribe(msg) {
|
|
106
|
+
// Convert old format to new format for backwards compatibility
|
|
107
|
+
const assetsIds = [];
|
|
108
|
+
for (const sub of msg.subscriptions) {
|
|
109
|
+
if (sub.filters) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(sub.filters);
|
|
112
|
+
if (Array.isArray(parsed)) {
|
|
113
|
+
assetsIds.push(...parsed);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Ignore parse errors
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (assetsIds.length > 0) {
|
|
122
|
+
this.subscribeMarket(assetsIds);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Unsubscribe from market data
|
|
127
|
+
*
|
|
128
|
+
* Format: { operation: "unsubscribe", assets_ids: ["token_id_1"] }
|
|
129
|
+
*
|
|
130
|
+
* @deprecated Use unsubscribeMarket() for the new API format
|
|
131
|
+
*/
|
|
132
|
+
unsubscribe(msg) {
|
|
133
|
+
// Convert old format to new format for backwards compatibility
|
|
134
|
+
const assetsIds = [];
|
|
135
|
+
for (const sub of msg.subscriptions) {
|
|
136
|
+
if (sub.filters) {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(sub.filters);
|
|
139
|
+
if (Array.isArray(parsed)) {
|
|
140
|
+
assetsIds.push(...parsed);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Ignore parse errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (assetsIds.length > 0) {
|
|
149
|
+
this.unsubscribeMarket(assetsIds);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Subscribe to market data (new API)
|
|
154
|
+
*
|
|
155
|
+
* @param assetsIds - Array of token IDs to subscribe to
|
|
156
|
+
* @param isInitial - If true, sends initial subscription format { type: "MARKET", ... }
|
|
157
|
+
* If false, sends dynamic format { operation: "subscribe", ... }
|
|
158
|
+
*/
|
|
159
|
+
subscribeMarket(assetsIds, isInitial = true) {
|
|
160
|
+
if (isInitial) {
|
|
161
|
+
// Initial subscription format
|
|
162
|
+
const msg = {
|
|
163
|
+
type: 'MARKET',
|
|
164
|
+
assets_ids: assetsIds,
|
|
165
|
+
};
|
|
166
|
+
this.send(JSON.stringify(msg));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Dynamic subscription format
|
|
170
|
+
const msg = {
|
|
171
|
+
operation: 'subscribe',
|
|
172
|
+
assets_ids: assetsIds,
|
|
173
|
+
};
|
|
174
|
+
this.send(JSON.stringify(msg));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Unsubscribe from market data (new API)
|
|
179
|
+
*
|
|
180
|
+
* @param assetsIds - Array of token IDs to unsubscribe from
|
|
181
|
+
*/
|
|
182
|
+
unsubscribeMarket(assetsIds) {
|
|
183
|
+
const msg = {
|
|
184
|
+
operation: 'unsubscribe',
|
|
185
|
+
assets_ids: assetsIds,
|
|
186
|
+
};
|
|
187
|
+
this.send(JSON.stringify(msg));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Subscribe to user channel (requires authentication)
|
|
191
|
+
*
|
|
192
|
+
* User channel provides personal order and trade events.
|
|
193
|
+
* Note: This requires connecting to the USER WebSocket endpoint.
|
|
194
|
+
*
|
|
195
|
+
* Format: { type: "USER", auth: { apiKey, secret, passphrase }, markets?: [...] }
|
|
196
|
+
*
|
|
197
|
+
* @param auth - CLOB API credentials
|
|
198
|
+
* @param markets - Optional array of condition IDs to filter events
|
|
199
|
+
*/
|
|
200
|
+
subscribeUser(auth, markets) {
|
|
201
|
+
const msg = {
|
|
202
|
+
type: 'USER',
|
|
203
|
+
auth,
|
|
204
|
+
};
|
|
205
|
+
if (markets && markets.length > 0) {
|
|
206
|
+
msg.markets = markets;
|
|
207
|
+
}
|
|
208
|
+
this.send(JSON.stringify(msg));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Subscribe to Binance crypto prices
|
|
212
|
+
*
|
|
213
|
+
* Provides real-time price updates from Binance exchange.
|
|
214
|
+
*
|
|
215
|
+
* **Usage:**
|
|
216
|
+
* ```typescript
|
|
217
|
+
* const client = new RealTimeDataClient({ url: WS_ENDPOINTS.LIVE_DATA });
|
|
218
|
+
* client.connect();
|
|
219
|
+
* client.subscribeCryptoPrices(['btcusdt', 'ethusdt', 'solusdt', 'xrpusdt']);
|
|
220
|
+
* ```
|
|
221
|
+
*
|
|
222
|
+
* **Symbol Format:** Lowercase concatenated pairs (e.g., 'btcusdt', 'ethusdt', 'solusdt', 'xrpusdt')
|
|
223
|
+
*
|
|
224
|
+
* **Subscription Message Format:**
|
|
225
|
+
* ```json
|
|
226
|
+
* {
|
|
227
|
+
* "action": "subscribe",
|
|
228
|
+
* "subscriptions": [{
|
|
229
|
+
* "topic": "crypto_prices",
|
|
230
|
+
* "type": "*",
|
|
231
|
+
* "filters": "{\"symbol\":\"btcusdt\"}"
|
|
232
|
+
* }]
|
|
233
|
+
* }
|
|
234
|
+
* ```
|
|
235
|
+
*
|
|
236
|
+
* **Response Message Format:**
|
|
237
|
+
* ```json
|
|
238
|
+
* {
|
|
239
|
+
* "topic": "crypto_prices",
|
|
240
|
+
* "type": "update",
|
|
241
|
+
* "timestamp": 1769846473135,
|
|
242
|
+
* "payload": {
|
|
243
|
+
* "symbol": "ethusdt",
|
|
244
|
+
* "timestamp": 1769846473000,
|
|
245
|
+
* "value": 2681.42,
|
|
246
|
+
* "full_accuracy_value": "2681.42000000"
|
|
247
|
+
* }
|
|
248
|
+
* }
|
|
249
|
+
* ```
|
|
250
|
+
*
|
|
251
|
+
* **Important Notes:**
|
|
252
|
+
* - Each symbol requires a separate subscription message
|
|
253
|
+
* - Use `type: "*"` (not `"update"`)
|
|
254
|
+
* - Filters must be JSON-stringified: `JSON.stringify({ symbol: "btcusdt" })`
|
|
255
|
+
* - This implementation matches the official @polymarket/real-time-data-client
|
|
256
|
+
*
|
|
257
|
+
* @param symbols - Array of Binance symbols in lowercase (e.g., ['btcusdt', 'ethusdt'])
|
|
258
|
+
* @see https://docs.polymarket.com/developers/RTDS/RTDS-crypto-prices
|
|
259
|
+
* @see https://github.com/Polymarket/real-time-data-client
|
|
260
|
+
*/
|
|
261
|
+
subscribeCryptoPrices(symbols) {
|
|
262
|
+
if (symbols.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
for (const symbol of symbols) {
|
|
265
|
+
const msg = {
|
|
266
|
+
action: 'subscribe',
|
|
267
|
+
subscriptions: [
|
|
268
|
+
{
|
|
269
|
+
topic: 'crypto_prices',
|
|
270
|
+
type: '*',
|
|
271
|
+
filters: JSON.stringify({ symbol }),
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
this.send(JSON.stringify(msg));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Unsubscribe from Binance crypto prices
|
|
280
|
+
*
|
|
281
|
+
* @param symbols - Array of Binance symbols to unsubscribe from
|
|
282
|
+
*/
|
|
283
|
+
unsubscribeCryptoPrices(symbols) {
|
|
284
|
+
if (symbols.length === 0)
|
|
285
|
+
return;
|
|
286
|
+
for (const symbol of symbols) {
|
|
287
|
+
const msg = {
|
|
288
|
+
action: 'unsubscribe',
|
|
289
|
+
subscriptions: [
|
|
290
|
+
{
|
|
291
|
+
topic: 'crypto_prices',
|
|
292
|
+
type: '*',
|
|
293
|
+
filters: JSON.stringify({ symbol }),
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
this.send(JSON.stringify(msg));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Subscribe to Chainlink crypto prices
|
|
302
|
+
*
|
|
303
|
+
* Provides real-time price updates from Chainlink oracles (official settlement price source).
|
|
304
|
+
*
|
|
305
|
+
* **Usage:**
|
|
306
|
+
* ```typescript
|
|
307
|
+
* const client = new RealTimeDataClient({ url: WS_ENDPOINTS.LIVE_DATA });
|
|
308
|
+
* client.connect();
|
|
309
|
+
* client.subscribeCryptoChainlinkPrices(['btc/usd', 'eth/usd', 'sol/usd', 'xrp/usd']);
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* **Symbol Format:** Lowercase slash-separated pairs (e.g., 'btc/usd', 'eth/usd', 'sol/usd', 'xrp/usd')
|
|
313
|
+
*
|
|
314
|
+
* **Subscription Message Format:**
|
|
315
|
+
* ```json
|
|
316
|
+
* {
|
|
317
|
+
* "action": "subscribe",
|
|
318
|
+
* "subscriptions": [{
|
|
319
|
+
* "topic": "crypto_prices_chainlink",
|
|
320
|
+
* "type": "*",
|
|
321
|
+
* "filters": "{\"symbol\":\"btc/usd\"}"
|
|
322
|
+
* }]
|
|
323
|
+
* }
|
|
324
|
+
* ```
|
|
325
|
+
*
|
|
326
|
+
* **Response Message Format:**
|
|
327
|
+
* ```json
|
|
328
|
+
* {
|
|
329
|
+
* "topic": "crypto_prices_chainlink",
|
|
330
|
+
* "type": "update",
|
|
331
|
+
* "timestamp": 1769833333076,
|
|
332
|
+
* "payload": {
|
|
333
|
+
* "symbol": "btc/usd",
|
|
334
|
+
* "timestamp": 1769833332000,
|
|
335
|
+
* "value": 83915.04025109926,
|
|
336
|
+
* "full_accuracy_value": "83915040251099250000000"
|
|
337
|
+
* }
|
|
338
|
+
* }
|
|
339
|
+
* ```
|
|
340
|
+
*
|
|
341
|
+
* **Important Notes:**
|
|
342
|
+
* - Each symbol requires a separate subscription message
|
|
343
|
+
* - Use `type: "*"` for all message types
|
|
344
|
+
* - Filters must be JSON-stringified: `JSON.stringify({ symbol: "btc/usd" })`
|
|
345
|
+
* - Chainlink prices are used as the official settlement source for 15m crypto markets
|
|
346
|
+
* - Higher precision available via `full_accuracy_value` field
|
|
347
|
+
*
|
|
348
|
+
* @param symbols - Array of Chainlink symbols in lowercase (e.g., ['btc/usd', 'eth/usd'])
|
|
349
|
+
* @see https://docs.polymarket.com/developers/RTDS/RTDS-crypto-prices
|
|
350
|
+
* @see https://data.chain.link/streams - Official Chainlink data feeds
|
|
351
|
+
*/
|
|
352
|
+
subscribeCryptoChainlinkPrices(symbols) {
|
|
353
|
+
if (symbols.length === 0)
|
|
354
|
+
return;
|
|
355
|
+
for (const symbol of symbols) {
|
|
356
|
+
const msg = {
|
|
357
|
+
action: 'subscribe',
|
|
358
|
+
subscriptions: [
|
|
359
|
+
{
|
|
360
|
+
topic: 'crypto_prices_chainlink',
|
|
361
|
+
type: '*',
|
|
362
|
+
filters: JSON.stringify({ symbol }),
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
this.send(JSON.stringify(msg));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Unsubscribe from Chainlink crypto prices
|
|
371
|
+
*
|
|
372
|
+
* @param symbols - Array of Chainlink symbols to unsubscribe from
|
|
373
|
+
*/
|
|
374
|
+
unsubscribeCryptoChainlinkPrices(symbols) {
|
|
375
|
+
if (symbols.length === 0)
|
|
376
|
+
return;
|
|
377
|
+
for (const symbol of symbols) {
|
|
378
|
+
const msg = {
|
|
379
|
+
action: 'unsubscribe',
|
|
380
|
+
subscriptions: [
|
|
381
|
+
{
|
|
382
|
+
topic: 'crypto_prices_chainlink',
|
|
383
|
+
type: '*',
|
|
384
|
+
filters: JSON.stringify({ symbol }),
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
this.send(JSON.stringify(msg));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Check if connected
|
|
393
|
+
*/
|
|
394
|
+
isConnected() {
|
|
395
|
+
return this.status === ConnectionStatus.CONNECTED;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Get current connection status
|
|
399
|
+
*/
|
|
400
|
+
getStatus() {
|
|
401
|
+
return this.status;
|
|
402
|
+
}
|
|
403
|
+
// ============================================================================
|
|
404
|
+
// Private Methods
|
|
405
|
+
// ============================================================================
|
|
406
|
+
send(data) {
|
|
407
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
408
|
+
this.ws.send(data);
|
|
409
|
+
this.log(`Sent: ${data.slice(0, 200)}${data.length > 200 ? '...' : ''}`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
this.log('Cannot send: WebSocket not open');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
handleOpen() {
|
|
416
|
+
this.log('WebSocket connected');
|
|
417
|
+
this.reconnectAttempts = 0;
|
|
418
|
+
this.pongReceived = true;
|
|
419
|
+
this.setStatus(ConnectionStatus.CONNECTED);
|
|
420
|
+
this.startPing();
|
|
421
|
+
this.config.onConnect?.(this);
|
|
422
|
+
}
|
|
423
|
+
handleMessage(data) {
|
|
424
|
+
try {
|
|
425
|
+
const raw = data.toString();
|
|
426
|
+
this.log(`Raw message: ${raw.slice(0, 200)}${raw.length > 200 ? '...' : ''}`);
|
|
427
|
+
const parsed = JSON.parse(raw);
|
|
428
|
+
// Handle different message formats from Polymarket CLOB WebSocket
|
|
429
|
+
const messages = this.parseMessages(parsed);
|
|
430
|
+
for (const message of messages) {
|
|
431
|
+
this.config.onMessage?.(this, message);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
this.log(`Message parse error: ${err}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Parse incoming WebSocket messages into our Message format
|
|
440
|
+
*
|
|
441
|
+
* ## Market Channel Events (topic: 'clob_market')
|
|
442
|
+
* @see https://docs.polymarket.com/developers/CLOB/websocket/market-channel
|
|
443
|
+
*
|
|
444
|
+
* | Event Type | Format | Trigger |
|
|
445
|
+
* |-------------------|-----------------------------------------------------------|----------------------------------|
|
|
446
|
+
* | book | Array: [{ market, asset_id, bids, asks, timestamp, hash }]| Initial subscribe or trade |
|
|
447
|
+
* | price_change | { market, price_changes: [{ asset_id, price, size, ... }]}| Order placed or cancelled |
|
|
448
|
+
* | last_trade_price | { market, asset_id, price, side, size, fee_rate_bps, ... }| Trade execution |
|
|
449
|
+
* | tick_size_change | { market, asset_id, old_tick_size, new_tick_size, ... } | Price > 0.96 or < 0.04 |
|
|
450
|
+
* | best_bid_ask | { market, asset_id, best_bid, best_ask, spread, ... } | Best price change (feature-flag) |
|
|
451
|
+
* | new_market | { id, question, market, slug, assets_ids, outcomes, ... } | Market creation (feature-flag) |
|
|
452
|
+
* | market_resolved | { ..., winning_asset_id, winning_outcome } | Market resolved (feature-flag) |
|
|
453
|
+
*
|
|
454
|
+
* ## User Channel Events (topic: 'clob_user')
|
|
455
|
+
* @see https://docs.polymarket.com/developers/CLOB/websocket/user-channel
|
|
456
|
+
*
|
|
457
|
+
* | Event Type | Format | Trigger |
|
|
458
|
+
* |------------|-----------------------------------------------------------------|--------------------------------|
|
|
459
|
+
* | trade | { event_type: 'trade', status, side, price, size, maker_orders }| Order matched/mined/confirmed |
|
|
460
|
+
* | order | { event_type: 'order', type, side, price, original_size, ... } | Order placed/updated/cancelled |
|
|
461
|
+
*/
|
|
462
|
+
parseMessages(raw) {
|
|
463
|
+
const messages = [];
|
|
464
|
+
// ========================================================================
|
|
465
|
+
// Array messages (book snapshots from market channel)
|
|
466
|
+
// ========================================================================
|
|
467
|
+
if (Array.isArray(raw)) {
|
|
468
|
+
for (const item of raw) {
|
|
469
|
+
if (typeof item === 'object' && item !== null) {
|
|
470
|
+
const book = item;
|
|
471
|
+
// book event: Orderbook snapshot with bids/asks
|
|
472
|
+
if ('bids' in book || 'asks' in book) {
|
|
473
|
+
const timestamp = this.normalizeTimestamp(book.timestamp);
|
|
474
|
+
messages.push({
|
|
475
|
+
topic: 'clob_market',
|
|
476
|
+
type: 'book',
|
|
477
|
+
timestamp,
|
|
478
|
+
payload: book,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return messages;
|
|
484
|
+
}
|
|
485
|
+
// ========================================================================
|
|
486
|
+
// Object messages
|
|
487
|
+
// ========================================================================
|
|
488
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
489
|
+
const obj = raw;
|
|
490
|
+
const timestamp = this.normalizeTimestamp(obj.timestamp) || Date.now();
|
|
491
|
+
// ----------------------------------------------------------------------
|
|
492
|
+
// User Channel Events (check event_type field first)
|
|
493
|
+
// ----------------------------------------------------------------------
|
|
494
|
+
// trade event: Trade status updates (MATCHED, MINED, CONFIRMED, RETRYING, FAILED)
|
|
495
|
+
if (obj.event_type === 'trade' || ('status' in obj && 'maker_orders' in obj)) {
|
|
496
|
+
messages.push({
|
|
497
|
+
topic: 'clob_user',
|
|
498
|
+
type: 'trade',
|
|
499
|
+
timestamp,
|
|
500
|
+
payload: obj,
|
|
501
|
+
});
|
|
502
|
+
return messages;
|
|
503
|
+
}
|
|
504
|
+
// order event: Order placed (PLACEMENT), updated (UPDATE), or cancelled (CANCELLATION)
|
|
505
|
+
if (obj.event_type === 'order' || ('original_size' in obj && 'size_matched' in obj)) {
|
|
506
|
+
messages.push({
|
|
507
|
+
topic: 'clob_user',
|
|
508
|
+
type: 'order',
|
|
509
|
+
timestamp,
|
|
510
|
+
payload: obj,
|
|
511
|
+
});
|
|
512
|
+
return messages;
|
|
513
|
+
}
|
|
514
|
+
// ----------------------------------------------------------------------
|
|
515
|
+
// Market Channel Events
|
|
516
|
+
// ----------------------------------------------------------------------
|
|
517
|
+
// price_change event: Order placed or cancelled (price level changes)
|
|
518
|
+
if ('price_changes' in obj && Array.isArray(obj.price_changes)) {
|
|
519
|
+
for (const change of obj.price_changes) {
|
|
520
|
+
messages.push({
|
|
521
|
+
topic: 'clob_market',
|
|
522
|
+
type: 'price_change',
|
|
523
|
+
timestamp,
|
|
524
|
+
payload: {
|
|
525
|
+
market: obj.market,
|
|
526
|
+
...change,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return messages;
|
|
531
|
+
}
|
|
532
|
+
// last_trade_price event: Trade execution (maker/taker matched)
|
|
533
|
+
if ('fee_rate_bps' in obj || ('price' in obj && 'side' in obj && 'size' in obj && !('price_changes' in obj) && !('original_size' in obj))) {
|
|
534
|
+
messages.push({
|
|
535
|
+
topic: 'clob_market',
|
|
536
|
+
type: 'last_trade_price',
|
|
537
|
+
timestamp,
|
|
538
|
+
payload: obj,
|
|
539
|
+
});
|
|
540
|
+
return messages;
|
|
541
|
+
}
|
|
542
|
+
// tick_size_change event: Tick size adjustment (price > 0.96 or < 0.04)
|
|
543
|
+
if ('old_tick_size' in obj || 'new_tick_size' in obj) {
|
|
544
|
+
messages.push({
|
|
545
|
+
topic: 'clob_market',
|
|
546
|
+
type: 'tick_size_change',
|
|
547
|
+
timestamp,
|
|
548
|
+
payload: obj,
|
|
549
|
+
});
|
|
550
|
+
return messages;
|
|
551
|
+
}
|
|
552
|
+
// best_bid_ask event: Best prices changed (feature-flagged)
|
|
553
|
+
if ('best_bid' in obj && 'best_ask' in obj && 'spread' in obj && !('price_changes' in obj)) {
|
|
554
|
+
messages.push({
|
|
555
|
+
topic: 'clob_market',
|
|
556
|
+
type: 'best_bid_ask',
|
|
557
|
+
timestamp,
|
|
558
|
+
payload: obj,
|
|
559
|
+
});
|
|
560
|
+
return messages;
|
|
561
|
+
}
|
|
562
|
+
// market_resolved event: Market resolution (feature-flagged)
|
|
563
|
+
// Must check before new_market since it extends new_market fields
|
|
564
|
+
if ('winning_asset_id' in obj || 'winning_outcome' in obj) {
|
|
565
|
+
messages.push({
|
|
566
|
+
topic: 'clob_market',
|
|
567
|
+
type: 'market_resolved',
|
|
568
|
+
timestamp,
|
|
569
|
+
payload: obj,
|
|
570
|
+
});
|
|
571
|
+
return messages;
|
|
572
|
+
}
|
|
573
|
+
// new_market event: Market creation (feature-flagged)
|
|
574
|
+
if ('question' in obj && 'slug' in obj && 'assets_ids' in obj && 'outcomes' in obj) {
|
|
575
|
+
messages.push({
|
|
576
|
+
topic: 'clob_market',
|
|
577
|
+
type: 'new_market',
|
|
578
|
+
timestamp,
|
|
579
|
+
payload: obj,
|
|
580
|
+
});
|
|
581
|
+
return messages;
|
|
582
|
+
}
|
|
583
|
+
// book event: Single orderbook snapshot (fallback for non-array format)
|
|
584
|
+
if ('bids' in obj || 'asks' in obj) {
|
|
585
|
+
messages.push({
|
|
586
|
+
topic: 'clob_market',
|
|
587
|
+
type: 'book',
|
|
588
|
+
timestamp,
|
|
589
|
+
payload: obj,
|
|
590
|
+
});
|
|
591
|
+
return messages;
|
|
592
|
+
}
|
|
593
|
+
// ----------------------------------------------------------------------
|
|
594
|
+
// Crypto Price Events (topic: crypto_prices or crypto_prices_chainlink)
|
|
595
|
+
// @see https://docs.polymarket.com/developers/RTDS/RTDS-crypto-prices
|
|
596
|
+
// ----------------------------------------------------------------------
|
|
597
|
+
// crypto_prices or crypto_prices_chainlink: Real-time price updates
|
|
598
|
+
if (obj.topic === 'crypto_prices' || obj.topic === 'crypto_prices_chainlink') {
|
|
599
|
+
const payload = obj.payload;
|
|
600
|
+
const priceTimestamp = this.normalizeTimestamp(payload?.timestamp) || timestamp;
|
|
601
|
+
messages.push({
|
|
602
|
+
topic: obj.topic,
|
|
603
|
+
type: obj.type || 'update',
|
|
604
|
+
timestamp: priceTimestamp,
|
|
605
|
+
payload: payload || obj,
|
|
606
|
+
});
|
|
607
|
+
return messages;
|
|
608
|
+
}
|
|
609
|
+
// Unknown message - log for debugging
|
|
610
|
+
this.log(`Unknown message format: ${JSON.stringify(obj).slice(0, 100)}`);
|
|
611
|
+
}
|
|
612
|
+
return messages;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Normalize timestamp to milliseconds
|
|
616
|
+
* Polymarket sends timestamps in seconds, need to convert to milliseconds
|
|
617
|
+
*/
|
|
618
|
+
normalizeTimestamp(ts) {
|
|
619
|
+
if (typeof ts === 'string') {
|
|
620
|
+
const parsed = parseInt(ts, 10);
|
|
621
|
+
if (isNaN(parsed))
|
|
622
|
+
return Date.now();
|
|
623
|
+
// If timestamp is in seconds (< 1e12), convert to milliseconds
|
|
624
|
+
return parsed < 1e12 ? parsed * 1000 : parsed;
|
|
625
|
+
}
|
|
626
|
+
if (typeof ts === 'number') {
|
|
627
|
+
// If timestamp is in seconds (< 1e12), convert to milliseconds
|
|
628
|
+
return ts < 1e12 ? ts * 1000 : ts;
|
|
629
|
+
}
|
|
630
|
+
return Date.now();
|
|
631
|
+
}
|
|
632
|
+
handleClose(code, reason) {
|
|
633
|
+
this.log(`WebSocket closed: ${code} - ${reason.toString()}`);
|
|
634
|
+
this.cleanup();
|
|
635
|
+
if (this.intentionalDisconnect) {
|
|
636
|
+
this.setStatus(ConnectionStatus.DISCONNECTED);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (this.config.autoReconnect) {
|
|
640
|
+
this.handleReconnect();
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
this.setStatus(ConnectionStatus.DISCONNECTED);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
handleError(err) {
|
|
647
|
+
this.log(`WebSocket error: ${err.message}`);
|
|
648
|
+
// Don't set status here - the 'close' event will follow
|
|
649
|
+
}
|
|
650
|
+
handlePong() {
|
|
651
|
+
this.log('Received pong');
|
|
652
|
+
this.pongReceived = true;
|
|
653
|
+
this.clearPongTimeout();
|
|
654
|
+
}
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// Ping/Pong Mechanism
|
|
657
|
+
// ============================================================================
|
|
658
|
+
/**
|
|
659
|
+
* Start periodic ping to keep connection alive
|
|
660
|
+
*
|
|
661
|
+
* Uses RFC 6455 WebSocket ping frames, which the server MUST respond to
|
|
662
|
+
* with pong frames. If no pong is received within pongTimeout, we
|
|
663
|
+
* consider the connection dead and reconnect.
|
|
664
|
+
*/
|
|
665
|
+
startPing() {
|
|
666
|
+
this.stopPing();
|
|
667
|
+
this.pingTimer = setInterval(() => {
|
|
668
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
669
|
+
this.log('Ping skipped: WebSocket not open');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (!this.pongReceived) {
|
|
673
|
+
this.log('Pong not received for previous ping - connection may be dead');
|
|
674
|
+
this.handleDeadConnection();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.pongReceived = false;
|
|
678
|
+
this.ws.ping();
|
|
679
|
+
this.log('Sent ping');
|
|
680
|
+
// Set timeout for pong response
|
|
681
|
+
this.setPongTimeout();
|
|
682
|
+
}, this.config.pingInterval);
|
|
683
|
+
}
|
|
684
|
+
stopPing() {
|
|
685
|
+
if (this.pingTimer) {
|
|
686
|
+
clearInterval(this.pingTimer);
|
|
687
|
+
this.pingTimer = null;
|
|
688
|
+
}
|
|
689
|
+
this.clearPongTimeout();
|
|
690
|
+
}
|
|
691
|
+
setPongTimeout() {
|
|
692
|
+
this.clearPongTimeout();
|
|
693
|
+
this.pongTimer = setTimeout(() => {
|
|
694
|
+
if (!this.pongReceived) {
|
|
695
|
+
this.log('Pong timeout - connection dead');
|
|
696
|
+
this.handleDeadConnection();
|
|
697
|
+
}
|
|
698
|
+
}, this.config.pongTimeout);
|
|
699
|
+
}
|
|
700
|
+
clearPongTimeout() {
|
|
701
|
+
if (this.pongTimer) {
|
|
702
|
+
clearTimeout(this.pongTimer);
|
|
703
|
+
this.pongTimer = null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
handleDeadConnection() {
|
|
707
|
+
this.log('Handling dead connection');
|
|
708
|
+
this.cleanup();
|
|
709
|
+
if (this.ws) {
|
|
710
|
+
this.ws.terminate(); // Force close
|
|
711
|
+
this.ws = null;
|
|
712
|
+
}
|
|
713
|
+
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
|
714
|
+
this.handleReconnect();
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
this.setStatus(ConnectionStatus.DISCONNECTED);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ============================================================================
|
|
721
|
+
// Reconnection Logic
|
|
722
|
+
// ============================================================================
|
|
723
|
+
/**
|
|
724
|
+
* Handle reconnection with exponential backoff
|
|
725
|
+
* Delays: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 512s (capped at maxReconnectAttempts)
|
|
726
|
+
*/
|
|
727
|
+
handleReconnect() {
|
|
728
|
+
if (this.intentionalDisconnect) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
732
|
+
this.log(`Max reconnect attempts (${this.config.maxReconnectAttempts}) reached`);
|
|
733
|
+
this.setStatus(ConnectionStatus.DISCONNECTED);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts);
|
|
737
|
+
this.reconnectAttempts++;
|
|
738
|
+
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})`);
|
|
739
|
+
this.setStatus(ConnectionStatus.RECONNECTING);
|
|
740
|
+
this.reconnectTimer = setTimeout(() => {
|
|
741
|
+
this.reconnectTimer = null;
|
|
742
|
+
this.connect();
|
|
743
|
+
}, delay);
|
|
744
|
+
}
|
|
745
|
+
cancelReconnect() {
|
|
746
|
+
if (this.reconnectTimer) {
|
|
747
|
+
clearTimeout(this.reconnectTimer);
|
|
748
|
+
this.reconnectTimer = null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// ============================================================================
|
|
752
|
+
// Utility Methods
|
|
753
|
+
// ============================================================================
|
|
754
|
+
cleanup() {
|
|
755
|
+
this.stopPing();
|
|
756
|
+
this.cancelReconnect();
|
|
757
|
+
}
|
|
758
|
+
setStatus(status) {
|
|
759
|
+
if (this.status !== status) {
|
|
760
|
+
this.status = status;
|
|
761
|
+
this.log(`Status changed: ${status}`);
|
|
762
|
+
this.config.onStatusChange?.(status);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
log(message) {
|
|
766
|
+
if (this.config.debug) {
|
|
767
|
+
console.log(`[RealTimeDataClient] ${message}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
//# sourceMappingURL=realtime-data-client.js.map
|