@pear-protocol/exchanges-sdk 0.2.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 (60) 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 +239 -47
  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 +107 -25
  28. package/dist/syncer/base.test.js +131 -0
  29. package/dist/syncer/binance/syncer.js +48 -20
  30. package/dist/syncer/bybit/syncer.js +39 -15
  31. package/dist/syncer/hyperliquid/funding.js +24 -0
  32. package/dist/syncer/hyperliquid/funding.test.js +63 -0
  33. package/dist/syncer/hyperliquid/syncer.js +183 -24
  34. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  35. package/dist/syncer/lighter/funding.js +25 -0
  36. package/dist/syncer/lighter/funding.test.js +48 -0
  37. package/dist/syncer/lighter/syncer.js +93 -41
  38. package/dist/syncer/lighter/syncer.test.js +132 -6
  39. package/dist/syncer/okx/syncer.js +41 -17
  40. package/dist/tracker/base.js +23 -6
  41. package/dist/tracker/base.test.js +132 -0
  42. package/dist/tracker/binance/tracker.js +48 -18
  43. package/dist/tracker/binance/tracker.test.js +138 -0
  44. package/dist/tracker/bybit/tracker.js +78 -23
  45. package/dist/tracker/bybit/tracker.test.js +145 -0
  46. package/dist/tracker/hyperliquid/mapper.js +244 -44
  47. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  48. package/dist/tracker/hyperliquid/tracker.js +241 -74
  49. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  50. package/dist/tracker/index.js +15 -37
  51. package/dist/tracker/lighter/mapper.js +163 -33
  52. package/dist/tracker/lighter/mapper.test.js +199 -0
  53. package/dist/tracker/lighter/tracker.js +219 -45
  54. package/dist/tracker/lighter/tracker.test.js +515 -0
  55. package/dist/tracker/lighter/types.js +10 -2
  56. package/dist/tracker/okx/tracker.js +50 -16
  57. package/dist/tracker/okx/tracker.test.js +126 -0
  58. package/dist/tracker/types.js +2 -1
  59. package/dist/tracker/utils.test.js +8 -0
  60. package/package.json +6 -5
@@ -1,67 +1,161 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchOrderBookDetails, fetchAccount } from '../../client/lighter/rest';
2
+ import { createExchangeWs } from '../../client';
3
+ import { fetchOrderBookDetails, fetchAccount, fetchAccountLimits } from '../../client/lighter/rest';
3
4
  import { BaseAccountTracker } from '../base';
4
- import { leverageFromImf, mapPositions, mapBalance } from './mapper';
5
+ import { mapPositions, buildAccountBalance, mapMarketDefaultAssetInfo, mapBalance, toLighterAccountTier } from './mapper';
6
+ import { LighterAccountType } from './types';
5
7
 
