@pear-protocol/exchanges-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +78 -12
  2. package/dist/client/hyperliquid/client.test.js +41 -0
  3. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  4. package/dist/client/hyperliquid/rest.js +26 -5
  5. package/dist/client/hyperliquid/ws.js +16 -4
  6. package/dist/client/index.js +2 -2
  7. package/dist/client/lighter/constants.js +9 -1
  8. package/dist/client/lighter/constants.test.js +22 -0
  9. package/dist/client/lighter/exchange-ws.js +2 -2
  10. package/dist/client/lighter/rest.js +101 -32
  11. package/dist/client/lighter/rest.test.js +152 -0
  12. package/dist/client/lighter/ws.js +7 -2
  13. package/dist/client/okx/rest.js +18 -9
  14. package/dist/helper/factory.js +2 -2
  15. package/dist/helper/hyperliquid/helper.js +28 -13
  16. package/dist/helper/hyperliquid/helper.test.js +45 -0
  17. package/dist/helper/lighter/helper.js +77 -16
  18. package/dist/helper/lighter/helper.test.js +289 -0
  19. package/dist/helper/lighter/types.js +9 -8
  20. package/dist/index.d.ts +182 -38
  21. package/dist/sdk.js +20 -16
  22. package/dist/sdk.test.js +101 -0
  23. package/dist/status/hyperliquid/status.js +18 -5
  24. package/dist/status/hyperliquid/status.test.js +81 -0
  25. package/dist/status/lighter/status.js +18 -11
  26. package/dist/status/lighter/status.test.js +75 -0
  27. package/dist/syncer/base.js +0 -2
  28. package/dist/syncer/base.test.js +0 -1
  29. package/dist/syncer/hyperliquid/syncer.js +96 -43
  30. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  31. package/dist/syncer/lighter/funding.js +25 -0
  32. package/dist/syncer/lighter/funding.test.js +48 -0
  33. package/dist/syncer/lighter/syncer.js +30 -6
  34. package/dist/syncer/lighter/syncer.test.js +132 -6
  35. package/dist/tracker/base.js +23 -6
  36. package/dist/tracker/base.test.js +132 -0
  37. package/dist/tracker/binance/tracker.js +48 -18
  38. package/dist/tracker/binance/tracker.test.js +138 -0
  39. package/dist/tracker/bybit/tracker.js +78 -23
  40. package/dist/tracker/bybit/tracker.test.js +145 -0
  41. package/dist/tracker/hyperliquid/mapper.js +244 -44
  42. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  43. package/dist/tracker/hyperliquid/tracker.js +241 -74
  44. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  45. package/dist/tracker/index.js +15 -37
  46. package/dist/tracker/lighter/mapper.js +163 -33
  47. package/dist/tracker/lighter/mapper.test.js +199 -0
  48. package/dist/tracker/lighter/tracker.js +219 -45
  49. package/dist/tracker/lighter/tracker.test.js +515 -0
  50. package/dist/tracker/lighter/types.js +10 -2
  51. package/dist/tracker/okx/tracker.js +50 -16
  52. package/dist/tracker/okx/tracker.test.js +126 -0
  53. package/dist/tracker/types.js +2 -1
  54. package/dist/tracker/utils.test.js +8 -0
  55. package/package.json +3 -2
