@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
@@ -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 };
@@ -0,0 +1,145 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { BybitAccountTracker } from './tracker';
3
+
4
+ const { fetchWalletBalanceMock, fetchPositionsMock, fetchAccountInfoMock, fetchPositionLeverageMock } = vi.hoisted(
5
+ () => ({
6
+ fetchWalletBalanceMock: vi.fn(),
7
+ fetchPositionsMock: vi.fn(),
8
+ fetchAccountInfoMock: vi.fn(),
9
+ fetchPositionLeverageMock: vi.fn()
10
+ })
11
+ );
12
+ vi.mock("../../client/bybit/rest", () => ({
13
+ fetchWalletBalance: fetchWalletBalanceMock,
14
+ fetchPositions: fetchPositionsMock,
15
+ fetchAccountInfo: fetchAccountInfoMock,
16
+ fetchPositionLeverage: fetchPositionLeverageMock
17
+ }));
18
+ const creds = { api_key: "k", api_secret: "s" };
19
+ function buildTracker() {
20
+ return new BybitAccountTracker({ demo: true, credentials: creds });
21
+ }
22
+ describe("BybitAccountTracker.snapshot", () => {
23
+ beforeEach(() => {
24
+ fetchWalletBalanceMock.mockReset();
25
+ fetchPositionsMock.mockReset();
26
+ fetchAccountInfoMock.mockReset();
27
+ });
28
+ it("returns mapped balance + positions across USDT and USDC settle coins with no socket opened", async () => {
29
+ fetchWalletBalanceMock.mockResolvedValue({
30
+ totalEquity: "2000",
31
+ totalWalletBalance: "2000",
32
+ totalMarginBalance: "2000",
33
+ totalAvailableBalance: "1800",
34
+ totalPerpUPL: "50",
35
+ totalInitialMargin: "100",
36
+ totalMaintenanceMargin: "20",
37
+ accountMMRate: "0.0100",
38
+ coin: [
39
+ {
40
+ coin: "USDT",
41
+ equity: "2000",
42
+ usdValue: "2000",
43
+ walletBalance: "2000",
44
+ unrealisedPnl: "50",
45
+ totalPositionIM: "100",
46
+ totalPositionMM: "20",
47
+ totalOrderIM: "0",
48
+ locked: "0",
49
+ borrowAmount: "0",
50
+ marginCollateral: true,
51
+ collateralSwitch: true
52
+ }
53
+ ]
54
+ });
55
+ fetchPositionsMock.mockImplementation(async (_creds, settleCoin) => {
56
+ if (settleCoin === "USDT") {
57
+ return [
58
+ {
59
+ symbol: "BTCUSDT",
60
+ side: "Buy",
61
+ size: "1",
62
+ avgPrice: "50000",
63
+ unrealisedPnl: "10",
64
+ leverage: "5",
65
+ tradeMode: "0",
66
+ liqPrice: "40000"
67
+ }
68
+ ];
69
+ }
70
+ return [];
71
+ });
72
+ fetchAccountInfoMock.mockResolvedValue({ marginMode: "REGULAR_MARGIN", updatedTime: "0" });
73
+ const snapshot = await buildTracker().snapshot();
74
+ expect(snapshot.balance).toMatchObject({
75
+ accountType: "REGULAR_MARGIN",
76
+ totalEquity: "2000",
77
+ availableToTrade: "1800",
78
+ maintenanceMargin: "20",
79
+ marginRatio: "0.0100"
80
+ });
81
+ expect(snapshot.positions).toHaveLength(1);
82
+ expect(snapshot.positions[0]).toMatchObject({
83
+ symbol: "BTCUSDT",
84
+ side: "long",
85
+ leverage: "5",
86
+ marginType: "cross",
87
+ liquidationPrice: "40000"
88
+ });
89
+ expect(fetchPositionsMock).toHaveBeenCalledWith(creds, "USDT", true);
90
+ expect(fetchPositionsMock).toHaveBeenCalledWith(creds, "USDC", true);
91
+ expect(fetchPositionLeverageMock).not.toHaveBeenCalled();
92
+ });
93
+ it("returns a null balance when the wallet fetch is empty", async () => {
94
+ fetchWalletBalanceMock.mockResolvedValue(void 0);
95
+ fetchPositionsMock.mockResolvedValue([]);
96
+ fetchAccountInfoMock.mockResolvedValue({ marginMode: "REGULAR_MARGIN", updatedTime: "0" });
97
+ const snapshot = await buildTracker().snapshot();
98
+ expect(snapshot.balance).toBeNull();
99
+ expect(snapshot.positions).toEqual([]);
100
+ });
101
+ });
102
+ describe("BybitAccountTracker.snapshotAssets", () => {
103
+ beforeEach(() => {
104
+ fetchPositionsMock.mockReset();
105
+ fetchPositionLeverageMock.mockReset();
106
+ });
107
+ it(">2 symbols: resolves positioned symbols from the two settleCoin reads and fills in the rest per symbol", async () => {
108
+ fetchPositionsMock.mockImplementation(
109
+ async (_creds, settleCoin) => settleCoin === "USDT" ? [
110
+ { symbol: "BTCUSDT", size: "1", leverage: "10", tradeMode: "0" },
111
+ { symbol: "ETHUSDT", size: "2", leverage: "5", tradeMode: "1" }
112
+ ] : []
113
+ );
114
+ fetchPositionLeverageMock.mockResolvedValue({ symbol: "DOGEUSDT", size: "0", leverage: "3", tradeMode: "0" });
115
+ const result = await buildTracker().snapshotAssets(["BTCUSDT", "ETHUSDT", "DOGEUSDT"]);
116
+ expect(fetchPositionsMock).toHaveBeenCalledTimes(2);
117
+ expect(fetchPositionLeverageMock).toHaveBeenCalledTimes(1);
118
+ expect(fetchPositionLeverageMock).toHaveBeenCalledWith(creds, "DOGEUSDT", true);
119
+ expect(result).toEqual({
120
+ BTCUSDT: { coin: "BTCUSDT", leverage: "10", marginType: "cross" },
121
+ ETHUSDT: { coin: "ETHUSDT", leverage: "5", marginType: "isolated" },
122
+ DOGEUSDT: { coin: "DOGEUSDT", leverage: "3", marginType: "cross" }
123
+ });
124
+ });
125
+ it("\u22642 symbols: skips the settleCoin batch and reads per symbol directly", async () => {
126
+ fetchPositionLeverageMock.mockResolvedValue({ symbol: "BTCUSDT", size: "0", leverage: "10", tradeMode: "0" });
127
+ const result = await buildTracker().snapshotAssets(["BTCUSDT"]);
128
+ expect(fetchPositionsMock).not.toHaveBeenCalled();
129
+ expect(fetchPositionLeverageMock).toHaveBeenCalledTimes(1);
130
+ expect(result.BTCUSDT).toEqual({ coin: "BTCUSDT", leverage: "10", marginType: "cross" });
131
+ });
132
+ it("maps a failed fill-in to null while the others succeed", async () => {
133
+ fetchPositionLeverageMock.mockImplementation(async (_creds, symbol) => {
134
+ if (symbol === "NOPEUSDT") throw new Error("10001");
135
+ return { symbol, size: "0", leverage: "10", tradeMode: "0" };
136
+ });
137
+ const result = await buildTracker().snapshotAssets(["BTCUSDT", "NOPEUSDT"]);
138
+ expect(result.BTCUSDT).toMatchObject({ leverage: "10", marginType: "cross" });
139
+ expect(result.NOPEUSDT).toBeNull();
140
+ });
141
+ it("throws when every per-symbol read fails", async () => {
142
+ fetchPositionLeverageMock.mockRejectedValue(new Error("down"));
143
+ await expect(buildTracker().snapshotAssets(["BTCUSDT", "ETHUSDT"])).rejects.toThrow("every position read failed");
144
+ });
145
+ });