@pear-protocol/exchanges-sdk 0.3.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 +182 -38
- 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 +0 -2
- package/dist/syncer/base.test.js +0 -1
- package/dist/syncer/hyperliquid/syncer.js +96 -43
- 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 +30 -6
- package/dist/syncer/lighter/syncer.test.js +132 -6
- 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 +3 -2
|
@@ -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 };
|
|
@@ -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 {
|
|
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
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
80
|
+
this.ws?.main.removeMessageListener(this.messageListenerId);
|
|
42
81
|
this.messageListenerId = null;
|
|
43
82
|
}
|
|
44
83
|
if (this.openListenerId) {
|
|
45
|
-
this.ws
|
|
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
|
|
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
|
|
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
|
|
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 };
|