@@ -0,0 +1,515 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { LighterAccountTracker } from './tracker';
3
+
4
+ const { createExchangeWsMock, fetchAccountMock, fetchAccountLimitsMock, fetchOrderBookDetailsMock } = vi.hoisted(
5
+ () => ({
6
+ createExchangeWsMock: vi.fn(),
7
+ fetchAccountMock: vi.fn(),
8
+ fetchAccountLimitsMock: vi.fn(),
9
+ fetchOrderBookDetailsMock: vi.fn()
10
+ })
11
+ );
12
+ vi.mock("../../client", () => ({ createExchangeWs: createExchangeWsMock }));
13
+ vi.mock("../../client/lighter/rest", () => ({
14
+ fetchAccount: fetchAccountMock,
15
+ fetchAccountLimits: fetchAccountLimitsMock,
16
+ fetchOrderBookDetails: fetchOrderBookDetailsMock
17
+ }));
18
+ const account = { exchangeIdentifier: "42" };
19
+ function buildTracker(sdk, demo = true) {
20
+ return new LighterAccountTracker({
21
+ demo,
22
+ account,
23
+ credentials: { api_private_key: "pk", api_key_index: 0 },
24
+ sdk
25
+ });
26
+ }
27
+ describe("LighterAccountTracker.snapshot", () => {
28
+ beforeEach(() => {
29
+ fetchAccountMock.mockReset();
30
+ fetchAccountLimitsMock.mockReset();
31
+ fetchOrderBookDetailsMock.mockReset();
32
+ });
33
+ it("reads the public GET /api/v1/account with no signer and maps balance + positions", async () => {
34
+ fetchOrderBookDetailsMock.mockResolvedValue([
35
+ {
36
+ symbol: "BTC-PERP",
37
+ market_id: 1,
38
+ status: "active",
39
+ default_initial_margin_fraction: 1e3,
40
+ maintenance_margin_fraction: 500
41
+ }
42
+ ]);
43
+ fetchAccountMock.mockResolvedValue({
44
+ code: 200,
45
+ accounts: [
46
+ {
47
+ account_type: 0,
48
+ account_trading_mode: 0,
49
+ collateral: "1000",
50
+ total_asset_value: "1100",
51
+ available_balance: "900",
52
+ cross_asset_value: "1100",
53
+ cross_initial_margin_requirement: "100",
54
+ cross_maintenance_margin_requirement: "55",
55
+ assets: [{ symbol: "USDC", balance: "900", locked_balance: "100" }],
56
+ positions: [
57
+ {
58
+ market_id: 1,
59
+ symbol: "BTC-PERP",
60
+ initial_margin_fraction: "10",
61
+ sign: 1,
62
+ position: "1",
63
+ avg_entry_price: "50000",
64
+ position_value: "1000",
65
+ unrealized_pnl: "50",
66
+ liquidation_price: "40000",
67
+ margin_mode: 0,
68
+ allocated_margin: "0"
69
+ }
70
+ ]
71
+ }
72
+ ]
73
+ });
74
+ const tracker = buildTracker();
75
+ const snapshot = await tracker.snapshot();
76
+ expect(fetchAccountMock).toHaveBeenCalledWith(42, true);
77
+ expect(snapshot.balance).toMatchObject({
78
+ accountType: "classic",
79
+ accountTier: void 0,
80
+ totalEquity: "1100",
81
+ availableToTrade: "900",
82
+ walletBalance: "1000.000000",
83
+ unrealizedPnl: "50.000000",
84
+ initialMarginUsed: "100.000000",
85
+ maintenanceMargin: "55.000000",
86
+ marginRatio: "0.0500"
87
+ });
88
+ expect(snapshot.positions).toHaveLength(1);
89
+ expect(snapshot.positions[0]).toMatchObject({
90
+ symbol: "BTC-PERP",
91
+ side: "long",
92
+ leverage: "10",
93
+ // 100 / initial_margin_fraction(10%)
94
+ marginType: "cross",
95
+ liquidationPrice: "40000"
96
+ });
97
+ });
98
+ it("enriches snapshots from authenticated accountLimits.user_tier when an SDK is available", async () => {
99
+ const createAuthToken = vi.fn().mockResolvedValue({ token: "auth-token" });
100
+ const sdk = { core: { misc: { lighter: { createAuthToken } } } };
101
+ fetchOrderBookDetailsMock.mockResolvedValue([]);
102
+ fetchAccountMock.mockResolvedValue({
103
+ code: 200,
104
+ accounts: [
105
+ {
106
+ account_type: 1,
107
+ account_trading_mode: 0,
108
+ collateral: "0",
109
+ total_asset_value: "0",
110
+ available_balance: "0",
111
+ cross_asset_value: "0",
112
+ cross_initial_margin_requirement: "0",
113
+ cross_maintenance_margin_requirement: "0",
114
+ positions: []
115
+ }
116
+ ]
117
+ });
118
+ fetchAccountLimitsMock.mockResolvedValue({ code: 200, user_tier: "premium" });
119
+ const snapshot = await buildTracker(sdk, false).snapshot();
120
+ expect(createAuthToken).toHaveBeenCalledWith({
121
+ accountIndex: 42,
122
+ apiKeyIndex: 0,
123
+ apiPrivateKey: "pk"
124
+ });
125
+ expect(fetchAccountLimitsMock).toHaveBeenCalledWith(42, "auth-token", false);
126
+ expect(snapshot.balance?.accountTier).toBe("premium");
127
+ });
128
+ it("skips unsupported demo tier auth while preserving public snapshot data", async () => {
129
+ const createAuthToken = vi.fn();
130
+ const sdk = { core: { misc: { lighter: { createAuthToken } } } };
131
+ fetchOrderBookDetailsMock.mockResolvedValue([]);
132
+ fetchAccountMock.mockResolvedValue(emptyAccountResponse());
133
+ const snapshot = await buildTracker(sdk).snapshot();
134
+ expect(createAuthToken).not.toHaveBeenCalled();
135
+ expect(fetchAccountLimitsMock).not.toHaveBeenCalled();
136
+ expect(snapshot.balance).toMatchObject({ accountTier: void 0, totalEquity: "1000" });
137
+ });
138
+ it("needs no signer / auth token for the snapshot read", async () => {
139
+ fetchAccountMock.mockResolvedValue({ code: 200, accounts: [] });
140
+ fetchOrderBookDetailsMock.mockResolvedValue([]);
141
+ const snapshot = await buildTracker().snapshot();
142
+ expect(fetchAccountMock).toHaveBeenCalledWith(42, true);
143
+ expect(fetchAccountMock).toHaveBeenCalledTimes(1);
144
+ expect(fetchAccountMock.mock.calls[0]).toHaveLength(2);
145
+ expect(fetchAccountLimitsMock).not.toHaveBeenCalled();
146
+ expect(snapshot).toEqual({ balance: null, positions: [] });
147
+ });
148
+ });
149
+ describe("LighterAccountTracker.snapshotAssets", () => {
150
+ beforeEach(() => {
151
+ fetchAccountMock.mockReset();
152
+ fetchOrderBookDetailsMock.mockReset();
153
+ });
154
+ it("answers positioned markets from the account read and the rest from market defaults, keyed by market id", async () => {
155
+ fetchAccountMock.mockResolvedValue({
156
+ code: 200,
157
+ accounts: [
158
+ {
159
+ positions: [
160
+ {
161
+ market_id: 1,
162
+ symbol: "BTC-PERP",
163
+ initial_margin_fraction: "5",
164
+ sign: 1,
165
+ position: "1",
166
+ avg_entry_price: "50000",
167
+ unrealized_pnl: "0",
168
+ liquidation_price: "40000",
169
+ margin_mode: 0
170
+ },
171
+ {
172
+ market_id: 2,
173
+ symbol: "ETH-PERP",
174
+ initial_margin_fraction: "25",
175
+ sign: 1,
176
+ position: "0",
177
+ avg_entry_price: "0",
178
+ unrealized_pnl: "0",
179
+ liquidation_price: "0",
180
+ margin_mode: 1
181
+ }
182
+ ]
183
+ }
184
+ ]
185
+ });
186
+ fetchOrderBookDetailsMock.mockResolvedValue([
187
+ { symbol: "BTC-PERP", market_id: 1, status: "active", default_initial_margin_fraction: 1e3 },
188
+ { symbol: "ETH-PERP", market_id: 2, status: "active", default_initial_margin_fraction: 2e3 }
189
+ ]);
190
+ const result = await buildTracker().snapshotAssets(["1", "2", "999"]);
191
+ expect(fetchAccountMock).toHaveBeenCalledTimes(1);
192
+ expect(fetchOrderBookDetailsMock).toHaveBeenCalledTimes(1);
193
+ expect(result["1"]).toEqual({ coin: "BTC-PERP", leverage: "20", marginType: "cross" });
194
+ expect(result["2"]).toEqual({ coin: "ETH-PERP", leverage: "4", marginType: "isolated" });
195
+ expect(result["999"]).toBeNull();
196
+ });
197
+ });
198
+ describe("LighterAccountTracker start lifecycle", () => {
199
+ beforeEach(() => {
200
+ createExchangeWsMock.mockReset();
201
+ fetchAccountMock.mockReset().mockResolvedValue(emptyAccountResponse());
202
+ fetchAccountLimitsMock.mockReset();
203
+ fetchOrderBookDetailsMock.mockReset();
204
+ });
205
+ it("shares an overlapping start and leaves one socket and poll timer running", async () => {
206
+ const markets = deferred();
207
+ const socket = mockLighterWs();
208
+ fetchOrderBookDetailsMock.mockReturnValue(markets.promise);
209
+ createExchangeWsMock.mockResolvedValue({ main: socket });
210
+ const tracker = buildTracker();
211
+ const firstStart = tracker.start();
212
+ const overlappingStart = tracker.start();
213
+ expect(overlappingStart).toBe(firstStart);
214
+ expect(fetchOrderBookDetailsMock).toHaveBeenCalledTimes(1);
215
+ markets.resolve([]);
216
+ await Promise.all([firstStart, overlappingStart]);
217
+ await tracker.start();
218
+ expect(createExchangeWsMock).toHaveBeenCalledTimes(1);
219
+ expect(socket.addMessageListener).toHaveBeenCalledTimes(1);
220
+ expect(socket.addOpenListener).toHaveBeenCalledTimes(1);
221
+ await tracker.disconnect();
222
+ expect(socket.destroy).toHaveBeenCalledTimes(1);
223
+ });
224
+ it("starts a fresh socket after disconnect without retaining the previous lifecycle", async () => {
225
+ const firstSocket = mockLighterWs();
226
+ const secondSocket = mockLighterWs();
227
+ fetchOrderBookDetailsMock.mockResolvedValue([]);
228
+ createExchangeWsMock.mockResolvedValueOnce({ main: firstSocket }).mockResolvedValueOnce({ main: secondSocket });
229
+ const tracker = buildTracker();
230
+ await tracker.start();
231
+ await tracker.disconnect();
232
+ await tracker.start();
233
+ expect(createExchangeWsMock).toHaveBeenCalledTimes(2);
234
+ expect(firstSocket.destroy).toHaveBeenCalledTimes(1);
235
+ expect(secondSocket.destroy).not.toHaveBeenCalled();
236
+ await tracker.disconnect();
237
+ expect(secondSocket.destroy).toHaveBeenCalledTimes(1);
238
+ });
239
+ });
240
+ describe("LighterAccountTracker live position snapshots", () => {
241
+ beforeEach(() => {
242
+ fetchAccountMock.mockReset();
243
+ fetchAccountLimitsMock.mockReset();
244
+ fetchOrderBookDetailsMock.mockReset();
245
+ });
246
+ it("removes a prior short when a zero tombstone resets its sign to positive", () => {
247
+ const tracker = buildTracker();
248
+ const updates = [];
249
+ tracker.trackPosition((positions) => updates.push(positions));
250
+ const internals = tracker;
251
+ internals.onAccountAll({
252
+ type: "subscribed/account_all",
253
+ assets: null,
254
+ positions: { 1: rawPosition({ sign: -1, position: "2" }) }
255
+ });
256
+ internals.onAccountAll({
257
+ type: "update/account_all",
258
+ assets: null,
259
+ positions: { 1: rawPosition({ sign: 1, position: "0" }) }
260
+ });
261
+ expect(updates).toEqual([[expect.objectContaining({ side: "short", size: "-2" })], []]);
262
+ expect(tracker.getPositions()).toEqual([]);
263
+ });
264
+ it("clears both websocket balance halves on reconnect before accepting the new account snapshot", () => {
265
+ const tracker = buildTracker();
266
+ const internals = tracker;
267
+ internals.marketsByMarketId.set(1, marketDetail());
268
+ internals.onUserStats(userStats("1000"));
269
+ internals.onAccountAll({
270
+ type: "subscribed/account_all",
271
+ assets: { 0: { symbol: "USDC", balance: "1000", locked_balance: "0" } },
272
+ positions: {
273
+ 1: rawPosition({
274
+ initial_margin_fraction: "25",
275
+ margin_mode: 1,
276
+ position: "2"
277
+ })
278
+ }
279
+ });
280
+ expect(tracker.getBalance()?.totalEquity).toBe("1000");
281
+ expect(tracker.getPositions()).toHaveLength(1);
282
+ expect(tracker.getTrackedAsset("1")).toEqual({ coin: "BTC", leverage: "4", marginType: "isolated" });
283
+ internals.handleWsOpen();
284
+ expect(tracker.getBalance()).toBeNull();
285
+ expect(tracker.getPositions()).toEqual([]);
286
+ expect(tracker.isInitialized).toBe(false);
287
+ expect(tracker.getTrackedAsset("1")).toEqual({ coin: "BTC", leverage: "10", marginType: "cross" });
288
+ internals.onAccountAll({
289
+ type: "subscribed/account_all",
290
+ assets: { 0: { symbol: "USDC", balance: "200", locked_balance: "0" } },
291
+ positions: {}
292
+ });
293
+ expect(tracker.getBalance()).toBeNull();
294
+ internals.onUserStats(userStats("200"));
295
+ expect(tracker.getBalance()).toMatchObject({
296
+ totalEquity: "200",
297
+ walletBalance: "200"
298
+ });
299
+ });
300
+ it("replaces cached positions when a REST poll returns an empty full snapshot", async () => {
301
+ const tracker = buildTracker();
302
+ const internals = tracker;
303
+ internals.onAccountAll({
304
+ type: "subscribed/account_all",
305
+ assets: null,
306
+ positions: { 1: rawPosition({ position: "2" }) }
307
+ });
308
+ fetchAccountMock.mockResolvedValue({
309
+ code: 200,
310
+ accounts: [
311
+ {
312
+ account_type: 0,
313
+ account_trading_mode: 0,
314
+ collateral: "1000",
315
+ total_asset_value: "1000",
316
+ available_balance: "1000",
317
+ cross_asset_value: "1000",
318
+ cross_initial_margin_requirement: "0",
319
+ cross_maintenance_margin_requirement: "0",
320
+ assets: [],
321
+ positions: []
322
+ }
323
+ ]
324
+ });
325
+ await internals.pollAccount();
326
+ expect(tracker.getPositions()).toEqual([]);
327
+ });
328
+ it("preserves zero-position REST settings in poll asset state without creating a position", async () => {
329
+ const tracker = buildTracker();
330
+ const internals = tracker;
331
+ fetchAccountMock.mockResolvedValue({
332
+ code: 200,
333
+ accounts: [
334
+ {
335
+ ...emptyAccountResponse().accounts[0],
336
+ positions: [
337
+ rawPosition({ market_id: 2, symbol: "ETH", position: "0", initial_margin_fraction: "25", margin_mode: 1 })
338
+ ]
339
+ }
340
+ ]
341
+ });
342
+ await internals.pollAccount();
343
+ expect(tracker.getPositions()).toEqual([]);
344
+ expect(tracker.getTrackedAsset("2")).toEqual({ coin: "ETH", leverage: "4", marginType: "isolated" });
345
+ });
346
+ it("periodically refreshes account tier inside the serialized poll and publishes the changed tier", async () => {
347
+ const createAuthToken = vi.fn().mockResolvedValue({ token: "auth-token" });
348
+ const sdk = { core: { misc: { lighter: { createAuthToken } } } };
349
+ const tracker = buildTracker(sdk, false);
350
+ const balanceUpdates = [];
351
+ tracker.trackBalance((balance) => balanceUpdates.push(balance));
352
+ fetchAccountMock.mockResolvedValue(emptyAccountResponse());
353
+ fetchAccountLimitsMock.mockResolvedValue({ code: 200, user_tier: "plus" });
354
+ const internals = tracker;
355
+ internals.accountTier = "premium";
356
+ internals.lastAccountTierFetchAt = 0;
357
+ internals.onUserStats({
358
+ type: "subscribed/user_stats",
359
+ stats: {
360
+ collateral: "1000",
361
+ portfolio_value: "1000",
362
+ available_balance: "1000",
363
+ margin_usage: "0",
364
+ account_trading_mode: 0
365
+ }
366
+ });
367
+ await internals.pollAccount();
368
+ await internals.pollAccount();
369
+ internals.onAccountAll({
370
+ type: "update/account_all",
371
+ assets: { 0: { symbol: "USDC", balance: "1000", locked_balance: "0" } },
372
+ positions: {}
373
+ });
374
+ expect(createAuthToken).toHaveBeenCalledTimes(1);
375
+ expect(fetchAccountLimitsMock).toHaveBeenCalledTimes(1);
376
+ expect(tracker.getBalance()?.accountTier).toBe("plus");
377
+ expect(balanceUpdates).toContainEqual(expect.objectContaining({ accountTier: "plus" }));
378
+ });
379
+ it("keeps tier refresh on its 60-second schedule when websocket traffic makes the REST snapshot stale", async () => {
380
+ const accountResponse = deferred();
381
+ const createAuthToken = vi.fn().mockResolvedValue({ token: "auth-token" });
382
+ const sdk = { core: { misc: { lighter: { createAuthToken } } } };
383
+ const tracker = buildTracker(sdk, false);
384
+ fetchAccountMock.mockReturnValueOnce(accountResponse.promise).mockResolvedValue(emptyAccountResponse());
385
+ fetchAccountLimitsMock.mockResolvedValue({ code: 200, user_tier: "plus" });
386
+ const internals = tracker;
387
+ internals.accountTier = "premium";
388
+ internals.lastAccountTierFetchAt = 0;
389
+ internals.onAccountAll({
390
+ type: "subscribed/account_all",
391
+ assets: { 0: { symbol: "USDC", balance: "1000", locked_balance: "0" } },
392
+ positions: {}
393
+ });
394
+ internals.onUserStats(userStats("1000"));
395
+ const stalePoll = internals.pollAccount();
396
+ internals.onUserStats(userStats("1001"));
397
+ accountResponse.resolve(emptyAccountResponse());
398
+ await stalePoll;
399
+ expect(tracker.getBalance()).toMatchObject({
400
+ accountTier: "plus",
401
+ totalEquity: "1001"
402
+ });
403
+ await internals.pollAccount();
404
+ expect(createAuthToken).toHaveBeenCalledTimes(1);
405
+ expect(fetchAccountLimitsMock).toHaveBeenCalledTimes(1);
406
+ expect(tracker.getBalance()?.accountTier).toBe("plus");
407
+ });
408
+ it("shares one in-flight REST poll and ignores its snapshot after a newer websocket update", async () => {
409
+ const accountResponse = deferred();
410
+ fetchAccountMock.mockReturnValue(accountResponse.promise);
411
+ const tracker = buildTracker();
412
+ const internals = tracker;
413
+ const firstPoll = internals.pollAccount();
414
+ const overlappingPoll = internals.pollAccount();
415
+ expect(fetchAccountMock).toHaveBeenCalledTimes(1);
416
+ internals.onAccountAll({
417
+ type: "update/account_all",
418
+ assets: null,
419
+ positions: { 1: rawPosition({ position: "3" }) }
420
+ });
421
+ accountResponse.resolve(emptyAccountResponse());
422
+ await Promise.all([firstPoll, overlappingPoll]);
423
+ expect(tracker.getPositions()).toEqual([expect.objectContaining({ symbol: "BTC", size: "3" })]);
424
+ });
425
+ it("ignores a REST poll that resolves after disconnect", async () => {
426
+ const accountResponse = deferred();
427
+ fetchAccountMock.mockReturnValue(accountResponse.promise);
428
+ const tracker = buildTracker();
429
+ const internals = tracker;
430
+ internals.onAccountAll({
431
+ type: "subscribed/account_all",
432
+ assets: null,
433
+ positions: { 1: rawPosition({ position: "2" }) }
434
+ });
435
+ const poll = internals.pollAccount();
436
+ await tracker.disconnect();
437
+ accountResponse.resolve(emptyAccountResponse());
438
+ await poll;
439
+ expect(tracker.getPositions()).toEqual([expect.objectContaining({ symbol: "BTC", size: "2" })]);
440
+ });
441
+ });
442
+ function emptyAccountResponse() {
443
+ return {
444
+ code: 200,
445
+ accounts: [
446
+ {
447
+ account_type: 0,
448
+ account_trading_mode: 0,
449
+ collateral: "1000",
450
+ total_asset_value: "1000",
451
+ available_balance: "1000",
452
+ cross_asset_value: "1000",
453
+ cross_initial_margin_requirement: "0",
454
+ cross_maintenance_margin_requirement: "0",
455
+ assets: [],
456
+ positions: []
457
+ }
458
+ ]
459
+ };
460
+ }
461
+ function deferred() {
462
+ let resolve;
463
+ const promise = new Promise((resolvePromise) => {
464
+ resolve = resolvePromise;
465
+ });
466
+ return { promise, resolve };
467
+ }
468
+ function mockLighterWs() {
469
+ return {
470
+ connected: true,
471
+ addMessageListener: vi.fn().mockReturnValue("message-listener"),
472
+ addOpenListener: vi.fn().mockReturnValue("open-listener"),
473
+ removeMessageListener: vi.fn(),
474
+ removeOpenListener: vi.fn(),
475
+ send: vi.fn(),
476
+ destroy: vi.fn()
477
+ };
478
+ }
479
+ function rawPosition(overrides) {
480
+ return {
481
+ market_id: 1,
482
+ symbol: "BTC",
483
+ initial_margin_fraction: "10",
484
+ sign: 1,
485
+ position: "1",
486
+ avg_entry_price: "1000",
487
+ position_value: "1000",
488
+ unrealized_pnl: "0",
489
+ liquidation_price: "500",
490
+ margin_mode: 0,
491
+ allocated_margin: "0",
492
+ ...overrides
493
+ };
494
+ }
495
+ function marketDetail() {
496
+ return {
497
+ market_id: 1,
498
+ symbol: "BTC",
499
+ status: "active",
500
+ default_initial_margin_fraction: 1e3,
501
+ maintenance_margin_fraction: 1e3
502
+ };
503
+ }
504
+ function userStats(portfolioValue) {
505
+ return {
506
+ type: "subscribed/user_stats",
507
+ stats: {
508
+ collateral: portfolioValue,
509
+ portfolio_value: portfolioValue,
510
+ available_balance: portfolioValue,
511
+ margin_usage: "0",
512
+ account_trading_mode: 0
513
+ }
514
+ };
515
+ }
@@ -1,6 +1,14 @@
1
1
  var LighterAccountType = /* @__PURE__ */ ((LighterAccountType2) => {
2
- LighterAccountType2["BASIC"] = "basic";
2
+ LighterAccountType2["BASIC"] = "classic";
3
+ LighterAccountType2["CLASSIC"] = "classic";
4
+ LighterAccountType2["UNIFIED"] = "unified";
3
5
  return LighterAccountType2;
4
6
  })(LighterAccountType || {});
