@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.
- package/README.md +78 -12
- package/dist/client/hyperliquid/client.test.js +41 -0
- package/dist/client/hyperliquid/exchange-ws.js +3 -2
- package/dist/client/hyperliquid/rest.js +26 -5
- package/dist/client/hyperliquid/ws.js +16 -4
- package/dist/client/index.js +2 -2
- package/dist/client/lighter/constants.js +9 -1
- package/dist/client/lighter/constants.test.js +22 -0
- package/dist/client/lighter/exchange-ws.js +2 -2
- package/dist/client/lighter/rest.js +101 -32
- package/dist/client/lighter/rest.test.js +152 -0
- package/dist/client/lighter/ws.js +7 -2
- package/dist/client/okx/rest.js +18 -9
- package/dist/helper/factory.js +2 -2
- package/dist/helper/hyperliquid/helper.js +28 -13
- package/dist/helper/hyperliquid/helper.test.js +45 -0
- package/dist/helper/lighter/helper.js +77 -16
- package/dist/helper/lighter/helper.test.js +289 -0
- package/dist/helper/lighter/types.js +9 -8
- package/dist/index.d.ts +239 -47
- package/dist/sdk.js +20 -16
- package/dist/sdk.test.js +101 -0
- package/dist/status/hyperliquid/status.js +18 -5
- package/dist/status/hyperliquid/status.test.js +81 -0
- package/dist/status/lighter/status.js +18 -11
- package/dist/status/lighter/status.test.js +75 -0
- package/dist/syncer/base.js +107 -25
- package/dist/syncer/base.test.js +131 -0
- package/dist/syncer/binance/syncer.js +48 -20
- package/dist/syncer/bybit/syncer.js +39 -15
- package/dist/syncer/hyperliquid/funding.js +24 -0
- package/dist/syncer/hyperliquid/funding.test.js +63 -0
- package/dist/syncer/hyperliquid/syncer.js +183 -24
- package/dist/syncer/hyperliquid/syncer.test.js +274 -0
- package/dist/syncer/lighter/funding.js +25 -0
- package/dist/syncer/lighter/funding.test.js +48 -0
- package/dist/syncer/lighter/syncer.js +93 -41
- package/dist/syncer/lighter/syncer.test.js +132 -6
- package/dist/syncer/okx/syncer.js +41 -17
- package/dist/tracker/base.js +23 -6
- package/dist/tracker/base.test.js +132 -0
- package/dist/tracker/binance/tracker.js +48 -18
- package/dist/tracker/binance/tracker.test.js +138 -0
- package/dist/tracker/bybit/tracker.js +78 -23
- package/dist/tracker/bybit/tracker.test.js +145 -0
- package/dist/tracker/hyperliquid/mapper.js +244 -44
- package/dist/tracker/hyperliquid/mapper.test.js +255 -0
- package/dist/tracker/hyperliquid/tracker.js +241 -74
- package/dist/tracker/hyperliquid/tracker.test.js +587 -0
- package/dist/tracker/index.js +15 -37
- package/dist/tracker/lighter/mapper.js +163 -33
- package/dist/tracker/lighter/mapper.test.js +199 -0
- package/dist/tracker/lighter/tracker.js +219 -45
- package/dist/tracker/lighter/tracker.test.js +515 -0
- package/dist/tracker/lighter/types.js +10 -2
- package/dist/tracker/okx/tracker.js +50 -16
- package/dist/tracker/okx/tracker.test.js +126 -0
- package/dist/tracker/types.js +2 -1
- package/dist/tracker/utils.test.js +8 -0
- 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
|
|
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
|
|
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((
|
|
20
|
-
const contractSize = contractSizeByInstrumentId.get(
|
|
21
|
-
|
|
40
|
+
return fills.reduce((result, rawFill) => {
|
|
41
|
+
const contractSize = contractSizeByInstrumentId.get(rawFill.instId) ?? 1;
|
|
42
|
+
result.push({
|
|
22
43
|
connector: "okx",
|
|
23
|
-
exchangeFillId:
|
|
24
|
-
cloid:
|
|
25
|
-
symbol:
|
|
26
|
-
price:
|
|
27
|
-
quantity: this.contractsToBase(
|
|
28
|
-
side:
|
|
29
|
-
reduceOnly: this.isReduceOnly(
|
|
30
|
-
timestamp: Number(
|
|
31
|
-
tradeFee: new BigNumber(
|
|
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
|
|
56
|
+
return result;
|
|
36
57
|
}, []);
|
|
37
58
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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) {
|
package/dist/tracker/base.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
class BaseAccountTracker {
|
|
2
2
|
onError;
|
|
3
3
|
demo;
|
|
4
|
-
credentials
|
|
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.
|
|
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
|
-
|
|
75
|
-
if (update.
|
|
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 {
|
|
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
|
|
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
|
|
72
|
+
this.userDataWs?.removeMessageListener(this.userDataListenerId);
|
|
56
73
|
this.userDataListenerId = null;
|
|
57
74
|
}
|
|
58
75
|
if (this.wsApiListenerId) {
|
|
59
|
-
this.wsApiWs
|
|
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
|
|
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
|
|
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
|
|
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 };
|