@pear-protocol/exchanges-sdk 0.3.0 → 0.5.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 (57) hide show
  1. package/README.md +78 -12
  2. package/dist/client/binance/rest.js +4 -3
  3. package/dist/client/bybit/rest.js +2 -1
  4. package/dist/client/hyperliquid/client.test.js +41 -0
  5. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  6. package/dist/client/hyperliquid/rest.js +28 -5
  7. package/dist/client/hyperliquid/ws.js +16 -4
  8. package/dist/client/index.js +2 -2
  9. package/dist/client/lighter/constants.js +9 -1
  10. package/dist/client/lighter/constants.test.js +22 -0
  11. package/dist/client/lighter/exchange-ws.js +2 -2
  12. package/dist/client/lighter/rest.js +110 -37
  13. package/dist/client/lighter/rest.test.js +152 -0
  14. package/dist/client/lighter/ws.js +7 -2
  15. package/dist/client/okx/rest.js +20 -10
  16. package/dist/helper/factory.js +2 -2
  17. package/dist/helper/hyperliquid/helper.js +28 -13
  18. package/dist/helper/hyperliquid/helper.test.js +45 -0
  19. package/dist/helper/lighter/helper.js +77 -16
  20. package/dist/helper/lighter/helper.test.js +289 -0
  21. package/dist/helper/lighter/types.js +9 -8
  22. package/dist/index.d.ts +189 -38
  23. package/dist/sdk.js +22 -16
  24. package/dist/sdk.test.js +101 -0
  25. package/dist/status/hyperliquid/status.js +18 -5
  26. package/dist/status/hyperliquid/status.test.js +81 -0
  27. package/dist/status/lighter/status.js +18 -11
  28. package/dist/status/lighter/status.test.js +75 -0
  29. package/dist/syncer/base.js +0 -2
  30. package/dist/syncer/base.test.js +0 -1
  31. package/dist/syncer/hyperliquid/syncer.js +96 -43
  32. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  33. package/dist/syncer/lighter/funding.js +25 -0
  34. package/dist/syncer/lighter/funding.test.js +48 -0
  35. package/dist/syncer/lighter/syncer.js +30 -6
  36. package/dist/syncer/lighter/syncer.test.js +132 -6
  37. package/dist/tracker/base.js +23 -6
  38. package/dist/tracker/base.test.js +132 -0
  39. package/dist/tracker/binance/tracker.js +48 -18
  40. package/dist/tracker/binance/tracker.test.js +138 -0
  41. package/dist/tracker/bybit/tracker.js +78 -23
  42. package/dist/tracker/bybit/tracker.test.js +145 -0
  43. package/dist/tracker/hyperliquid/mapper.js +244 -44
  44. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  45. package/dist/tracker/hyperliquid/tracker.js +241 -74
  46. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  47. package/dist/tracker/index.js +15 -37
  48. package/dist/tracker/lighter/mapper.js +163 -33
  49. package/dist/tracker/lighter/mapper.test.js +199 -0
  50. package/dist/tracker/lighter/tracker.js +219 -45
  51. package/dist/tracker/lighter/tracker.test.js +515 -0
  52. package/dist/tracker/lighter/types.js +10 -2
  53. package/dist/tracker/okx/tracker.js +50 -16
  54. package/dist/tracker/okx/tracker.test.js +126 -0
  55. package/dist/tracker/types.js +2 -1
  56. package/dist/tracker/utils.test.js +8 -0
  57. package/package.json +6 -5
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { BaseAccountTracker } from './base';
3
+ import { HyperliquidAccountType } from './hyperliquid/types';
4
+
5
+ class FakeAccountTracker extends BaseAccountTracker {
6
+ connected = false;
7
+ startCalls = 0;
8
+ cleanupCalls = 0;
9
+ assetSnapshotCalls = [];
10
+ async start() {
11
+ this.startCalls++;
12
+ this.connected = true;
13
+ }
14
+ async snapshot() {
15
+ return { balance: null, positions: [] };
16
+ }
17
+ async fetchAssetSnapshots(coins) {
18
+ this.assetSnapshotCalls.push(coins);
19
+ return Object.fromEntries(coins.map((coin) => [coin, { coin, leverage: "1", marginType: "cross" }]));
20
+ }
21
+ get isConnected() {
22
+ return this.connected;
23
+ }
24
+ updatePositions(positions, mode = "merge") {
25
+ this.onStateUpdate({ positions, positionsMode: mode });
26
+ }
27
+ updateBalance(balance) {
28
+ this.onStateUpdate({ balance });
29
+ }
30
+ updateTrackedAsset(asset, info) {
31
+ this.onStateUpdate({ trackedAssets: /* @__PURE__ */ new Map([[asset, info]]) });
32
+ }
33
+ ensureAssetTracked() {
34
+ }
35
+ releaseAsset() {
36
+ }
37
+ async cleanupConnections() {
38
+ this.cleanupCalls++;
39
+ this.connected = false;
40
+ }
41
+ }
42
+ function buildConfig() {
43
+ return { demo: true, credentials: { api_key: "key", api_secret: "secret", api_pass: "pass" } };
44
+ }
45
+ describe("BaseAccountTracker", () => {
46
+ it("constructs from credentials + demo with no socket side-effect", () => {
47
+ const tracker = new FakeAccountTracker(buildConfig());
48
+ expect(tracker.isConnected).toBe(false);
49
+ expect(tracker.isInitialized).toBe(false);
50
+ expect(tracker.startCalls).toBe(0);
51
+ });
52
+ it("exposes snapshot() on the AccountTracker interface", () => {
53
+ const tracker = new FakeAccountTracker(buildConfig());
54
+ expect(typeof tracker.snapshot).toBe("function");
55
+ });
56
+ it("snapshotAssets() dedupes requested coins and skips the read entirely for an empty list", async () => {
57
+ const tracker = new FakeAccountTracker(buildConfig());
58
+ await expect(tracker.snapshotAssets([])).resolves.toEqual({});
59
+ expect(tracker.assetSnapshotCalls).toEqual([]);
60
+ const result = await tracker.snapshotAssets(["BTC", "ETH", "BTC"]);
61
+ expect(tracker.assetSnapshotCalls).toEqual([["BTC", "ETH"]]);
62
+ expect(Object.keys(result)).toEqual(["BTC", "ETH"]);
63
+ });
64
+ it("start() opens a connection and disconnect() tears down whatever start() created", async () => {
65
+ const tracker = new FakeAccountTracker(buildConfig());
66
+ await tracker.start();
67
+ expect(tracker.isConnected).toBe(true);
68
+ expect(tracker.cleanupCalls).toBe(0);
69
+ await tracker.disconnect();
70
+ expect(tracker.isConnected).toBe(false);
71
+ expect(tracker.cleanupCalls).toBe(1);
72
+ });
73
+ it("distinguishes incremental position deltas from authoritative snapshots", () => {
74
+ const tracker = new FakeAccountTracker(buildConfig());
75
+ const btc = {
76
+ symbol: "BTC",
77
+ side: "long",
78
+ size: "1",
79
+ entryPrice: "100",
80
+ unrealizedPnl: "0",
81
+ leverage: "2",
82
+ marginType: "cross",
83
+ liquidationPrice: null
84
+ };
85
+ const eth = { ...btc, symbol: "ETH" };
86
+ tracker.updatePositions(/* @__PURE__ */ new Map([["BTC:long", btc]]));
87
+ tracker.updatePositions(/* @__PURE__ */ new Map([["ETH:long", eth]]));
88
+ expect(tracker.getPositions()).toEqual([btc, eth]);
89
+ tracker.updatePositions(/* @__PURE__ */ new Map([["ETH:long", eth]]), "replace");
90
+ expect(tracker.getPositions()).toEqual([eth]);
91
+ tracker.updatePositions(/* @__PURE__ */ new Map(), "replace");
92
+ expect(tracker.getPositions()).toEqual([]);
93
+ });
94
+ it("clears and broadcasts a balance that is no longer safe to represent", () => {
95
+ const tracker = new FakeAccountTracker(buildConfig());
96
+ const balance = {
97
+ accountType: HyperliquidAccountType.DEFAULT,
98
+ free: { USDC: "100" },
99
+ used: { USDC: "0" },
100
+ total: { USDC: "100" },
101
+ availableToTrade: "100",
102
+ totalEquity: "100",
103
+ walletBalance: "100",
104
+ unrealizedPnl: "0",
105
+ initialMarginUsed: "0",
106
+ maintenanceMargin: "0",
107
+ marginRatio: "0.0000",
108
+ assets: [],
109
+ timestamp: 1
110
+ };
111
+ const updates = [];
112
+ tracker.trackBalance((update) => updates.push(update));
113
+ tracker.updateBalance(balance);
114
+ tracker.updateBalance(null);
115
+ expect(tracker.getBalance()).toBeNull();
116
+ expect(updates).toEqual([balance, null]);
117
+ });
118
+ it("replays an explicitly cleared balance to listeners registered later", () => {
119
+ const tracker = new FakeAccountTracker(buildConfig());
120
+ const updates = [];
121
+ tracker.updateBalance(null);
122
+ tracker.trackBalance((update) => updates.push(update));
123
+ expect(tracker.isInitialized).toBe(true);
124
+ expect(updates).toEqual([null]);
125
+ });
126
+ it("does not mark account state initialized from asset metadata alone", () => {
127
+ const tracker = new FakeAccountTracker(buildConfig());
128
+ tracker.updateTrackedAsset("BTC", { coin: "BTC", leverage: "2", marginType: "cross" });
129
+ expect(tracker.getTrackedAsset("BTC")).toEqual({ coin: "BTC", leverage: "2", marginType: "cross" });
130
+ expect(tracker.isInitialized).toBe(false);
131
+ });
132
+ });
@@ -1,4 +1,5 @@
1
- import { fetchAccountStatus, fetchAllPositionRisk, fetchMultiAssetsMargin, keepAliveListenKey, fetchPositionRisk } from '../../client/binance/rest';
1
+ import { createExchangeWs } from '../../client';
2
+ import { fetchAllPositionRisk, keepAliveListenKey, fetchAccountStatus, fetchMultiAssetsMargin, fetchPositionRisk } from '../../client/binance/rest';
2
3
  import { BaseAccountTracker } from '../base';