7
+ var LighterAccountTier = /* @__PURE__ */ ((LighterAccountTier2) => {
8
+ LighterAccountTier2["STANDARD"] = "standard";
9
+ LighterAccountTier2["PREMIUM"] = "premium";
10
+ LighterAccountTier2["PLUS"] = "plus";
11
+ return LighterAccountTier2;
12
+ })(LighterAccountTier || {});
5
13
 
6
- export { LighterAccountType };
14
+ export { LighterAccountTier, LighterAccountType };
@@ -1,10 +1,11 @@
1
- import { fetchAccountConfig, fetchBalance, fetchPositions, fetchLeverageInfo } from '../../client/okx/rest';
1
+ import { createExchangeWs } from '../../client';
2
+ import { fetchLeverageInfos, fetchAccountConfig, fetchBalance, fetchPositions } from '../../client/okx/rest';
2
3
  import { BaseAccountTracker } from '../base';
3
- import { acctLvToAccountType, mergeRawCoins, buildBalance, mapPositions, mapPositionUpdates } from './mapper';
4
+ import { acctLvToAccountType, mergeRawCoins, mapPositions, buildBalance, mapPositionUpdates } from './mapper';
4
5
 
5
6
  class OkxAccountTracker extends BaseAccountTracker {
6
7
  creds;
7
- ws;
8
+ ws = null;
8
9
  messageListenerId = null;
9
10
  openListenerId = null;
10
11
  rawCoins = /* @__PURE__ */ new Map();
@@ -13,40 +14,73 @@ class OkxAccountTracker extends BaseAccountTracker {
13
14
  trackedAssetInfos = /* @__PURE__ */ new Map();
14
15
  constructor(config) {
15
16
  super(config);
16
- this.credentials = config.credentials;
17
17
  this.creds = config.credentials;
18
- this.ws = config.ws;
18
+ }
19
+ async snapshot() {
20
+ const { config, balance, positions } = await this.fetchSnapshotData();
21
+ const accountType = acctLvToAccountType(config.acctLv);
22
+ const rawCoins = mergeRawCoins(/* @__PURE__ */ new Map(), balance.details ?? []);
23
+ return {
24
+ balance: buildBalance(balance, rawCoins, accountType),
25
+ positions: Array.from(mapPositions(positions).values())
26
+ };
27
+ }
28
+ /**
29
+ * Stateless one-shot per-asset leverage read via `leverage-info`, which accepts up to 20 instIds
30
+ * per request (chunked in the client), so N assets cost ceil(N/20) calls. Cross margin only,
31
+ * matching the account-level assumption used everywhere else in this connector.
32
+ */
33
+ async fetchAssetSnapshots(instIds) {
34
+ const infos = await fetchLeverageInfos(this.creds, instIds, this.demo);
35
+ const byInstId = /* @__PURE__ */ new Map();
36
+ for (const info of infos) {
37
+ if (!byInstId.has(info.instId)) byInstId.set(info.instId, info);
38
+ }
39
+ const result = {};
40
+ for (const instId of instIds) {
41
+ const info = byInstId.get(instId);
42
+ result[instId] = info ? { coin: instId, leverage: info.lever || "0", marginType: info.mgnMode } : null;
43
+ }
44
+ return result;
19
45
  }
20
46
  async start() {
21
- const [config, balance, positions] = await Promise.all([
22
- fetchAccountConfig(this.creds, this.demo),
23
- fetchBalance(this.creds, this.demo),
24
- fetchPositions(this.creds, this.demo)
25
- ]);
47
+ const { config, balance, positions } = await this.fetchSnapshotData();
26
48
  this.accountType = acctLvToAccountType(config.acctLv);
27
49
  this.handleInitialData(balance, positions);
50
+ this.ws = await createExchangeWs("okx", { demo: this.demo, credentials: this.credentials });
28
51
  this.messageListenerId = this.ws.main.addMessageListener((raw) => this.routeRaw(raw));
29
52
  this.openListenerId = this.ws.main.addOpenListener(() => {
30
- this.ws.login().then(() => this.subscribe()).catch((err) => this.onError(err));
53
+ this.ws?.login().then(() => this.subscribe()).catch((err) => this.onError(err));
31
54
  });
32
55
  this.subscribe();
33
56
  }
34
57
  async cleanupConnections() {
35
58
  if (this.messageListenerId) {
36
- this.ws.main.removeMessageListener(this.messageListenerId);
59
+ this.ws?.main.removeMessageListener(this.messageListenerId);
37
60
  this.messageListenerId = null;
38
61
  }
39
62
  if (this.openListenerId) {
40
- this.ws.main.removeOpenListener(this.openListenerId);
63
+ this.ws?.main.removeOpenListener(this.openListenerId);
41
64
  this.openListenerId = null;
42
65
  }
66
+ this.ws?.destroy();
67
+ this.ws = null;
43
68
  }
44
69
  get isConnected() {
45
- return this.ws.isConnected;
70
+ return this.ws?.isConnected ?? false;
71
+ }
72
+ async fetchSnapshotData() {
73
+ const [config, balance, positions] = await Promise.all([
74
+ fetchAccountConfig(this.creds, this.demo),
75
+ fetchBalance(this.creds, this.demo),
76
+ fetchPositions(this.creds, this.demo)
77
+ ]);
78
+ return { config, balance, positions };
46
79
  }
47
80
  ensureAssetTracked(instId) {
48
81
  if (this.trackedAssetInfos.has(instId)) return;
49
- fetchLeverageInfo(this.creds, instId, this.demo).then((info) => {
82
+ fetchLeverageInfos(this.creds, [instId], this.demo).catch(() => []).then((infos) => {
83
+ const info = infos[0];
50
84
  const leverage = info?.lever || "0";
51
85
  const marginType = info?.mgnMode;
52
86
  const trackedAssetInfo = { coin: instId, leverage, marginType };
@@ -60,7 +94,7 @@ class OkxAccountTracker extends BaseAccountTracker {
60
94
  this.onStateUpdate({ trackedAssets: new Map(this.trackedAssetInfos) });
61
95
  }
62
96
  subscribe() {
63
- this.ws.main.send({
97
+ this.ws?.main.send({
64
98
  op: "subscribe",
65
99
  args: [{ channel: "account" }, { channel: "positions", instType: "SWAP" }]
66
100
  });