@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,8 +1,10 @@
1
1
  import { vi, describe, beforeEach, it, expect } from 'vitest';
2
- import { fetchTradesSince } from '../../client/lighter/rest';
2
+ import { fetchTradesSince, fetchPositionFundingSince, fetchAccount } from '../../client/lighter/rest';
3
3
  import { LighterAccountSyncer } from './syncer';
4
4
 
5
5
  vi.mock("../../client/lighter/rest", () => ({
6
+ fetchAccount: vi.fn(),
7
+ fetchPositionFundingSince: vi.fn(),
6
8
  fetchTradesSince: vi.fn()
7
9
  }));
8
10
  const ACCOUNT_INDEX = 87440;
@@ -78,11 +80,29 @@ const TRADES = [
78
80
  taker_position_size_before: "0.00000",
79
81
  maker_position_size_before: "0.00000",
80
82
  integrator_maker_fee: 50
83
+ },
84
+ {
85
+ // A flip is not a reduce-only fill: Lighter explicitly reports that the
86
+ // account-side position sign changed after crossing through zero.
87
+ trade_id: 90000000002,
88
+ trade_id_str: "90000000002",
89
+ type: "trade",
90
+ market_id: 1,
91
+ size: "0.00200",
92
+ price: "65000.0",
93
+ usd_amount: "130.000000",
94
+ ask_account_id: ACCOUNT_INDEX,
95
+ bid_account_id: 726722,
96
+ is_maker_ask: false,
97
+ timestamp: 1782050001e3,
98
+ taker_position_size_before: "0.00100",
99
+ taker_position_sign_changed: true,
100
+ maker_position_size_before: "0.00000"
81
101
  }
82
102
  ];