3
4
  import { positionKey } from '../utils';
4
5
  import { DEFAULT_LISTEN_KEY_REFRESH_MS, DEFAULT_ACCOUNT_POLL_MS } from './const';
@@ -7,8 +8,8 @@ import { BinanceAccountType } from './types';
7
8
 
8
9
  class BinanceAccountTracker extends BaseAccountTracker {
9
10
  creds;
10
- userDataWs;
11
- wsApiWs;
11
+ userDataWs = null;
12
+ wsApiWs = null;
12
13
  listenKeyRefreshMs;
13
14
  accountPollMs;
14
15
  userDataListenerId = null;
@@ -20,22 +21,38 @@ class BinanceAccountTracker extends BaseAccountTracker {
20
21
  trackedAssetInfos = /* @__PURE__ */ new Map();
21
22
  constructor(config) {
22
23
  super(config);
23
- this.credentials = config.credentials;
24
24
  this.creds = config.credentials;
25
- this.userDataWs = config.userDataWs;
26
- this.wsApiWs = config.wsApiWs;
27
25
  this.listenKeyRefreshMs = config.listenKeyRefreshMs ?? DEFAULT_LISTEN_KEY_REFRESH_MS;
28
26
  this.accountPollMs = config.accountPollMs ?? DEFAULT_ACCOUNT_POLL_MS;
29
27
  }
28
+ async snapshot() {
29
+ const { snapshot, positionRisk, multiAssetsMargin } = await this.fetchSnapshotData();
30
+ const accountType = multiAssetsMargin ? BinanceAccountType.MULTI_ASSET : BinanceAccountType.SINGLE_ASSET;
31
+ const balance = { ...mapBalance(snapshot), accountType };
32
+ return { balance, positions: Array.from(mapPositions(positionRisk).values()) };
33
+ }
34
+ /**
35
+ * Stateless one-shot per-asset settings read: `positionRisk` with no symbol returns leverage +
36
+ * margin type for every symbol (positioned or not), so any number of assets costs one call.
37
+ */
38
+ async fetchAssetSnapshots(coins) {
39
+ const risks = await fetchAllPositionRisk(this.creds, this.demo);
40
+ const bySymbol = new Map(risks.map((risk) => [risk.symbol, risk]));
41
+ const result = {};
42
+ for (const coin of coins) {
43
+ const risk = bySymbol.get(coin);
44
+ result[coin] = risk ? toTrackedAssetInfo(coin, risk) : null;
45
+ }
46
+ return result;
47
+ }
30
48
  async start() {
31
- const [snapshot, positionRisk, multiAssetsMargin] = await Promise.all([
32
- fetchAccountStatus(this.creds, this.demo),
33
- fetchAllPositionRisk(this.creds, this.demo),
34
- fetchMultiAssetsMargin(this.creds, this.demo)
35
- ]);
49
+ const { snapshot, positionRisk, multiAssetsMargin } = await this.fetchSnapshotData();
36
50
  this.accountType = multiAssetsMargin ? BinanceAccountType.MULTI_ASSET : BinanceAccountType.SINGLE_ASSET;
37
51
  this.handleAccountStatus(snapshot);
38
52
  this.handlePositionRisk(positionRisk, false);
53
+ const ws = await createExchangeWs("binance", { demo: this.demo, credentials: this.credentials });
54
+ this.userDataWs = ws.userData;
55
+ this.wsApiWs = ws.wsApi;
39
56
  this.userDataListenerId = this.userDataWs.addMessageListener((raw) => this.routeUserData(raw));
40
57
  this.wsApiListenerId = this.wsApiWs.addMessageListener((raw) => this.routeWsApi(raw));
41
58
  this.listenKeyTimer = setInterval(() => {
@@ -52,24 +69,34 @@ class BinanceAccountTracker extends BaseAccountTracker {
52
69
  if (this.pollTimer) clearInterval(this.pollTimer);
53
70
  this.pollTimer = null;
54
71
  if (this.userDataListenerId) {
55
- this.userDataWs.removeMessageListener(this.userDataListenerId);
72
+ this.userDataWs?.removeMessageListener(this.userDataListenerId);
56
73
  this.userDataListenerId = null;
57
74
  }
58
75
  if (this.wsApiListenerId) {
59
- this.wsApiWs.removeMessageListener(this.wsApiListenerId);
76
+ this.wsApiWs?.removeMessageListener(this.wsApiListenerId);
60
77
  this.wsApiListenerId = null;
61
78
  }
79
+ this.userDataWs?.destroy();
80
+ this.userDataWs = null;
81
+ this.wsApiWs?.destroy();
82
+ this.wsApiWs = null;
62
83
  }
63
84
  get isConnected() {
64
- return this.userDataWs.connected && this.wsApiWs.connected;
85
+ return Boolean(this.userDataWs?.connected && this.wsApiWs?.connected);
86
+ }
87
+ async fetchSnapshotData() {
88
+ const [snapshot, positionRisk, multiAssetsMargin] = await Promise.all([
89
+ fetchAccountStatus(this.creds, this.demo),
90
+ fetchAllPositionRisk(this.creds, this.demo),
91
+ fetchMultiAssetsMargin(this.creds, this.demo)
92
+ ]);
93
+ return { snapshot, positionRisk, multiAssetsMargin };
65
94
  }
66
95
  ensureAssetTracked(coin) {
67
96
  if (this.trackedAssetInfos.has(coin)) return;
68
97
  fetchPositionRisk(this.creds, coin, this.demo).then((risk) => {
69
98
  if (!risk) return;
70
- const leverage = risk.leverage;
71
- const marginType = risk.marginType;
72
- const info = { coin, leverage, marginType };
99
+ const info = toTrackedAssetInfo(coin, risk);
73
100
  this.trackedAssetInfos.set(coin, info);
74
101
  const trackedAssets = /* @__PURE__ */ new Map();
75
102
  trackedAssets.set(coin, info);
@@ -83,7 +110,7 @@ class BinanceAccountTracker extends BaseAccountTracker {
83
110
  this.onStateUpdate({ trackedAssets: new Map(this.trackedAssetInfos) });
84
111
  }
85
112
  pollAccountStatus() {
86
- if (!this.wsApiWs.connected) return;
113
+ if (!this.wsApiWs?.connected) return;
87
114
  this.wsApiWs.sendSignedRequest(this.creds, "v2/account.status").catch((err) => {
88
115
  this.onError(err instanceof Error ? err : new Error(String(err)));
89
116
  });
@@ -177,5 +204,8 @@ class BinanceAccountTracker extends BaseAccountTracker {
177
204
  });
178
205
  }
179
206
  }
207
+ function toTrackedAssetInfo(coin, risk) {
208
+ return { coin, leverage: risk.leverage, marginType: risk.marginType };
209
+ }
180
210
 
181
211
  export { BinanceAccountTracker };
@@ -0,0 +1,138 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { BinanceAccountTracker } from './tracker';
3
+
4
+ const {
5
+ fetchAccountStatusMock,
6
+ fetchAllPositionRiskMock,
7
+ fetchMultiAssetsMarginMock,
8
+ fetchPositionRiskMock,
9
+ keepAliveListenKeyMock
10
+ } = vi.hoisted(() => ({
11
+ fetchAccountStatusMock: vi.fn(),
12
+ fetchAllPositionRiskMock: vi.fn(),
13
+ fetchMultiAssetsMarginMock: vi.fn(),
14
+ fetchPositionRiskMock: vi.fn(),
15
+ keepAliveListenKeyMock: vi.fn()
16
+ }));
17
+ vi.mock("../../client/binance/rest", () => ({
18
+ fetchAccountStatus: fetchAccountStatusMock,
19
+ fetchAllPositionRisk: fetchAllPositionRiskMock,
20
+ fetchMultiAssetsMargin: fetchMultiAssetsMarginMock,
21
+ fetchPositionRisk: fetchPositionRiskMock,
22
+ keepAliveListenKey: keepAliveListenKeyMock
23
+ }));
24
+ const creds = {
25
+ read: { api_key: "rk", api_secret: "rs" },
26
+ write: { api_key: "wk", api_secret: "ws" }
27
+ };
28
+ function buildTracker() {
29
+ return new BinanceAccountTracker({ demo: true, credentials: creds });
30
+ }
31
+ describe("BinanceAccountTracker.snapshot", () => {
32
+ beforeEach(() => {
33
+ fetchAccountStatusMock.mockReset();
34
+ fetchAllPositionRiskMock.mockReset();
35
+ fetchMultiAssetsMarginMock.mockReset();
36
+ });
37
+ it("splices accountType onto the omit-shaped mapBalance() result", async () => {
38
+ fetchAccountStatusMock.mockResolvedValue({
39
+ totalWalletBalance: "5000",
40
+ totalMarginBalance: "5000",
41
+ availableBalance: "4500",
42
+ totalUnrealizedProfit: "25",
43
+ totalInitialMargin: "200",
44
+ totalMaintMargin: "50",
45
+ assets: [
46
+ {
47
+ asset: "USDT",
48
+ walletBalance: "5000",
49
+ availableBalance: "4500",
50
+ unrealizedProfit: "25",
51
+ marginBalance: "5000",
52
+ initialMargin: "200",
53
+ maintMargin: "50",
54
+ marginAvailable: true
55
+ }
56
+ ]
57
+ });
58
+ fetchAllPositionRiskMock.mockResolvedValue([
59
+ {
60
+ symbol: "BTCUSDT",
61
+ leverage: "10",
62
+ marginType: "cross",
63
+ isAutoAddMargin: "false",
64
+ isolatedMargin: "0",
65
+ positionAmt: "0.5",
66
+ entryPrice: "50000",
67
+ breakEvenPrice: "50000",
68
+ unRealizedProfit: "25",
69
+ liquidationPrice: "40000",
70
+ markPrice: "51000",
71
+ maxNotionalValue: "1000000",
72
+ positionSide: "BOTH",
73
+ notional: "25500",
74
+ isolatedWallet: "0",
75
+ updateTime: 0
76
+ }
77
+ ]);
78
+ fetchMultiAssetsMarginMock.mockResolvedValue(true);
79
+ const snapshot = await buildTracker().snapshot();
80
+ expect(snapshot.balance).toMatchObject({
81
+ accountType: "MULTI_ASSET",
82
+ totalEquity: "5000",
83
+ availableToTrade: "4500",
84
+ maintenanceMargin: "50",
85
+ marginRatio: "0.0100"
86
+ });
87
+ expect(snapshot.positions).toHaveLength(1);
88
+ expect(snapshot.positions[0]).toMatchObject({
89
+ symbol: "BTCUSDT",
90
+ side: "long",
91
+ leverage: "10",
92
+ marginType: "cross",
93
+ liquidationPrice: "40000"
94
+ });
95
+ });
96
+ it("reports SINGLE_ASSET accountType when multi-assets margin is off", async () => {
97
+ fetchAccountStatusMock.mockResolvedValue({
98
+ totalWalletBalance: "0",
99
+ totalMarginBalance: "0",
100
+ availableBalance: "0",
101
+ totalUnrealizedProfit: "0",
102
+ totalInitialMargin: "0",
103
+ totalMaintMargin: "0",
104
+ assets: []
105
+ });
106
+ fetchAllPositionRiskMock.mockResolvedValue([]);
107
+ fetchMultiAssetsMarginMock.mockResolvedValue(false);
108
+ const snapshot = await buildTracker().snapshot();
109
+ expect(snapshot.balance?.accountType).toBe("SINGLE_ASSET");
110
+ expect(snapshot.positions).toEqual([]);
111
+ });
112
+ });
113
+ describe("BinanceAccountTracker.snapshotAssets", () => {
114
+ beforeEach(() => {
115
+ fetchAllPositionRiskMock.mockReset();
116
+ });
117
+ it("answers any number of symbols from one all-symbols positionRisk call", async () => {
118
+ fetchAllPositionRiskMock.mockResolvedValue([
119
+ { symbol: "BTCUSDT", leverage: "10", marginType: "cross" },
120
+ { symbol: "ETHUSDT", leverage: "5", marginType: "isolated" }
121
+ ]);
122
+ const result = await buildTracker().snapshotAssets(["BTCUSDT", "ETHUSDT", "DOGEUSDT"]);
123
+ expect(fetchAllPositionRiskMock).toHaveBeenCalledTimes(1);
124
+ expect(result).toEqual({
125
+ BTCUSDT: { coin: "BTCUSDT", leverage: "10", marginType: "cross" },
126
+ ETHUSDT: { coin: "ETHUSDT", leverage: "5", marginType: "isolated" },
127
+ DOGEUSDT: null
128
+ });
129
+ });
130
+ it("collapses hedge-mode LONG/SHORT rows into one entry per symbol", async () => {
131
+ fetchAllPositionRiskMock.mockResolvedValue([
132
+ { symbol: "BTCUSDT", leverage: "10", marginType: "cross", positionSide: "LONG" },
133
+ { symbol: "BTCUSDT", leverage: "10", marginType: "cross", positionSide: "SHORT" }
134
+ ]);
135
+ const result = await buildTracker().snapshotAssets(["BTCUSDT"]);
136
+ expect(result.BTCUSDT).toEqual({ coin: "BTCUSDT", leverage: "10", marginType: "cross" });
137
+ });
138
+ });
@@ -1,11 +1,12 @@
1
- import { fetchWalletBalance, fetchPositions, fetchAccountInfo, fetchPositionLeverage } from '../../client/bybit/rest';
1
+ import { createExchangeWs } from '../../client';
2
+ import { fetchPositions, fetchPositionLeverage, fetchWalletBalance, fetchAccountInfo } from '../../client/bybit/rest';
2
3
  import { BaseAccountTracker } from '../base';
3
4
  import { mergeRawCoins, buildBalance, mapPositions, mapPositionUpdates } from './mapper';
4
5
  import './types';
5
6
 
6
7
  class BybitAccountTracker extends BaseAccountTracker {
7
8
  creds;
8
- ws;
9
+ ws = null;
9
10
  messageListenerId = null;
10
11
  openListenerId = null;
11
12
  rawCoins = /* @__PURE__ */ new Map();
@@ -14,47 +15,94 @@ class BybitAccountTracker extends BaseAccountTracker {
14
15
  trackedAssetInfos = /* @__PURE__ */ new Map();
15
16
  constructor(config) {
16
17
  super(config);
17
- this.credentials = config.credentials;
18
18
  this.creds = config.credentials;
19
- this.ws = config.ws;
20
19
  }
21
- async start() {
22
- const [wallet, positionsUsd, positionsUsdc, accountInfo] = await Promise.all([
23
- fetchWalletBalance(this.creds, this.demo),
24
- fetchPositions(this.creds, "USDT", this.demo),
25
- fetchPositions(this.creds, "USDC", this.demo),
26
- fetchAccountInfo(this.creds, this.demo)
27
- ]);
28
- this.handleInitialData(
29
- wallet ?? null,
30
- [positionsUsd, positionsUsdc].flat(),
31
- accountInfo?.marginMode ?? null
20
+ async snapshot() {
21
+ const { wallet, positions, accountInfo } = await this.fetchSnapshotData();
22
+ const accountType = accountInfo?.marginMode ?? null;
23
+ let balance = null;
24
+ if (wallet) {
25
+ const rawCoins = mergeRawCoins(/* @__PURE__ */ new Map(), wallet.coin ?? []);
26
+ balance = buildBalance(wallet, rawCoins, accountType);
27
+ }
28
+ return { balance, positions: Array.from(mapPositions(positions).values()) };
29
+ }
30
+ /**
31
+ * Stateless one-shot per-asset settings read. Bybit has no batch leverage endpoint, but
32
+ * `position/list` by settleCoin returns every open position (with leverage) in one call, so
33
+ * only requested symbols without an open position need a per-symbol fill-in read (`?symbol=`
34
+ * returns the leverage setting even at size 0). For ≤2 symbols the per-symbol reads alone are
35
+ * cheaper than the two settleCoin reads, so the batch step is skipped. A failed fill-in maps
36
+ * to `null`; the read only throws when no underlying read succeeded.
37
+ */
38
+ async fetchAssetSnapshots(coins) {
39
+ const bySymbol = /* @__PURE__ */ new Map();
40
+ const useBatch = coins.length > 2;
41
+ if (useBatch) {
42
+ const [positionsUsd, positionsUsdc] = await Promise.all([
43
+ fetchPositions(this.creds, "USDT", this.demo),
44
+ fetchPositions(this.creds, "USDC", this.demo)
45
+ ]);
46
+ for (const pos of [positionsUsd, positionsUsdc].flat()) {
47
+ if (pos.symbol) bySymbol.set(pos.symbol, pos);
48
+ }
49
+ }
50
+ const missing = coins.filter((coin) => !bySymbol.has(coin));
51
+ const fillIns = await Promise.allSettled(
52
+ missing.map((symbol) => fetchPositionLeverage(this.creds, symbol, this.demo))
32
53
  );
54
+ missing.forEach((symbol, i) => {
55
+ const res = fillIns[i];
56
+ if (res?.status === "fulfilled" && res.value) bySymbol.set(symbol, res.value);
57
+ });
58
+ if (!useBatch && fillIns.every((res) => res.status === "rejected")) {
59
+ throw new Error("Bybit snapshotAssets: every position read failed");
60
+ }
61
+ const result = {};
62
+ for (const coin of coins) {
63
+ const pos = bySymbol.get(coin);
64
+ result[coin] = pos ? toTrackedAssetInfo(coin, pos) : null;
65
+ }
66
+ return result;
67
+ }
68
+ async start() {
69
+ const { wallet, positions, accountInfo } = await this.fetchSnapshotData();
70
+ this.handleInitialData(wallet ?? null, positions, accountInfo?.marginMode ?? null);
71
+ this.ws = await createExchangeWs("bybit", { demo: this.demo, credentials: this.credentials });
33
72
  this.messageListenerId = this.ws.main.addMessageListener((raw) => this.routeRaw(raw));
34
73
  this.openListenerId = this.ws.main.addOpenListener(() => {
35
- this.ws.authenticate().then(() => this.subscribe()).catch((err) => this.onError(err));
74
+ this.ws?.authenticate().then(() => this.subscribe()).catch((err) => this.onError(err));
36
75
  });
37
76
  this.subscribe();
38
77
  }
39
78
  async cleanupConnections() {
40
79
  if (this.messageListenerId) {
41
- this.ws.main.removeMessageListener(this.messageListenerId);
80
+ this.ws?.main.removeMessageListener(this.messageListenerId);
42
81
  this.messageListenerId = null;
43
82
  }
44
83
  if (this.openListenerId) {
45
- this.ws.main.removeOpenListener(this.openListenerId);
84
+ this.ws?.main.removeOpenListener(this.openListenerId);
46
85
  this.openListenerId = null;
47
86
  }
87
+ this.ws?.destroy();
88
+ this.ws = null;
48
89
  }
49
90
  get isConnected() {
50
- return this.ws.isConnected;
91
+ return this.ws?.isConnected ?? false;
92
+ }
93
+ async fetchSnapshotData() {
94
+ const [wallet, positionsUsd, positionsUsdc, accountInfo] = await Promise.all([
95
+ fetchWalletBalance(this.creds, this.demo),
96
+ fetchPositions(this.creds, "USDT", this.demo),
97
+ fetchPositions(this.creds, "USDC", this.demo),
98
+ fetchAccountInfo(this.creds, this.demo)
99
+ ]);
100
+ return { wallet, positions: [positionsUsd, positionsUsdc].flat(), accountInfo };
51
101
  }
52
102
  ensureAssetTracked(coin) {
53
103
  if (this.trackedAssetInfos.has(coin)) return;
54
104
  fetchPositionLeverage(this.creds, coin, this.demo).then((pos) => {
55
- const leverage = pos?.leverage || "1";
56
- const marginType = pos?.tradeMode === "1" ? "cross" : "isolated";
57
- const info = { coin, leverage, marginType };
105
+ const info = toTrackedAssetInfo(coin, pos);
58
106
  this.trackedAssetInfos.set(coin, info);
59
107
  const trackedAssets = /* @__PURE__ */ new Map();
60
108
  trackedAssets.set(coin, info);
@@ -68,7 +116,7 @@ class BybitAccountTracker extends BaseAccountTracker {
68
116
  this.onStateUpdate({ trackedAssets: new Map(this.trackedAssetInfos) });
69
117
  }
70
118
  subscribe() {
71
- this.ws.main.send({ op: "subscribe", args: ["wallet", "position"] });
119
+ this.ws?.main.send({ op: "subscribe", args: ["wallet", "position"] });
72
120
  }
73
121
  handleInitialData(wallet, positions, accountType) {
74
122
  this.accountType = accountType;
@@ -134,5 +182,12 @@ class BybitAccountTracker extends BaseAccountTracker {
134
182
  if (balance) this.onStateUpdate({ balance });
135
183
  }
136
184
  }
185
+ function toTrackedAssetInfo(coin, pos) {
186
+ return {
187
+ coin,
188
+ leverage: pos?.leverage || "1",
189
+ marginType: pos?.tradeMode === "1" ? "isolated" : "cross"
190
+ };
191
+ }
137
192
 
138
193
  export { BybitAccountTracker };