8
+ const ACCOUNT_TIER_REFRESH_INTERVAL_MS = 6e4;
9
+ function toPosRecord(positions) {
10
+ const posRecord = {};
11
+ for (const p of positions) {
12
+ posRecord[String(p.market_id)] = p;
13
+ }
14
+ return posRecord;
15
+ }
6
16
  class LighterAccountTracker extends BaseAccountTracker {
7
17
  accountIdentifier;
8
- ws;
18
+ sdk;
19
+ ws = null;
9
20
  messageListenerId = null;
10
21
  openListenerId = null;
11
22
  pollTimer = null;
23
+ pollInFlight = null;
24
+ startInFlight = null;
25
+ lifecycleVersion = 0;
26
+ stateVersion = 0;
12
27
  assetsByAssetId = /* @__PURE__ */ new Map();
28
+ rawPositionsByMarketId = /* @__PURE__ */ new Map();
29
+ marketsByMarketId = /* @__PURE__ */ new Map();
13
30
  summary = null;
31
+ accountTier;
32
+ lastAccountTierFetchAt = 0;
14
33
  hasAssets = false;
15
34
  constructor(config) {
16
35
  super(config);
17
- this.credentials = config.credentials;
18
36
  this.accountIdentifier = config.account.exchangeIdentifier;
19
- this.ws = config.ws;
37
+ this.sdk = config.sdk;
38
+ }
39
+ /** Stateless one-shot account read; tier enrichment is optional and requires SDK-backed auth. */
40
+ async snapshot() {
41
+ const [account, markets, accountTier] = await Promise.all([
42
+ this.fetchPrimaryAccount(),
43
+ fetchOrderBookDetails(this.demo),
44
+ this.fetchAccountTier()
45
+ ]);
46
+ if (!account) return { balance: null, positions: [] };
47
+ const { positions } = mapPositions(toPosRecord(account.positions ?? []));
48
+ const marketsByMarketId = new Map(markets.map((market) => [market.market_id, market]));
49
+ return {
50
+ balance: buildAccountBalance(account, marketsByMarketId, accountTier),
51
+ positions: Array.from(positions.values())
52
+ };
53
+ }
54
+ /**
55
+ * Stateless one-shot per-asset settings read, keyed by market id (the same identifier the live
56
+ * trackAsset path uses). One public `GET /api/v1/account` covers every market the account holds
57
+ * a position row for (custom margin included, even when its size is zero); `orderBookDetails`
58
+ * supplies the exchange default for markets absent from the account payload.
59
+ */
60
+ async fetchAssetSnapshots(marketIds) {
61
+ const [account, markets] = await Promise.all([this.fetchPrimaryAccount(), fetchOrderBookDetails(this.demo)]);
62
+ const { trackedAssets } = mapPositions(toPosRecord(account?.positions ?? []), true);
63
+ const defaults = /* @__PURE__ */ new Map();
64
+ for (const m of markets) {
65
+ defaults.set(String(m.market_id), mapMarketDefaultAssetInfo(m));
66
+ }
67
+ const result = {};
68
+ for (const marketId of marketIds) {
69
+ result[marketId] = trackedAssets.get(marketId) ?? defaults.get(marketId) ?? null;
70
+ }
71
+ return result;
72
+ }
73
+ start() {
74
+ if (this.startInFlight) {
75
+ return this.startInFlight;
76
+ }
77
+ if (this.ws || this.pollTimer) {
78
+ return Promise.resolve();
79
+ }
80
+ const start = this.performStart().finally(() => {
81
+ if (this.startInFlight === start) {
82
+ this.startInFlight = null;
83
+ }
84
+ });
85
+ this.startInFlight = start;
86
+ return start;
20
87
  }
21
- async start() {
22
- const markets = await fetchOrderBookDetails();
88
+ /** Creates one tracker lifecycle while repeated callers share the same pending start. */
89
+ async performStart() {
90
+ const lifecycleVersion = ++this.lifecycleVersion;
91
+ const [markets, accountTier] = await Promise.all([fetchOrderBookDetails(this.demo), this.fetchAccountTier()]);
92
+ if (lifecycleVersion !== this.lifecycleVersion) return;
93
+ this.accountTier = accountTier;
94
+ this.lastAccountTierFetchAt = Date.now();
23
95
  const trackedAssets = /* @__PURE__ */ new Map();
24
96
  for (const m of markets) {
25
- trackedAssets.set(String(m.market_id), {
26
- coin: m.symbol,
27
- leverage: leverageFromImf(new BigNumber(m.default_initial_margin_fraction).div(100).toFixed()),
28
- marginType: "cross"
29
- });
97
+ this.marketsByMarketId.set(m.market_id, m);
98
+ trackedAssets.set(String(m.market_id), mapMarketDefaultAssetInfo(m));
30
99
  }
31
100
  this.onStateUpdate({ trackedAssets });
101
+ const ws = (await createExchangeWs("lighter", { demo: this.demo, credentials: this.credentials })).main;
102
+ if (lifecycleVersion !== this.lifecycleVersion) {
103
+ ws.destroy();
104
+ return;
105
+ }
106
+ this.ws = ws;
32
107
  this.messageListenerId = this.ws.addMessageListener((raw) => this.routeRaw(raw));
33
- this.openListenerId = this.ws.addOpenListener(() => this.sendStaticSubs());
34
- this.sendStaticSubs();
35
- this.pollAccount();
36
- this.pollTimer = setInterval(() => this.pollAccount(), 2e3);
108
+ this.openListenerId = this.ws.addOpenListener(() => this.handleWsOpen());
109
+ this.handleWsOpen();
110
+ void this.pollAccount();
111
+ this.pollTimer = setInterval(() => void this.pollAccount(), 2e3);
37
112
  }
38
113
  async cleanupConnections() {
114
+ this.lifecycleVersion += 1;
115
+ this.startInFlight = null;
39
116
  if (this.pollTimer) {
40
117
  clearInterval(this.pollTimer);
41
118
  this.pollTimer = null;
42
119
  }
43
120
  if (this.messageListenerId) {
44
- this.ws.removeMessageListener(this.messageListenerId);
121
+ this.ws?.removeMessageListener(this.messageListenerId);
45
122
  this.messageListenerId = null;
46
123
  }
47
124
  if (this.openListenerId) {
48
- this.ws.removeOpenListener(this.openListenerId);
125
+ this.ws?.removeOpenListener(this.openListenerId);
49
126
  this.openListenerId = null;
50
127
  }
128
+ this.ws?.destroy();
129
+ this.ws = null;
51
130
  }
52
131
  get isConnected() {
53
- return this.ws.connected;
132
+ return this.ws?.connected ?? false;
54
133
  }
55
134
  ensureAssetTracked(_coin) {
56
135
  }
57
136
  releaseAsset(_coin) {
58
137
  }
138
+ /** Starts a clean account-data epoch before subscribing on each websocket connection. */
139
+ handleWsOpen() {
140
+ this.stateVersion += 1;
141
+ this.assetsByAssetId.clear();
142
+ this.rawPositionsByMarketId.clear();
143
+ this.summary = null;
144
+ this.hasAssets = false;
145
+ this.resetAccountState();
146
+ const trackedAssets = /* @__PURE__ */ new Map();
147
+ for (const market of this.marketsByMarketId.values()) {
148
+ trackedAssets.set(String(market.market_id), mapMarketDefaultAssetInfo(market));
149
+ }
150
+ this.onStateUpdate({ trackedAssets });
151
+ this.sendStaticSubs();
152
+ }
59
153
  sendStaticSubs() {
60
- this.ws.send({
154
+ this.ws?.send({
61
155
  type: "subscribe",
62
156
  channel: `account_all/${this.accountIdentifier}`
63
157
  });
64
- this.ws.send({
158
+ this.ws?.send({
65
159
  type: "subscribe",
66
160
  channel: `user_stats/${this.accountIdentifier}`
67
161
  });
@@ -87,16 +181,37 @@ class LighterAccountTracker extends BaseAccountTracker {
87
181
  }
88
182
  }
89
183
  onAccountAll(data) {
184
+ this.stateVersion += 1;
90
185
  const update = {};
186
+ const isSnapshot = data.type === "subscribed/account_all";
91
187
  if (data.assets) {
188
+ if (isSnapshot) {
189
+ this.assetsByAssetId.clear();
190
+ }
92
191
  for (const [id, asset] of Object.entries(data.assets)) {
93
192
  this.assetsByAssetId.set(id, asset);
94
193
  }
95
194
  this.hasAssets = true;
96
195
  }
97
- if (data.positions && Object.keys(data.positions).length > 0) {
98
- const { positions, trackedAssets } = mapPositions(data.positions);
196
+ if (data.positions) {
197
+ const positionsForMapping = { ...data.positions };
198
+ if (isSnapshot) {
199
+ this.rawPositionsByMarketId.clear();
200
+ }
201
+ for (const [marketId, position] of Object.entries(data.positions)) {
202
+ if (new BigNumber(position.position).isZero()) {
203
+ const previousPosition = this.rawPositionsByMarketId.get(marketId);
204
+ if (previousPosition) {
205
+ positionsForMapping[marketId] = { ...position, sign: previousPosition.sign };
206
+ }
207
+ this.rawPositionsByMarketId.delete(marketId);
208
+ } else {
209
+ this.rawPositionsByMarketId.set(marketId, position);
210
+ }
211
+ }
212
+ const { positions, trackedAssets } = mapPositions(positionsForMapping, !isSnapshot);
99
213
  update.positions = positions;
214
+ update.positionsMode = isSnapshot ? "replace" : "merge";
100
215
  update.trackedAssets = trackedAssets;
101
216
  }
102
217
  const balance = this.buildBalance();
@@ -104,46 +219,105 @@ class LighterAccountTracker extends BaseAccountTracker {
104
219
  if (Object.keys(update).length > 0) this.onStateUpdate(update);
105
220
  }
106
221
  onUserStats(data) {
222
+ this.stateVersion += 1;
107
223
  const s = data.stats;
108
224
  this.summary = {
109
225
  collateral: s.collateral || "0",
110
226
  portfolioValue: s.portfolio_value || "0",
227
+ crossPortfolioValue: s.cross_stats?.portfolio_value,
111
228
  availableToTrade: s.available_balance || "0",
112
- marginUsagePct: s.margin_usage || "0"
229
+ marginUsagePct: s.margin_usage || "0",
230
+ accountType: s.account_trading_mode === 1 ? LighterAccountType.UNIFIED : LighterAccountType.CLASSIC,
231
+ accountTier: this.accountTier
113
232
  };
114
233
  const balance = this.buildBalance();
115
234
  if (balance) this.onStateUpdate({ balance });
116
235
  }
117
236
  buildBalance() {
118
237
  if (!this.summary || !this.hasAssets) return null;
119
- return mapBalance(this.summary, this.assetsByAssetId);
238
+ return mapBalance(
239
+ { ...this.summary, accountTier: this.accountTier },
240
+ this.assetsByAssetId,
241
+ this.rawPositionsByMarketId.values(),
242
+ this.marketsByMarketId
243
+ );
244
+ }
245
+ async fetchPrimaryAccount() {
246
+ const data = await fetchAccount(Number(this.accountIdentifier), this.demo);
247
+ return data.accounts[0];
120
248
  }
121
- async pollAccount() {
249
+ async fetchAccountTier() {
250
+ if (!this.sdk || this.demo) return void 0;
122
251
  try {
252
+ const credentials = this.credentials;
123
253
  const accountIndex = Number(this.accountIdentifier);
124
- const data = await fetchAccount(accountIndex);
125
- const account = data.accounts[0];
126
- if (!account?.positions?.length) return;
127
- const posRecord = {};
128
- for (const p of account.positions) {
129
- posRecord[String(p.market_id)] = {
130
- market_id: p.market_id,
131
- symbol: p.symbol,
132
- initial_margin_fraction: p.initial_margin_fraction,
133
- sign: p.sign,
134
- position: p.position,
135
- avg_entry_price: p.avg_entry_price,
136
- unrealized_pnl: p.unrealized_pnl,
137
- liquidation_price: p.liquidation_price,
138
- margin_mode: p.margin_mode
139
- };
140
- }
141
- const { positions, trackedAssets } = mapPositions(posRecord);
142
- this.onStateUpdate({ positions, trackedAssets });
254
+ const { token } = await this.sdk.core.misc.lighter.createAuthToken({
255
+ accountIndex,
256
+ apiKeyIndex: credentials.api_key_index,
257
+ apiPrivateKey: credentials.api_private_key
258
+ });
259
+ const limits = await fetchAccountLimits(accountIndex, token, this.demo);
260
+ return toLighterAccountTier(limits.user_tier);
143
261
  } catch (err) {
144
262
  this.onError(err instanceof Error ? err : new Error(String(err)));
263
+ return void 0;
264
+ }
265
+ }
266
+ pollAccount() {
267
+ if (this.pollInFlight) return this.pollInFlight;
268
+ const lifecycleVersion = this.lifecycleVersion;
269
+ const stateVersion = this.stateVersion;
270
+ const poll = this.performAccountPoll(lifecycleVersion, stateVersion).finally(() => {
271
+ if (this.pollInFlight === poll) {
272
+ this.pollInFlight = null;
273
+ }
274
+ });
275
+ this.pollInFlight = poll;
276
+ return poll;
277
+ }
278
+ async performAccountPoll(lifecycleVersion, stateVersion) {
279
+ try {
280
+ const refreshAccountTier = this.shouldRefreshAccountTier();
281
+ if (refreshAccountTier) {
282
+ this.lastAccountTierFetchAt = Date.now();
283
+ }
284
+ const [account, refreshedAccountTier] = await Promise.all([
285
+ this.fetchPrimaryAccount(),
286
+ refreshAccountTier ? this.fetchAccountTier() : Promise.resolve(void 0)
287
+ ]);
288
+ if (lifecycleVersion !== this.lifecycleVersion) return;
289
+ if (refreshAccountTier && refreshedAccountTier !== void 0 && refreshedAccountTier !== this.accountTier) {
290
+ this.accountTier = refreshedAccountTier;
291
+ const balance = this.buildBalance();
292
+ if (balance) {
293
+ this.onStateUpdate({ balance });
294
+ }
295
+ }
296
+ if (!account || stateVersion !== this.stateVersion) return;
297
+ const positionRecord = toPosRecord(account.positions ?? []);
298
+ const { positions, trackedAssets } = mapPositions(positionRecord, true);
299
+ this.stateVersion += 1;
300
+ this.rawPositionsByMarketId.clear();
301
+ for (const [marketId, position] of Object.entries(positionRecord)) {
302
+ if (!new BigNumber(position.position).isZero()) {
303
+ this.rawPositionsByMarketId.set(marketId, position);
304
+ }
305
+ }
306
+ this.onStateUpdate({
307
+ balance: buildAccountBalance(account, this.marketsByMarketId, this.accountTier),
308
+ positions,
309
+ positionsMode: "replace",
310
+ trackedAssets
311
+ });
312
+ } catch (err) {
313
+ if (lifecycleVersion === this.lifecycleVersion) {
314
+ this.onError(err instanceof Error ? err : new Error(String(err)));
315
+ }
145
316
  }
146
317
  }
318
+ shouldRefreshAccountTier() {
319
+ return this.sdk !== void 0 && Date.now() - this.lastAccountTierFetchAt >= ACCOUNT_TIER_REFRESH_INTERVAL_MS;
320
+ }
147
321
  }
148
322
 
149
323
  export { LighterAccountTracker };