83
- function createSyncer() {
103
+ function createSyncer(demo = false, createAuthToken = vi.fn().mockResolvedValue({ token: "test-token" })) {
84
104
  const sdk = {
85
- core: { misc: { lighter: { createAuthToken: vi.fn().mockResolvedValue({ token: "test-token" }) } } }
105
+ core: { misc: { lighter: { createAuthToken } } }
86
106
  };
87
107
  const config = {
88
108
  sdk,
@@ -90,12 +110,12 @@ function createSyncer() {
90
110
  account: { exchangeIdentifier: String(ACCOUNT_INDEX) },
91
111
  credentials: { api_key_index: 0, api_private_key: "test-key" },
92
112
  ws: {},
93
- demo: false
113
+ demo
94
114
  };
95
115
  return new LighterAccountSyncer(config);
96
116
  }
97
- async function listFills() {
98
- const syncer = createSyncer();
117
+ async function listFills(demo = false) {
118
+ const syncer = createSyncer(demo);
99
119
  const params = {
100
120
  since: /* @__PURE__ */ new Date(0),
101
121
  instruments: [{ id: "1" }],
@@ -104,6 +124,26 @@ async function listFills() {
104
124
  };
105
125
  return syncer.listExternalFills(params);
106
126
  }
127
+ async function listFunding(demo = false) {
128
+ const syncer = createSyncer(demo);
129
+ const params = {
130
+ since: /* @__PURE__ */ new Date(1e3),
131
+ instruments: [{ id: "1" }]
132
+ };
133
+ return syncer.listExternalFunding(params);
134
+ }
135
+ async function listPositions() {
136
+ const syncer = createSyncer();
137
+ const params = {
138
+ instruments: [
139
+ {
140
+ id: "1",
141
+ precision: { quantity: { contractSize: 0.01 } }
142
+ }
143
+ ]
144
+ };
145
+ return syncer.listExternalPositions(params);
146
+ }
107
147
  const byId = (fills, id) => fills.find((fill) => fill.exchangeFillId === id);
108
148
  describe("LighterAccountSyncer fee mapping", () => {
109
149
  beforeEach(() => {
@@ -145,4 +185,90 @@ describe("LighterAccountSyncer fee mapping", () => {
145
185
  builderFee: "0.005"
146
186
  });
147
187
  });
188
+ it("does not classify a position flip as reduce-only", async () => {
189
+ const fills = await listFills();
190
+ expect(byId(fills, "90000000002")).toMatchObject({
191
+ side: "SELL",
192
+ reduceOnly: false
193
+ });
194
+ });
195
+ });
196
+ describe("LighterAccountSyncer funding mapping", () => {
197
+ it("fetches paginated position funding with the generated auth token", async () => {
198
+ vi.mocked(fetchPositionFundingSince).mockResolvedValue([
199
+ {
200
+ timestamp: 1e3,
201
+ market_id: 1,
202
+ funding_id: 7,
203
+ change: "-2",
204
+ discount: "0",
205
+ rate: "0.0001",
206
+ position_size: "4",
207
+ position_side: "long"
208
+ }
209
+ ]);
210
+ await expect(listFunding()).resolves.toEqual([
211
+ {
212
+ connector: "lighter",
213
+ instrumentId: "1",
214
+ fundingRate: "0.0001",
215
+ notional: "-2",
216
+ fundingPerUnit: "-0.5",
217
+ timestamp: 1e3
218
+ }
219
+ ]);
220
+ expect(fetchPositionFundingSince).toHaveBeenCalledWith(ACCOUNT_INDEX, "test-token", /* @__PURE__ */ new Date(1e3), false);
221
+ });
222
+ });
223
+ describe("LighterAccountSyncer demo authentication boundary", () => {
224
+ it("rejects authenticated demo history reads before minting a mainnet token", async () => {
225
+ vi.clearAllMocks();
226
+ await expect(listFills(true)).rejects.toThrow("backend signing is mainnet-only");
227
+ await expect(listFunding(true)).rejects.toThrow("backend signing is mainnet-only");
228
+ expect(fetchTradesSince).not.toHaveBeenCalled();
229
+ expect(fetchPositionFundingSince).not.toHaveBeenCalled();
230
+ });
231
+ });
232
+ describe("LighterAccountSyncer position mapping", () => {
233
+ it("derives mark price from position value in normalized base units", async () => {
234
+ vi.mocked(fetchAccount).mockResolvedValue({
235
+ code: 200,
236
+ accounts: [
237
+ {
238
+ account_type: 0,
239
+ collateral: "1000",
240
+ total_asset_value: "1000",
241
+ available_balance: "900",
242
+ cross_asset_value: "1000",
243
+ cross_initial_margin_requirement: "100",
244
+ cross_maintenance_margin_requirement: "50",
245
+ positions: [
246
+ {
247
+ market_id: 1,
248
+ symbol: "BTC",
249
+ initial_margin_fraction: "10",
250
+ sign: -1,
251
+ position: "100",
252
+ avg_entry_price: "50000",
253
+ position_value: "60000",
254
+ unrealized_pnl: "-10000",
255
+ liquidation_price: "70000",
256
+ margin_mode: 0,
257
+ allocated_margin: "0"
258
+ }
259
+ ]
260
+ }
261
+ ]
262
+ });
263
+ await expect(listPositions()).resolves.toEqual([
264
+ {
265
+ symbol: "1",
266
+ size: "1",
267
+ side: "SELL",
268
+ entryPrice: "50000",
269
+ markPrice: "60000"
270
+ }
271
+ ]);
272
+ expect(fetchAccount).toHaveBeenCalledWith(ACCOUNT_INDEX, false);
273
+ });
148
274
  });
@@ -1,5 +1,5 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchFillsHistorySince } from '../../client/okx/rest';
2
+ import { fetchPositions, fetchFillsHistorySince } from '../../client/okx/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
 
5
5
  class OkxAccountSyncer extends BaseAccountSyncer {
@@ -8,6 +8,27 @@ class OkxAccountSyncer extends BaseAccountSyncer {
8
8
  super(config);
9
9
  this.creds = config.credentials;
10
10
  }
11
+ async listExternalPositions({
12
+ instruments
13
+ }) {
14
+ const rawPositions = await fetchPositions(this.creds, this.demo);
15
+ const contractSizeByInstrumentId = new Map(
16
+ instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
17
+ );
18
+ return rawPositions.reduce((positions, rawPosition) => {
19
+ if (!rawPosition.pos || rawPosition.pos === "0") return positions;
20
+ const contractSize = contractSizeByInstrumentId.get(rawPosition.instId) ?? 1;
21
+ const signedSize = new BigNumber(rawPosition.pos);
22
+ positions.push({
23
+ symbol: rawPosition.instId,
24
+ size: signedSize.abs().times(contractSize).toFixed(),
25
+ side: signedSize.isGreaterThan(0) ? "BUY" : "SELL",
26
+ entryPrice: rawPosition.avgPx,
27
+ markPrice: rawPosition.avgPx
28
+ });
29
+ return positions;
30
+ }, []);
31
+ }
11
32
  async listExternalFills({
12
33
  since,
13
34
  instruments
@@ -16,28 +37,31 @@ class OkxAccountSyncer extends BaseAccountSyncer {
16
37
  const contractSizeByInstrumentId = new Map(
17
38
  instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
18
39
  );
19
- return fills.reduce((acc, fill) => {
20
- const contractSize = contractSizeByInstrumentId.get(fill.instId) ?? 1;
21
- acc.push({
40
+ return fills.reduce((result, rawFill) => {
41
+ const contractSize = contractSizeByInstrumentId.get(rawFill.instId) ?? 1;
42
+ result.push({
22
43
  connector: "okx",
23
- exchangeFillId: fill.billId || fill.tradeId,
24
- cloid: fill.clOrdId || null,
25
- symbol: fill.instId,
26
- price: fill.fillPx,
27
- quantity: this.contractsToBase(fill.fillSz, contractSize),
28
- side: fill.side === "buy" ? "BUY" : "SELL",
29
- reduceOnly: this.isReduceOnly(fill),
30
- timestamp: Number(fill.fillTime || fill.ts),
31
- tradeFee: new BigNumber(fill.fee || 0).abs().toFixed(),
44
+ exchangeFillId: rawFill.billId || rawFill.tradeId,
45
+ cloid: rawFill.clOrdId || null,
46
+ symbol: rawFill.instId,
47
+ price: rawFill.fillPx,
48
+ quantity: this.contractsToBase(rawFill.fillSz, contractSize),
49
+ side: rawFill.side === "buy" ? "BUY" : "SELL",
50
+ reduceOnly: this.isReduceOnly(rawFill),
51
+ timestamp: Number(rawFill.fillTime || rawFill.ts),
52
+ tradeFee: new BigNumber(rawFill.fee || 0).abs().toFixed(),
32
53
  builderFee: "0",
33
54
  liquidation: null
34
55
  });
35
- return acc;
56
+ return result;
36
57
  }, []);
37
58
  }
38
- isReduceOnly(fill) {
39
- if (fill.posSide === "long") return fill.side === "sell";
40
- if (fill.posSide === "short") return fill.side === "buy";
59
+ async listExternalFunding(_params) {
60
+ throw new Error("funding sync is not implemented for okx");
61
+ }
62
+ isReduceOnly(rawFill) {
63
+ if (rawFill.posSide === "long") return rawFill.side === "sell";
64
+ if (rawFill.posSide === "short") return rawFill.side === "buy";
41
65
  return false;
42
66
  }
43
67
  contractsToBase(quantity, contractSize) {
@@ -1,8 +1,9 @@
1
1
  class BaseAccountTracker {
2
2
  onError;
3
3
  demo;
4
- credentials = null;
4
+ credentials;
5
5
  balance = null;
6
+ balanceInitialized = false;
6
7
  positions = /* @__PURE__ */ new Map();
7
8
  initialized = false;
8
9
  balanceListeners = /* @__PURE__ */ new Set();
@@ -10,11 +11,17 @@ class BaseAccountTracker {
10
11
  assetEntries = /* @__PURE__ */ new Map();
11
12
  constructor(config) {
12
13
  this.demo = config.demo;
14
+ this.credentials = config.credentials;
13
15
  this.onError = (error) => {
14
16
  console.warn("[AccountTracker] error:", error.message);
15
17
  config.onError?.(error);
16
18
  };
17
19
  }
20
+ async snapshotAssets(coins) {
21
+ const unique = [...new Set(coins)];
22
+ if (unique.length === 0) return {};
23
+ return this.fetchAssetSnapshots(unique);
24
+ }
18
25
  async disconnect() {
19
26
  this.balanceListeners.clear();
20
27
  this.positionListeners.clear();
@@ -33,7 +40,7 @@ class BaseAccountTracker {
33
40
  }
34
41
  trackBalance(cb) {
35
42
  this.balanceListeners.add(cb);
36
- if (this.balance) this.notify(cb, this.balance);
43
+ if (this.balanceInitialized) this.notify(cb, this.balance);
37
44
  return () => this.balanceListeners.delete(cb);
38
45
  }
39
46
  trackPosition(cb) {
@@ -70,17 +77,27 @@ class BaseAccountTracker {
70
77
  getTrackedAsset(coin) {
71
78
  return this.assetEntries.get(coin)?.info ?? null;
72
79
  }
80
+ /** Drops cached account snapshots without broadcasting a fabricated explicit-null update. */
81
+ resetAccountState() {
82
+ this.balance = null;
83
+ this.balanceInitialized = false;
84
+ this.positions.clear();
85
+ this.initialized = false;
86
+ }
73
87
  onStateUpdate(update) {
74
- if (update.balance) this.applyBalanceUpdate(update.balance);
75
- if (update.positions) this.applyPositionsUpdate(update.positions);
88
+ const hasAccountState = update.balance !== void 0 || update.positions !== void 0;
89
+ if (update.balance !== void 0) this.applyBalanceUpdate(update.balance);
90
+ if (update.positions) this.applyPositionsUpdate(update.positions, update.positionsMode ?? "merge");
76
91
  if (update.trackedAssets) this.applyTrackedAssetsUpdate(update.trackedAssets);
77
- if (!this.initialized) this.initialized = true;
92
+ if (hasAccountState && !this.initialized) this.initialized = true;
78
93
  }
79
94
  applyBalanceUpdate(balance) {
80
95
  this.balance = balance;
96
+ this.balanceInitialized = true;
81
97
  this.broadcast(this.balanceListeners, balance);
82
98
  }
83
- applyPositionsUpdate(positions) {
99
+ applyPositionsUpdate(positions, mode) {
100
+ if (mode === "replace") this.positions.clear();
84
101
  for (const [key, pos] of positions) {
85
102
  if (pos.size === "0") this.positions.delete(key);
86
103
  else this.positions.set(key, pos);
@@ -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 };