@nevuamarkets/poly-websockets 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +251 -0
- package/dist/WSSubscriptionManager.d.ts +18 -0
- package/dist/WSSubscriptionManager.js +174 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +21 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +34 -0
- package/dist/modules/GroupRegistry.d.ts +85 -0
- package/dist/modules/GroupRegistry.js +261 -0
- package/dist/modules/GroupSocket.d.ts +22 -0
- package/dist/modules/GroupSocket.js +311 -0
- package/dist/modules/OrderBookCache.d.ts +49 -0
- package/dist/modules/OrderBookCache.js +173 -0
- package/dist/types/PolymarketWebSocket.d.ts +242 -0
- package/dist/types/PolymarketWebSocket.js +50 -0
- package/dist/types/WebSocketSubscriptions.d.ts +19 -0
- package/dist/types/WebSocketSubscriptions.js +10 -0
- package/package.json +49 -0
- package/src/WSSubscriptionManager.ts +201 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +37 -0
- package/src/modules/GroupRegistry.ts +274 -0
- package/src/modules/GroupSocket.ts +338 -0
- package/src/modules/OrderBookCache.ts +208 -0
- package/src/types/PolymarketWebSocket.ts +280 -0
- package/src/types/WebSocketSubscriptions.ts +26 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GroupRegistry = void 0;
|
|
7
|
+
const async_mutex_1 = require("async-mutex");
|
|
8
|
+
const lodash_1 = __importDefault(require("lodash"));
|
|
9
|
+
const uuid_1 = require("uuid");
|
|
10
|
+
const WebSocketSubscriptions_1 = require("../types/WebSocketSubscriptions");
|
|
11
|
+
const logger_1 = require("../logger");
|
|
12
|
+
/*
|
|
13
|
+
* Global group store and mutex, intentionally **not** exported anymore to prevent
|
|
14
|
+
* accidental external mutation. All access should go through the helper methods
|
|
15
|
+
* on GroupRegistry instead.
|
|
16
|
+
*/
|
|
17
|
+
const wsGroups = [];
|
|
18
|
+
const wsGroupsMutex = new async_mutex_1.Mutex();
|
|
19
|
+
class GroupRegistry {
|
|
20
|
+
/**
|
|
21
|
+
* Atomic mutate helper.
|
|
22
|
+
*
|
|
23
|
+
* @param fn - The function to run atomically.
|
|
24
|
+
* @returns The result of the function.
|
|
25
|
+
*/
|
|
26
|
+
async mutate(fn) {
|
|
27
|
+
const release = await wsGroupsMutex.acquire();
|
|
28
|
+
try {
|
|
29
|
+
return await fn(wsGroups);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
release();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read-only copy of the registry.
|
|
37
|
+
*
|
|
38
|
+
* Only to be used in test suite.
|
|
39
|
+
*/
|
|
40
|
+
snapshot() {
|
|
41
|
+
return wsGroups.map(group => ({
|
|
42
|
+
...group,
|
|
43
|
+
assetIds: new Set(group.assetIds),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Find the first group with capacity to hold new assets.
|
|
48
|
+
*
|
|
49
|
+
* Returns the groupId if found, otherwise null.
|
|
50
|
+
*/
|
|
51
|
+
findGroupWithCapacity(newAssetLen, maxPerWS) {
|
|
52
|
+
for (const group of wsGroups) {
|
|
53
|
+
if (group.assetIds.size === 0)
|
|
54
|
+
continue;
|
|
55
|
+
if (group.assetIds.size + newAssetLen <= maxPerWS)
|
|
56
|
+
return group.groupId;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the indices of all groups that contain the asset.
|
|
62
|
+
*
|
|
63
|
+
* Returns an array of indices.
|
|
64
|
+
*/
|
|
65
|
+
getGroupIndicesForAsset(assetId) {
|
|
66
|
+
var _a;
|
|
67
|
+
const indices = [];
|
|
68
|
+
for (let i = 0; i < wsGroups.length; i++) {
|
|
69
|
+
if ((_a = wsGroups[i]) === null || _a === void 0 ? void 0 : _a.assetIds.has(assetId))
|
|
70
|
+
indices.push(i);
|
|
71
|
+
}
|
|
72
|
+
return indices;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if any group contains the asset.
|
|
76
|
+
*/
|
|
77
|
+
hasAsset(assetId) {
|
|
78
|
+
return wsGroups.some(group => group.assetIds.has(assetId));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Find the group by groupId.
|
|
82
|
+
*
|
|
83
|
+
* Returns the group if found, otherwise undefined.
|
|
84
|
+
*/
|
|
85
|
+
findGroupById(groupId) {
|
|
86
|
+
return wsGroups.find(g => g.groupId === groupId);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Atomically remove **all** groups from the registry and return them so the
|
|
90
|
+
* caller can perform any asynchronous cleanup (closing sockets, etc.)
|
|
91
|
+
* outside the lock.
|
|
92
|
+
*
|
|
93
|
+
* Returns the removed groups.
|
|
94
|
+
*/
|
|
95
|
+
async clearAllGroups() {
|
|
96
|
+
let removed = [];
|
|
97
|
+
await this.mutate(groups => {
|
|
98
|
+
removed = [...groups];
|
|
99
|
+
groups.length = 0;
|
|
100
|
+
});
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Add new asset subscriptions.
|
|
105
|
+
*
|
|
106
|
+
* – Ignores assets that are already subscribed.
|
|
107
|
+
* – Either reuses an existing group with capacity or creates new groups (size ≤ maxPerWS).
|
|
108
|
+
* – If appending to a group:
|
|
109
|
+
* - A new group is created with the updated assetIds.
|
|
110
|
+
* - The old group is marked for cleanup.
|
|
111
|
+
* - The group is added to the list of groups to connect.
|
|
112
|
+
*
|
|
113
|
+
* @param assetIds - The assetIds to add.
|
|
114
|
+
* @param maxPerWS - The maximum number of assets per WebSocket group.
|
|
115
|
+
* @returns An array of *new* groupIds that need websocket connections.
|
|
116
|
+
*/
|
|
117
|
+
async addAssets(assetIds, maxPerWS) {
|
|
118
|
+
const groupIdsToConnect = [];
|
|
119
|
+
let newAssetIds = [];
|
|
120
|
+
await this.mutate(groups => {
|
|
121
|
+
newAssetIds = assetIds.filter(id => !groups.some(g => g.assetIds.has(id)));
|
|
122
|
+
if (newAssetIds.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const existingGroupId = this.findGroupWithCapacity(newAssetIds.length, maxPerWS);
|
|
125
|
+
/*
|
|
126
|
+
If no existing group with capacity is found, create new groups.
|
|
127
|
+
*/
|
|
128
|
+
if (existingGroupId === null) {
|
|
129
|
+
const chunks = lodash_1.default.chunk(newAssetIds, maxPerWS);
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
const groupId = (0, uuid_1.v4)();
|
|
132
|
+
groups.push({
|
|
133
|
+
groupId,
|
|
134
|
+
assetIds: new Set(chunk),
|
|
135
|
+
wsClient: null,
|
|
136
|
+
status: WebSocketSubscriptions_1.WebSocketStatus.PENDING
|
|
137
|
+
});
|
|
138
|
+
groupIdsToConnect.push(groupId);
|
|
139
|
+
}
|
|
140
|
+
/*
|
|
141
|
+
If an existing group with capacity is found, update the group.
|
|
142
|
+
*/
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const existingGroup = groups.find(g => g.groupId === existingGroupId);
|
|
146
|
+
if (!existingGroup) {
|
|
147
|
+
// Should never happen
|
|
148
|
+
throw new Error(`Group with capacity not found for ${newAssetIds.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
const updatedAssetIds = new Set([...existingGroup.assetIds, ...newAssetIds]);
|
|
151
|
+
// Mark old group ready for cleanup
|
|
152
|
+
existingGroup.assetIds = new Set();
|
|
153
|
+
existingGroup.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
|
|
154
|
+
const groupId = (0, uuid_1.v4)();
|
|
155
|
+
groups.push({
|
|
156
|
+
groupId,
|
|
157
|
+
assetIds: updatedAssetIds,
|
|
158
|
+
wsClient: null,
|
|
159
|
+
status: WebSocketSubscriptions_1.WebSocketStatus.PENDING
|
|
160
|
+
});
|
|
161
|
+
groupIdsToConnect.push(groupId);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
if (newAssetIds.length > 0) {
|
|
165
|
+
logger_1.logger.info({
|
|
166
|
+
message: `Added ${newAssetIds.length} new asset(s)`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return groupIdsToConnect;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Remove asset subscriptions from every group that contains the asset.
|
|
173
|
+
*
|
|
174
|
+
* It should be only one group that contains the asset, we search all of them
|
|
175
|
+
* regardless.
|
|
176
|
+
*
|
|
177
|
+
* Returns the list of assetIds that were removed.
|
|
178
|
+
*/
|
|
179
|
+
async removeAssets(assetIds, bookCache) {
|
|
180
|
+
const removedAssetIds = [];
|
|
181
|
+
await this.mutate(groups => {
|
|
182
|
+
groups.forEach(group => {
|
|
183
|
+
if (group.assetIds.size === 0)
|
|
184
|
+
return;
|
|
185
|
+
assetIds.forEach(id => {
|
|
186
|
+
if (group.assetIds.delete(id)) {
|
|
187
|
+
bookCache.clear(id);
|
|
188
|
+
removedAssetIds.push(id);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
if (removedAssetIds.length > 0) {
|
|
194
|
+
logger_1.logger.info({
|
|
195
|
+
message: `Removed ${removedAssetIds.length} asset(s)`
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return removedAssetIds;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Disconnect a group.
|
|
202
|
+
*/
|
|
203
|
+
disconnectGroup(group) {
|
|
204
|
+
var _a;
|
|
205
|
+
(_a = group.wsClient) === null || _a === void 0 ? void 0 : _a.close();
|
|
206
|
+
group.wsClient = null;
|
|
207
|
+
logger_1.logger.info({
|
|
208
|
+
message: 'Disconnected group',
|
|
209
|
+
groupId: group.groupId,
|
|
210
|
+
assetIds: Array.from(group.assetIds),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
;
|
|
214
|
+
/**
|
|
215
|
+
* Check status of groups and reconnect or cleanup as needed.
|
|
216
|
+
*
|
|
217
|
+
* – Empty groups are removed from the global array and returned.
|
|
218
|
+
* – Dead (but non-empty) groups are reset so that caller can reconnect them.
|
|
219
|
+
* – Pending groups are returned so that caller can connect them.
|
|
220
|
+
*
|
|
221
|
+
* Returns an array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
|
|
222
|
+
*/
|
|
223
|
+
async getGroupsToReconnectAndCleanup() {
|
|
224
|
+
const reconnectIds = [];
|
|
225
|
+
await this.mutate(groups => {
|
|
226
|
+
const groupsToRemove = new Set();
|
|
227
|
+
for (const group of groups) {
|
|
228
|
+
if (group.assetIds.size === 0) {
|
|
229
|
+
groupsToRemove.add(group.groupId);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (group.status === WebSocketSubscriptions_1.WebSocketStatus.ALIVE) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (group.status === WebSocketSubscriptions_1.WebSocketStatus.DEAD) {
|
|
236
|
+
this.disconnectGroup(group);
|
|
237
|
+
reconnectIds.push(group.groupId);
|
|
238
|
+
}
|
|
239
|
+
if (group.status === WebSocketSubscriptions_1.WebSocketStatus.CLEANUP) {
|
|
240
|
+
groupsToRemove.add(group.groupId);
|
|
241
|
+
group.assetIds = new Set();
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (group.status === WebSocketSubscriptions_1.WebSocketStatus.PENDING) {
|
|
245
|
+
reconnectIds.push(group.groupId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (groupsToRemove.size > 0) {
|
|
249
|
+
groups.forEach(group => {
|
|
250
|
+
if (groupsToRemove.has(group.groupId)) {
|
|
251
|
+
this.disconnectGroup(group);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
const remaining = groups.filter(group => !groupsToRemove.has(group.groupId));
|
|
255
|
+
groups.splice(0, groups.length, ...remaining);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return reconnectIds;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
exports.GroupRegistry = GroupRegistry;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Bottleneck from 'bottleneck';
|
|
2
|
+
import { WebSocketGroup } from '../types/WebSocketSubscriptions';
|
|
3
|
+
import { OrderBookCache } from './OrderBookCache';
|
|
4
|
+
import { WebSocketHandlers } from '../types/PolymarketWebSocket';
|
|
5
|
+
export declare class GroupSocket {
|
|
6
|
+
private group;
|
|
7
|
+
private limiter;
|
|
8
|
+
private bookCache;
|
|
9
|
+
private handlers;
|
|
10
|
+
private pingInterval?;
|
|
11
|
+
constructor(group: WebSocketGroup, limiter: Bottleneck, bookCache: OrderBookCache, handlers: WebSocketHandlers);
|
|
12
|
+
/**
|
|
13
|
+
* Establish the websocket connection using the provided Bottleneck limiter.
|
|
14
|
+
*
|
|
15
|
+
*/
|
|
16
|
+
connect(): Promise<void>;
|
|
17
|
+
private setupEventHandlers;
|
|
18
|
+
private handleBookEvents;
|
|
19
|
+
private handleTickEvents;
|
|
20
|
+
private handlePriceChangeEvents;
|
|
21
|
+
private handleLastTradeEvents;
|
|
22
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GroupSocket = void 0;
|
|
7
|
+
const ws_1 = __importDefault(require("ws"));
|
|
8
|
+
const logger_1 = require("../logger");
|
|
9
|
+
const WebSocketSubscriptions_1 = require("../types/WebSocketSubscriptions");
|
|
10
|
+
const PolymarketWebSocket_1 = require("../types/PolymarketWebSocket");
|
|
11
|
+
const lodash_1 = __importDefault(require("lodash"));
|
|
12
|
+
const ms_1 = __importDefault(require("ms"));
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
14
|
+
const CLOB_WSS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
|
|
15
|
+
class GroupSocket {
|
|
16
|
+
constructor(group, limiter, bookCache, handlers) {
|
|
17
|
+
this.group = group;
|
|
18
|
+
this.limiter = limiter;
|
|
19
|
+
this.bookCache = bookCache;
|
|
20
|
+
this.handlers = handlers;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Establish the websocket connection using the provided Bottleneck limiter.
|
|
24
|
+
*
|
|
25
|
+
*/
|
|
26
|
+
async connect() {
|
|
27
|
+
if (this.group.assetIds.size === 0) {
|
|
28
|
+
this.group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
logger_1.logger.info({
|
|
33
|
+
message: 'Connecting to CLOB WebSocket',
|
|
34
|
+
groupId: this.group.groupId,
|
|
35
|
+
assetIdsLength: this.group.assetIds.size,
|
|
36
|
+
});
|
|
37
|
+
this.group.wsClient = await this.limiter.schedule({ priority: 0 }, async () => {
|
|
38
|
+
const ws = new ws_1.default(CLOB_WSS_URL);
|
|
39
|
+
/*
|
|
40
|
+
This handler will be replaced by the handlers in setupEventHandlers
|
|
41
|
+
*/
|
|
42
|
+
ws.on('error', (err) => {
|
|
43
|
+
logger_1.logger.warn({
|
|
44
|
+
message: 'Error connecting to CLOB WebSocket',
|
|
45
|
+
error: err,
|
|
46
|
+
groupId: this.group.groupId,
|
|
47
|
+
assetIdsLength: this.group.assetIds.size,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return ws;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
this.group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
|
|
55
|
+
throw err; // caller responsible for error handler
|
|
56
|
+
}
|
|
57
|
+
this.setupEventHandlers();
|
|
58
|
+
}
|
|
59
|
+
setupEventHandlers() {
|
|
60
|
+
const group = this.group;
|
|
61
|
+
const handlers = this.handlers;
|
|
62
|
+
/*
|
|
63
|
+
Define handlers within this scope to capture 'this' context
|
|
64
|
+
*/
|
|
65
|
+
const handleOpen = async () => {
|
|
66
|
+
var _a;
|
|
67
|
+
if (group.assetIds.size === 0) {
|
|
68
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.ALIVE;
|
|
72
|
+
group.wsClient.send(JSON.stringify({ assets_ids: Array.from(group.assetIds), type: 'market' }));
|
|
73
|
+
await ((_a = handlers.onWSOpen) === null || _a === void 0 ? void 0 : _a.call(handlers, group.groupId, Array.from(group.assetIds)));
|
|
74
|
+
this.pingInterval = setInterval(() => {
|
|
75
|
+
if (group.assetIds.size === 0) {
|
|
76
|
+
clearInterval(this.pingInterval);
|
|
77
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!group.wsClient) {
|
|
81
|
+
clearInterval(this.pingInterval);
|
|
82
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
group.wsClient.ping();
|
|
86
|
+
}, (0, crypto_1.randomInt)((0, ms_1.default)('15s'), (0, ms_1.default)('25s')));
|
|
87
|
+
};
|
|
88
|
+
const handleMessage = async (data) => {
|
|
89
|
+
var _a, _b;
|
|
90
|
+
let events = [];
|
|
91
|
+
try {
|
|
92
|
+
const parsedData = JSON.parse(data.toString());
|
|
93
|
+
events = Array.isArray(parsedData) ? parsedData : [parsedData];
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
await ((_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`Not JSON: ${data.toString()}`)));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
events = lodash_1.default.filter(events, (event) => lodash_1.default.size(event.asset_id) > 0);
|
|
100
|
+
const bookEvents = [];
|
|
101
|
+
const lastTradeEvents = [];
|
|
102
|
+
const tickEvents = [];
|
|
103
|
+
const priceChangeEvents = [];
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
/*
|
|
106
|
+
Skip events for asset ids that are not in the group to ensure that
|
|
107
|
+
we don't get stale events for assets that were removed.
|
|
108
|
+
*/
|
|
109
|
+
if (!group.assetIds.has(event.asset_id)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
|
|
113
|
+
bookEvents.push(event);
|
|
114
|
+
}
|
|
115
|
+
else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
|
|
116
|
+
lastTradeEvents.push(event);
|
|
117
|
+
}
|
|
118
|
+
else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
|
|
119
|
+
tickEvents.push(event);
|
|
120
|
+
}
|
|
121
|
+
else if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
|
|
122
|
+
priceChangeEvents.push(event);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
await ((_b = handlers.onError) === null || _b === void 0 ? void 0 : _b.call(handlers, new Error(`Unknown event: ${JSON.stringify(event)}`)));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await this.handleBookEvents(bookEvents);
|
|
129
|
+
await this.handleTickEvents(tickEvents);
|
|
130
|
+
await this.handlePriceChangeEvents(priceChangeEvents);
|
|
131
|
+
await this.handleLastTradeEvents(lastTradeEvents);
|
|
132
|
+
};
|
|
133
|
+
const handlePong = () => {
|
|
134
|
+
group.groupId;
|
|
135
|
+
};
|
|
136
|
+
const handleError = (err) => {
|
|
137
|
+
var _a;
|
|
138
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
|
|
139
|
+
clearInterval(this.pingInterval);
|
|
140
|
+
(_a = handlers.onError) === null || _a === void 0 ? void 0 : _a.call(handlers, new Error(`WebSocket error for group ${group.groupId}: ${err.message}`));
|
|
141
|
+
};
|
|
142
|
+
const handleClose = async (code, reason) => {
|
|
143
|
+
var _a;
|
|
144
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
|
|
145
|
+
clearInterval(this.pingInterval);
|
|
146
|
+
await ((_a = handlers.onWSClose) === null || _a === void 0 ? void 0 : _a.call(handlers, group.groupId, code, reason.toString()));
|
|
147
|
+
};
|
|
148
|
+
if (group.wsClient) {
|
|
149
|
+
// Remove any existing handlers
|
|
150
|
+
group.wsClient.removeAllListeners();
|
|
151
|
+
// Add the handlers
|
|
152
|
+
group.wsClient.on('open', handleOpen);
|
|
153
|
+
group.wsClient.on('message', handleMessage);
|
|
154
|
+
group.wsClient.on('pong', handlePong);
|
|
155
|
+
group.wsClient.on('error', handleError);
|
|
156
|
+
group.wsClient.on('close', handleClose);
|
|
157
|
+
}
|
|
158
|
+
if (group.assetIds.size === 0) {
|
|
159
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!group.wsClient) {
|
|
163
|
+
group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async handleBookEvents(bookEvents) {
|
|
168
|
+
var _a, _b;
|
|
169
|
+
if (bookEvents.length) {
|
|
170
|
+
for (const event of bookEvents) {
|
|
171
|
+
this.bookCache.replaceBook(event);
|
|
172
|
+
}
|
|
173
|
+
await ((_b = (_a = this.handlers).onBook) === null || _b === void 0 ? void 0 : _b.call(_a, bookEvents));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async handleTickEvents(tickEvents) {
|
|
177
|
+
var _a, _b;
|
|
178
|
+
if (tickEvents.length) {
|
|
179
|
+
await ((_b = (_a = this.handlers).onTickSizeChange) === null || _b === void 0 ? void 0 : _b.call(_a, tickEvents));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async handlePriceChangeEvents(priceChangeEvents) {
|
|
183
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
184
|
+
if (priceChangeEvents.length) {
|
|
185
|
+
await ((_b = (_a = this.handlers).onPriceChange) === null || _b === void 0 ? void 0 : _b.call(_a, priceChangeEvents));
|
|
186
|
+
for (const event of priceChangeEvents) {
|
|
187
|
+
try {
|
|
188
|
+
this.bookCache.upsertPriceChange(event);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
logger_1.logger.warn({
|
|
192
|
+
message: `Skipping price_change: book not found for asset`,
|
|
193
|
+
asset_id: event.asset_id,
|
|
194
|
+
event: event,
|
|
195
|
+
error: err
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let spreadOver10Cents;
|
|
200
|
+
try {
|
|
201
|
+
spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
logger_1.logger.warn({
|
|
205
|
+
message: 'Skipping price_change: error calculating spread',
|
|
206
|
+
asset_id: event.asset_id,
|
|
207
|
+
event: event,
|
|
208
|
+
error: err
|
|
209
|
+
});
|
|
210
|
+
await ((_d = (_c = this.handlers).onError) === null || _d === void 0 ? void 0 : _d.call(_c, err));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!spreadOver10Cents) {
|
|
214
|
+
let newPrice;
|
|
215
|
+
try {
|
|
216
|
+
newPrice = this.bookCache.midpoint(event.asset_id);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
logger_1.logger.warn({
|
|
220
|
+
message: 'Skipping price_change: error calculating midpoint',
|
|
221
|
+
asset_id: event.asset_id,
|
|
222
|
+
event: event,
|
|
223
|
+
error: err
|
|
224
|
+
});
|
|
225
|
+
await ((_f = (_e = this.handlers).onError) === null || _f === void 0 ? void 0 : _f.call(_e, err));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const bookEntry = this.bookCache.getBookEntry(event.asset_id);
|
|
229
|
+
if (!bookEntry) {
|
|
230
|
+
logger_1.logger.warn({
|
|
231
|
+
message: 'Skipping price_change: book not found for asset',
|
|
232
|
+
asset_id: event.asset_id,
|
|
233
|
+
event: event,
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (newPrice !== bookEntry.price) {
|
|
238
|
+
bookEntry.price = newPrice;
|
|
239
|
+
const priceUpdateEvent = {
|
|
240
|
+
asset_id: event.asset_id,
|
|
241
|
+
event_type: 'price_update',
|
|
242
|
+
triggeringEvent: event,
|
|
243
|
+
timestamp: event.timestamp,
|
|
244
|
+
book: { bids: bookEntry.bids, asks: bookEntry.asks },
|
|
245
|
+
price: newPrice,
|
|
246
|
+
midpoint: bookEntry.midpoint || '',
|
|
247
|
+
spread: bookEntry.spread || '',
|
|
248
|
+
};
|
|
249
|
+
await ((_h = (_g = this.handlers).onPolymarketPriceUpdate) === null || _h === void 0 ? void 0 : _h.call(_g, [priceUpdateEvent]));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async handleLastTradeEvents(lastTradeEvents) {
|
|
256
|
+
var _a, _b, _c, _d, _e, _f;
|
|
257
|
+
if (lastTradeEvents.length) {
|
|
258
|
+
/*
|
|
259
|
+
Note: There is no need to edit the book here. According to the docs, a separate
|
|
260
|
+
book event is sent when a trade affects the book.
|
|
261
|
+
|
|
262
|
+
See: https://docs.polymarket.com/developers/CLOB/websocket/market-channel#book-message
|
|
263
|
+
*/
|
|
264
|
+
await ((_b = (_a = this.handlers).onLastTradePrice) === null || _b === void 0 ? void 0 : _b.call(_a, lastTradeEvents));
|
|
265
|
+
for (const event of lastTradeEvents) {
|
|
266
|
+
let spreadOver10Cents;
|
|
267
|
+
try {
|
|
268
|
+
spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
logger_1.logger.warn({
|
|
272
|
+
message: 'Skipping last_trade_price: error calculating spread',
|
|
273
|
+
asset_id: event.asset_id,
|
|
274
|
+
event: event,
|
|
275
|
+
error: err
|
|
276
|
+
});
|
|
277
|
+
await ((_d = (_c = this.handlers).onError) === null || _d === void 0 ? void 0 : _d.call(_c, err));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (spreadOver10Cents) {
|
|
281
|
+
// Ensure no trailing zeros
|
|
282
|
+
const newPrice = parseFloat(event.price).toString();
|
|
283
|
+
const bookEntry = this.bookCache.getBookEntry(event.asset_id);
|
|
284
|
+
if (!bookEntry) {
|
|
285
|
+
logger_1.logger.warn({
|
|
286
|
+
message: 'Skipping last_trade_price: book not found for asset',
|
|
287
|
+
asset_id: event.asset_id,
|
|
288
|
+
event: event,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (newPrice !== bookEntry.price) {
|
|
293
|
+
bookEntry.price = newPrice;
|
|
294
|
+
const priceUpdateEvent = {
|
|
295
|
+
asset_id: event.asset_id,
|
|
296
|
+
event_type: 'price_update',
|
|
297
|
+
triggeringEvent: event,
|
|
298
|
+
timestamp: event.timestamp,
|
|
299
|
+
book: { bids: bookEntry.bids, asks: bookEntry.asks },
|
|
300
|
+
price: newPrice,
|
|
301
|
+
midpoint: bookEntry.midpoint || '',
|
|
302
|
+
spread: bookEntry.spread || '',
|
|
303
|
+
};
|
|
304
|
+
await ((_f = (_e = this.handlers).onPolymarketPriceUpdate) === null || _f === void 0 ? void 0 : _f.call(_e, [priceUpdateEvent]));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
exports.GroupSocket = GroupSocket;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BookEvent, PriceChangeEvent, PriceLevel } from '../types/PolymarketWebSocket';
|
|
2
|
+
export interface BookEntry {
|
|
3
|
+
bids: PriceLevel[];
|
|
4
|
+
asks: PriceLevel[];
|
|
5
|
+
price: string | null;
|
|
6
|
+
midpoint: string | null;
|
|
7
|
+
spread: string | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function sortDescendingInPlace(bookSide: PriceLevel[]): void;
|
|
10
|
+
export declare class OrderBookCache {
|
|
11
|
+
private bookCache;
|
|
12
|
+
constructor();
|
|
13
|
+
/**
|
|
14
|
+
* Replace full book (after a `book` event)
|
|
15
|
+
*/
|
|
16
|
+
replaceBook(event: BookEvent): void;
|
|
17
|
+
/**
|
|
18
|
+
* Update a cached book from a `price_change` event.
|
|
19
|
+
*
|
|
20
|
+
* Returns true if the book was updated.
|
|
21
|
+
* Throws if the book is not found.
|
|
22
|
+
*/
|
|
23
|
+
upsertPriceChange(event: PriceChangeEvent): void;
|
|
24
|
+
/**
|
|
25
|
+
* Return `true` if best-bid/best-ask spread exceeds `cents`.
|
|
26
|
+
*
|
|
27
|
+
* Side effect: updates the book's spread
|
|
28
|
+
*
|
|
29
|
+
* Throws if either side of the book is empty.
|
|
30
|
+
*/
|
|
31
|
+
spreadOver(assetId: string, cents?: number): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Calculate the midpoint of the book, rounded to 3dp, no trailing zeros
|
|
34
|
+
*
|
|
35
|
+
* Side effect: updates the book's midpoint
|
|
36
|
+
*
|
|
37
|
+
* Throws if
|
|
38
|
+
* - the book is not found or missing either bid or ask
|
|
39
|
+
* - the midpoint is NaN.
|
|
40
|
+
*/
|
|
41
|
+
midpoint(assetId: string): string;
|
|
42
|
+
clear(assetId?: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Get a book entry by asset id.
|
|
45
|
+
*
|
|
46
|
+
* Return null if the book is not found.
|
|
47
|
+
*/
|
|
48
|
+
getBookEntry(assetId: string): BookEntry | null;
|
|
49
|
+
}
|