@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, afterEach, vi, it, expect } from 'vitest';
|
|
2
|
+
import { BaseAccountSyncer } from './base';
|
|
3
|
+
|
|
4
|
+
function extFill(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
exchangeFillId: "tx-default",
|
|
7
|
+
cloid: null,
|
|
8
|
+
connector: "hyperliquid",
|
|
9
|
+
symbol: "BTC",
|
|
10
|
+
price: "100",
|
|
11
|
+
quantity: "1",
|
|
12
|
+
side: "BUY",
|
|
13
|
+
reduceOnly: false,
|
|
14
|
+
timestamp: 1e3,
|
|
15
|
+
tradeFee: "0",
|
|
16
|
+
builderFee: "0",
|
|
17
|
+
liquidation: null,
|
|
18
|
+
...overrides
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function exchangePos(overrides = {}) {
|
|
22
|
+
return { symbol: "BTC", size: "1", side: "BUY", entryPrice: "100", markPrice: "100", ...overrides };
|
|
23
|
+
}
|
|
24
|
+
class FakeSyncer extends BaseAccountSyncer {
|
|
25
|
+
positionsFn = vi.fn();
|
|
26
|
+
fillsFn = vi.fn();
|
|
27
|
+
listExternalPositions(params) {
|
|
28
|
+
return this.positionsFn(params);
|
|
29
|
+
}
|
|
30
|
+
listExternalFills(params) {
|
|
31
|
+
return this.fillsFn(params);
|
|
32
|
+
}
|
|
33
|
+
listExternalFunding() {
|
|
34
|
+
return Promise.resolve([]);
|
|
35
|
+
}
|
|
36
|
+
isReduceOnly() {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function makeSyncer(args) {
|
|
41
|
+
const fills = {
|
|
42
|
+
since: vi.fn().mockResolvedValue({ sinceMs: 0, neverSynced: args.neverSynced, confirmedExchangeFillIds: [] }),
|
|
43
|
+
reconcile: vi.fn().mockResolvedValue({}),
|
|
44
|
+
confirm: vi.fn().mockResolvedValue({}),
|
|
45
|
+
create: vi.fn().mockResolvedValue({}),
|
|
46
|
+
createBulk: vi.fn().mockResolvedValue({})
|
|
47
|
+
};
|
|
48
|
+
const sync = { fills };
|
|
49
|
+
const sdk = {
|
|
50
|
+
core: {
|
|
51
|
+
positions: { openLite: vi.fn().mockResolvedValue({ positions: [] }) },
|
|
52
|
+
fills: { provisionals: vi.fn().mockResolvedValue({ fills: [] }), details: vi.fn() },
|
|
53
|
+
instruments: { list: vi.fn().mockResolvedValue({ instruments: [] }) },
|
|
54
|
+
sync
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const config = {
|
|
58
|
+
sdk,
|
|
59
|
+
connector: "hyperliquid",
|
|
60
|
+
account: { id: "acct-1" },
|
|
61
|
+
credentials: {},
|
|
62
|
+
demo: false
|
|
63
|
+
};
|
|
64
|
+
return { syncer: new FakeSyncer(config), sync };
|
|
65
|
+
}
|
|
66
|
+
const NEWEST_TS = 105e4;
|
|
67
|
+
function trimmableFills(btcEraTs) {
|
|
68
|
+
return [
|
|
69
|
+
extFill({ exchangeFillId: "a", symbol: "BTC", side: "BUY", quantity: "1", timestamp: btcEraTs.open }),
|
|
70
|
+
extFill({ exchangeFillId: "b", symbol: "BTC", side: "SELL", quantity: "1", timestamp: btcEraTs.close }),
|
|
71
|
+
extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
const SOL_SNAPSHOT = [exchangePos({ symbol: "SOL", size: "2" })];
|
|
75
|
+
describe("BaseAccountSyncer \u2014 first-sync plumbing", () => {
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
it("neverSynced=true: the posted snapshot is fetched AFTER the fill stream", async () => {
|
|
80
|
+
const { syncer, sync } = makeSyncer({ neverSynced: true });
|
|
81
|
+
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
82
|
+
syncer.fillsFn.mockResolvedValue([
|
|
83
|
+
extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
|
|
84
|
+
]);
|
|
85
|
+
await syncer.synchronizeFills();
|
|
86
|
+
expect(syncer.positionsFn).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(syncer.fillsFn.mock.invocationCallOrder[0]).toBeLessThan(
|
|
88
|
+
syncer.positionsFn.mock.invocationCallOrder[0] ?? 0
|
|
89
|
+
);
|
|
90
|
+
expect(sync.fills.create).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.exchangePositions).toBe(SOL_SNAPSHOT);
|
|
92
|
+
});
|
|
93
|
+
it("bumps the posted watermark past the overlap window when trimmed fills fall inside it", async () => {
|
|
94
|
+
const { syncer, sync } = makeSyncer({ neverSynced: true });
|
|
95
|
+
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
96
|
+
syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e6, close: NEWEST_TS - 2e4 }));
|
|
97
|
+
await syncer.synchronizeFills();
|
|
98
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS + 6e4 + 1);
|
|
99
|
+
});
|
|
100
|
+
it("leaves the watermark unbumped when the trimmed era is entirely older than the overlap window", async () => {
|
|
101
|
+
const { syncer, sync } = makeSyncer({ neverSynced: true });
|
|
102
|
+
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
103
|
+
syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e5, close: 2e5 }));
|
|
104
|
+
await syncer.synchronizeFills();
|
|
105
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS);
|
|
106
|
+
});
|
|
107
|
+
it("logs the trim exactly once when a trim happened", async () => {
|
|
108
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
|
|
109
|
+
});
|
|
110
|
+
const { syncer } = makeSyncer({ neverSynced: true });
|
|
111
|
+
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
112
|
+
syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e5, close: 2e5 }));
|
|
113
|
+
await syncer.synchronizeFills();
|
|
114
|
+
const trimLogs = logSpy.mock.calls.filter((call) => String(call[0]).includes("first-sync trim"));
|
|
115
|
+
expect(trimLogs).toHaveLength(1);
|
|
116
|
+
expect(String(trimLogs[0]?.[0])).toContain("hyperliquid");
|
|
117
|
+
});
|
|
118
|
+
it("neverSynced=false: positions fetched once, no bump, no trim log", async () => {
|
|
119
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
|
|
120
|
+
});
|
|
121
|
+
const { syncer, sync } = makeSyncer({ neverSynced: false });
|
|
122
|
+
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
123
|
+
syncer.fillsFn.mockResolvedValue([
|
|
124
|
+
extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
|
|
125
|
+
]);
|
|
126
|
+
await syncer.synchronizeFills();
|
|
127
|
+
expect(syncer.positionsFn).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS);
|
|
129
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { fetchUserTradesSince, fetchAllOrdersSince } from '../../client/binance/rest';
|
|
2
|
+
import { fetchAllPositionRisk, fetchUserTradesSince, fetchAllOrdersSince } from '../../client/binance/rest';
|
|
3
3
|
import { BaseAccountSyncer } from '../base';
|
|
4
4
|
|
|
5
5
|
class BinanceAccountSyncer extends BaseAccountSyncer {
|
|
@@ -8,6 +8,31 @@ class BinanceAccountSyncer extends BaseAccountSyncer {
|
|
|
8
8
|
super(config);
|
|
9
9
|
this.creds = config.credentials;
|
|
10
10
|
}
|
|
11
|
+
async listExternalPositions({
|
|
12
|
+
instruments
|
|
13
|
+
}) {
|
|
14
|
+
const contractSizeByInstrumentId = new Map(
|
|
15
|
+
instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
|
|
16
|
+
);
|
|
17
|
+
const rawPositions = await fetchAllPositionRisk(this.creds, this.demo);
|
|
18
|
+
return rawPositions.reduce((positions, rawPosition) => {
|
|
19
|
+
const signedSize = new BigNumber(rawPosition.positionAmt);
|
|
20
|
+
if (signedSize.isZero()) {
|
|
21
|
+
return positions;
|
|
22
|
+
}
|
|
23
|
+
const contractSize = contractSizeByInstrumentId.get(rawPosition.symbol) ?? 1;
|
|
24
|
+
const size = signedSize.abs().times(contractSize).toFixed();
|
|
25
|
+
const side = signedSize.isGreaterThan(0) ? "BUY" : "SELL";
|
|
26
|
+
positions.push({
|
|
27
|
+
symbol: rawPosition.symbol,
|
|
28
|
+
size,
|
|
29
|
+
side,
|
|
30
|
+
entryPrice: rawPosition.entryPrice,
|
|
31
|
+
markPrice: rawPosition.markPrice
|
|
32
|
+
});
|
|
33
|
+
return positions;
|
|
34
|
+
}, []);
|
|
35
|
+
}
|
|
11
36
|
async listExternalFills({
|
|
12
37
|
since,
|
|
13
38
|
instruments,
|
|
@@ -25,33 +50,36 @@ class BinanceAccountSyncer extends BaseAccountSyncer {
|
|
|
25
50
|
fetchAllOrdersSince(this.creds, instrumentIds, since, this.demo)
|
|
26
51
|
]);
|
|
27
52
|
const ordersBySymbolOrderId = this.mapOrdersBySymbolOrderId(orders);
|
|
28
|
-
return trades.reduce((
|
|
29
|
-
if (!requestedInstrumentIds.has(
|
|
30
|
-
return
|
|
53
|
+
return trades.reduce((fills, rawFill) => {
|
|
54
|
+
if (!requestedInstrumentIds.has(rawFill.symbol)) {
|
|
55
|
+
return fills;
|
|
31
56
|
}
|
|
32
|
-
const order = ordersBySymbolOrderId.get(this.orderKey(
|
|
33
|
-
|
|
57
|
+
const order = ordersBySymbolOrderId.get(this.orderKey(rawFill.symbol, rawFill.orderId));
|
|
58
|
+
fills.push({
|
|
34
59
|
connector: "binance",
|
|
35
|
-
exchangeFillId:
|
|
60
|
+
exchangeFillId: rawFill.id.toString(),
|
|
36
61
|
cloid: order?.clientOrderId || null,
|
|
37
|
-
symbol:
|
|
38
|
-
price:
|
|
39
|
-
quantity:
|
|
40
|
-
side:
|
|
41
|
-
reduceOnly: this.isReduceOnly(
|
|
42
|
-
timestamp:
|
|
43
|
-
tradeFee: new BigNumber(
|
|
62
|
+
symbol: rawFill.symbol,
|
|
63
|
+
price: rawFill.price,
|
|
64
|
+
quantity: rawFill.qty,
|
|
65
|
+
side: rawFill.side,
|
|
66
|
+
reduceOnly: this.isReduceOnly(rawFill),
|
|
67
|
+
timestamp: rawFill.time,
|
|
68
|
+
tradeFee: new BigNumber(rawFill.commission || 0).abs().toFixed(),
|
|
44
69
|
builderFee: "0",
|
|
45
70
|
liquidation: null
|
|
46
71
|
});
|
|
47
|
-
return
|
|
72
|
+
return fills;
|
|
48
73
|
}, []);
|
|
49
74
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
async listExternalFunding(_params) {
|
|
76
|
+
throw new Error("funding sync is not implemented for binance");
|
|
77
|
+
}
|
|
78
|
+
isReduceOnly(rawFill) {
|
|
79
|
+
if (rawFill.positionSide === "LONG") return rawFill.side === "SELL";
|
|
80
|
+
if (rawFill.positionSide === "SHORT") return rawFill.side === "BUY";
|
|
81
|
+
if (rawFill.positionSide === "BOTH") {
|
|
82
|
+
return !new BigNumber(rawFill.realizedPnl || 0).isZero();
|
|
55
83
|
}
|
|
56
84
|
return false;
|
|
57
85
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { fetchExecutionsSince } from '../../client/bybit/rest';
|
|
2
|
+
import { fetchPositions, fetchExecutionsSince } from '../../client/bybit/rest';
|
|
3
3
|
import { BaseAccountSyncer } from '../base';
|
|
4
4
|
|
|
5
5
|
class BybitAccountSyncer extends BaseAccountSyncer {
|
|
@@ -8,28 +8,52 @@ class BybitAccountSyncer 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, "USDT", 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.size || rawPosition.size === "0" || !rawPosition.side) return positions;
|
|
20
|
+
const contractSize = contractSizeByInstrumentId.get(rawPosition.symbol) ?? 1;
|
|
21
|
+
const size = new BigNumber(rawPosition.size).abs().times(contractSize).toFixed();
|
|
22
|
+
positions.push({
|
|
23
|
+
symbol: rawPosition.symbol,
|
|
24
|
+
size,
|
|
25
|
+
side: rawPosition.side === "Buy" ? "BUY" : "SELL",
|
|
26
|
+
entryPrice: rawPosition.avgPrice,
|
|
27
|
+
markPrice: rawPosition.avgPrice
|
|
28
|
+
});
|
|
29
|
+
return positions;
|
|
30
|
+
}, []);
|
|
31
|
+
}
|
|
11
32
|
async listExternalFills({ since }) {
|
|
12
33
|
const executions = await fetchExecutionsSince(this.creds, since, this.demo);
|
|
13
|
-
return executions.reduce((
|
|
14
|
-
|
|
34
|
+
return executions.reduce((fills, rawFill) => {
|
|
35
|
+
fills.push({
|
|
15
36
|
connector: "bybit",
|
|
16
|
-
exchangeFillId:
|
|
17
|
-
cloid:
|
|
18
|
-
symbol:
|
|
19
|
-
price:
|
|
20
|
-
quantity:
|
|
21
|
-
side:
|
|
22
|
-
reduceOnly: this.isReduceOnly(
|
|
23
|
-
timestamp: Number(
|
|
24
|
-
tradeFee: new BigNumber(
|
|
37
|
+
exchangeFillId: rawFill.execId,
|
|
38
|
+
cloid: rawFill.orderLinkId || null,
|
|
39
|
+
symbol: rawFill.symbol,
|
|
40
|
+
price: rawFill.execPrice,
|
|
41
|
+
quantity: rawFill.execQty,
|
|
42
|
+
side: rawFill.side === "Buy" ? "BUY" : "SELL",
|
|
43
|
+
reduceOnly: this.isReduceOnly(rawFill),
|
|
44
|
+
timestamp: Number(rawFill.execTime),
|
|
45
|
+
tradeFee: new BigNumber(rawFill.execFee || 0).abs().toFixed(),
|
|
25
46
|
builderFee: "0",
|
|
26
47
|
liquidation: null
|
|
27
48
|
});
|
|
28
|
-
return
|
|
49
|
+
return fills;
|
|
29
50
|
}, []);
|
|
30
51
|
}
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
async listExternalFunding(_params) {
|
|
53
|
+
throw new Error("funding sync is not implemented for bybit");
|
|
54
|
+
}
|
|
55
|
+
isReduceOnly(rawFill) {
|
|
56
|
+
return Number(rawFill.closedSize || "0") > 0;
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
|
|
3
|
+
function toFundingPaymentEntries(events, symbolToInstrumentId) {
|
|
4
|
+
return events.reduce((entries, event) => {
|
|
5
|
+
const instrumentId = symbolToInstrumentId[event.delta.coin];
|
|
6
|
+
if (!instrumentId) {
|
|
7
|
+
console.warn(`Instrument symbol ${event.delta.coin} not found in instrument symbol to id map.`);
|
|
8
|
+
return entries;
|
|
9
|
+
}
|
|
10
|
+
const size = new BigNumber(event.delta.szi);
|
|
11
|
+
const fundingPerUnit = size.isZero() ? "0" : new BigNumber(event.delta.usdc).div(size).toFixed();
|
|
12
|
+
entries.push({
|
|
13
|
+
connector: "hyperliquid",
|
|
14
|
+
instrumentId,
|
|
15
|
+
fundingRate: event.delta.fundingRate,
|
|
16
|
+
notional: event.delta.usdc,
|
|
17
|
+
fundingPerUnit,
|
|
18
|
+
timestamp: event.time
|
|
19
|
+
});
|
|
20
|
+
return entries;
|
|
21
|
+
}, []);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { toFundingPaymentEntries };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { toFundingPaymentEntries } from './funding';
|
|
3
|
+
|
|
4
|
+
const fundingEvent = (over) => ({
|
|
5
|
+
time: over.time,
|
|
6
|
+
hash: "0xabc",
|
|
7
|
+
delta: {
|
|
8
|
+
type: "funding",
|
|
9
|
+
coin: over.coin,
|
|
10
|
+
usdc: over.usdc ?? "1.5",
|
|
11
|
+
szi: over.szi ?? "10",
|
|
12
|
+
fundingRate: over.fundingRate,
|
|
13
|
+
nSamples: 1
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
describe("toFundingPaymentEntries", () => {
|
|
17
|
+
const map = {
|
|
18
|
+
BTC: "HL-BTC",
|
|
19
|
+
ETH: "HL-ETH"
|
|
20
|
+
};
|
|
21
|
+
it("maps a funding event to a funding-payment entry (rate + notional + per-size)", () => {
|
|
22
|
+
const result = toFundingPaymentEntries(
|
|
23
|
+
[fundingEvent({ coin: "BTC", fundingRate: "-0.0001", usdc: "-2.5", szi: "5", time: 1e3 })],
|
|
24
|
+
map
|
|
25
|
+
);
|
|
26
|
+
expect(result).toEqual([
|
|
27
|
+
{
|
|
28
|
+
connector: "hyperliquid",
|
|
29
|
+
instrumentId: "HL-BTC",
|
|
30
|
+
fundingRate: "-0.0001",
|
|
31
|
+
notional: "-2.5",
|
|
32
|
+
// notional / szi = -2.5 / 5
|
|
33
|
+
fundingPerUnit: "-0.5",
|
|
34
|
+
timestamp: 1e3
|
|
35
|
+
}
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
it("uses signed division by szi (per-size is the attribution coefficient)", () => {
|
|
39
|
+
const result = toFundingPaymentEntries(
|
|
40
|
+
[fundingEvent({ coin: "BTC", fundingRate: "0.0001", usdc: "2.5", szi: "-5", time: 1e3 })],
|
|
41
|
+
map
|
|
42
|
+
);
|
|
43
|
+
expect(result[0]?.fundingPerUnit).toBe("-0.5");
|
|
44
|
+
});
|
|
45
|
+
it("uses zero per-size when size is zero (no divide by zero)", () => {
|
|
46
|
+
const result = toFundingPaymentEntries(
|
|
47
|
+
[fundingEvent({ coin: "BTC", fundingRate: "0.0001", usdc: "0", szi: "0", time: 1e3 })],
|
|
48
|
+
map
|
|
49
|
+
);
|
|
50
|
+
expect(result[0]?.fundingPerUnit).toBe("0");
|
|
51
|
+
});
|
|
52
|
+
it("skips events whose coin is not in the instrument map", () => {
|
|
53
|
+
const result = toFundingPaymentEntries(
|
|
54
|
+
[
|
|
55
|
+
fundingEvent({ coin: "BTC", fundingRate: "0.0001", time: 1e3 }),
|
|
56
|
+
fundingEvent({ coin: "DOGE", fundingRate: "0.0002", time: 2e3 }),
|
|
57
|
+
fundingEvent({ coin: "ETH", fundingRate: "0.0003", time: 3e3 })
|
|
58
|
+
],
|
|
59
|
+
map
|
|
60
|
+
);
|
|
61
|
+
expect(result.map((entry) => entry.instrumentId)).toEqual(["HL-BTC", "HL-ETH"]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,12 +1,61 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
1
2
|
import { fetchInfo } from '../../client/hyperliquid/rest';
|
|
2
3
|
import { BaseAccountSyncer } from '../base';
|
|
4
|
+
import { toFundingPaymentEntries } from './funding';
|
|
3
5
|
|
|
6
|
+
const HYPERLIQUID_FILL_PAGE_LIMIT = 2e3;
|
|
7
|
+
const HYPERLIQUID_FILL_RETENTION_LIMIT = 1e4;
|
|
4
8
|
class HyperliquidAccountSyncer extends BaseAccountSyncer {
|
|
5
9
|
address;
|
|
6
10
|
constructor(config) {
|
|
7
11
|
super(config);
|
|
8
12
|
this.address = config.account.exchangeIdentifier;
|
|
9
13
|
}
|
|
14
|
+
async listExternalPositions({
|
|
15
|
+
instruments
|
|
16
|
+
}) {
|
|
17
|
+
const instrumentByBase = new Map(
|
|
18
|
+
instruments.map((instrument) => [
|
|
19
|
+
instrument.base,
|
|
20
|
+
{ id: instrument.id, contractSize: instrument.precision.quantity.contractSize }
|
|
21
|
+
])
|
|
22
|
+
);
|
|
23
|
+
const perpDexs = await fetchInfo({ type: "perpDexs" }, this.demo);
|
|
24
|
+
const dexNames = [...new Set(perpDexs.map((perpDex) => perpDex?.name ?? ""))];
|
|
25
|
+
const states = await Promise.all(
|
|
26
|
+
dexNames.map(
|
|
27
|
+
(dex) => fetchInfo(
|
|
28
|
+
{
|
|
29
|
+
type: "clearinghouseState",
|
|
30
|
+
user: this.address,
|
|
31
|
+
dex
|
|
32
|
+
},
|
|
33
|
+
this.demo
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
const assetPositions = states.flatMap((state) => state.assetPositions);
|
|
38
|
+
return assetPositions.reduce((positions, { position: rawPosition }) => {
|
|
39
|
+
const szi = new BigNumber(rawPosition.szi);
|
|
40
|
+
if (szi.isZero()) {
|
|
41
|
+
return positions;
|
|
42
|
+
}
|
|
43
|
+
const instrument = instrumentByBase.get(rawPosition.coin);
|
|
44
|
+
if (!instrument) {
|
|
45
|
+
console.warn(`Instrument for coin ${rawPosition.coin} not found.`);
|
|
46
|
+
return positions;
|
|
47
|
+
}
|
|
48
|
+
const size = szi.abs().times(instrument.contractSize).toFixed();
|
|
49
|
+
positions.push({
|
|
50
|
+
symbol: instrument.id,
|
|
51
|
+
size,
|
|
52
|
+
side: szi.isPositive() ? "BUY" : "SELL",
|
|
53
|
+
entryPrice: rawPosition.entryPx,
|
|
54
|
+
markPrice: new BigNumber(rawPosition.positionValue).abs().div(size).toFixed()
|
|
55
|
+
});
|
|
56
|
+
return positions;
|
|
57
|
+
}, []);
|
|
58
|
+
}
|
|
10
59
|
async listExternalFills({
|
|
11
60
|
since,
|
|
12
61
|
instruments
|
|
@@ -15,38 +64,148 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
|
|
|
15
64
|
acc[instrument.base] = instrument.id;
|
|
16
65
|
return acc;
|
|
17
66
|
}, {});
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
const allFills = [];
|
|
68
|
+
const seenFillIds = /* @__PURE__ */ new Set();
|
|
69
|
+
let oldestReachableFillTime = null;
|
|
70
|
+
const addFill = (fill) => {
|
|
71
|
+
const fillId = hyperliquidFillId(fill);
|
|
72
|
+
if (!seenFillIds.has(fillId)) {
|
|
73
|
+
seenFillIds.add(fillId);
|
|
74
|
+
allFills.push(fill);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
let cursor = since.getTime();
|
|
78
|
+
let batchSize;
|
|
79
|
+
do {
|
|
80
|
+
const batch = await fetchInfo(
|
|
81
|
+
{
|
|
82
|
+
type: "userFillsByTime",
|
|
83
|
+
user: this.address,
|
|
84
|
+
startTime: cursor,
|
|
85
|
+
aggregateByTime: false
|
|
86
|
+
},
|
|
87
|
+
this.demo
|
|
88
|
+
);
|
|
89
|
+
batchSize = batch.length;
|
|
90
|
+
if (batch.length > 0 && oldestReachableFillTime === null) {
|
|
91
|
+
oldestReachableFillTime = Math.min(...batch.map((fill) => fill.time));
|
|
92
|
+
}
|
|
93
|
+
for (const rawFill of batch) {
|
|
94
|
+
addFill(rawFill);
|
|
95
|
+
}
|
|
96
|
+
if (seenFillIds.size >= HYPERLIQUID_FILL_RETENTION_LIMIT && oldestReachableFillTime !== null && since.getTime() < oldestReachableFillTime) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Hyperliquid fill history is incomplete: requested since ${since.getTime()} predates oldest reachable fill ${oldestReachableFillTime} within the ${HYPERLIQUID_FILL_RETENTION_LIMIT}-fill retention limit.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT) {
|
|
102
|
+
const nextCursor = batch[batch.length - 1]?.time ?? cursor;
|
|
103
|
+
if (nextCursor <= cursor) {
|
|
104
|
+
warnStalledPagination("fills", cursor);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
cursor = nextCursor;
|
|
108
|
+
}
|
|
109
|
+
} while (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT);
|
|
110
|
+
cursor = since.getTime();
|
|
111
|
+
do {
|
|
112
|
+
const twapBatch = await fetchInfo(
|
|
113
|
+
{
|
|
114
|
+
type: "userTwapSliceFillsByTime",
|
|
115
|
+
user: this.address,
|
|
116
|
+
startTime: cursor,
|
|
117
|
+
aggregateByTime: false
|
|
118
|
+
},
|
|
119
|
+
this.demo
|
|
120
|
+
);
|
|
121
|
+
batchSize = twapBatch.length;
|
|
122
|
+
for (const item of twapBatch) {
|
|
123
|
+
addFill(item.fill);
|
|
124
|
+
}
|
|
125
|
+
if (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT) {
|
|
126
|
+
const nextCursor = twapBatch[twapBatch.length - 1]?.fill.time ?? cursor;
|
|
127
|
+
if (nextCursor <= cursor) {
|
|
128
|
+
warnStalledPagination("TWAP fills", cursor);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
cursor = nextCursor;
|
|
132
|
+
}
|
|
133
|
+
} while (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT);
|
|
134
|
+
allFills.sort((a, b) => a.time - b.time);
|
|
135
|
+
return allFills.reduce((fills, rawFill) => {
|
|
136
|
+
const instrumentId = instrumentSymbolToIdMap[rawFill.coin];
|
|
26
137
|
if (!instrumentId) {
|
|
27
|
-
console.warn(`Instrument symbol ${
|
|
28
|
-
return
|
|
138
|
+
console.warn(`Instrument symbol ${rawFill.coin} not found in instrument symbol to id map.`);
|
|
139
|
+
return fills;
|
|
29
140
|
}
|
|
30
|
-
|
|
141
|
+
const tradeFee = new BigNumber(rawFill.fee).minus(rawFill.builderFee ?? "0");
|
|
142
|
+
if (tradeFee.isNegative()) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Hyperliquid fill ${hyperliquidFillId(rawFill)} has a negative net trade fee (${tradeFee.toFixed()}); the backend external-fill contract does not accept Hyperliquid rebates.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
fills.push({
|
|
31
148
|
connector: "hyperliquid",
|
|
32
|
-
exchangeFillId:
|
|
33
|
-
cloid:
|
|
149
|
+
exchangeFillId: hyperliquidFillId(rawFill),
|
|
150
|
+
cloid: rawFill.cloid ?? null,
|
|
34
151
|
symbol: instrumentId,
|
|
35
|
-
price:
|
|
36
|
-
quantity:
|
|
37
|
-
side:
|
|
38
|
-
reduceOnly: this.isReduceOnly(
|
|
39
|
-
timestamp:
|
|
40
|
-
tradeFee:
|
|
41
|
-
builderFee:
|
|
42
|
-
liquidation:
|
|
152
|
+
price: rawFill.px,
|
|
153
|
+
quantity: rawFill.sz,
|
|
154
|
+
side: rawFill.side === "B" ? "BUY" : "SELL",
|
|
155
|
+
reduceOnly: this.isReduceOnly(rawFill),
|
|
156
|
+
timestamp: rawFill.time,
|
|
157
|
+
tradeFee: tradeFee.toFixed(),
|
|
158
|
+
builderFee: new BigNumber(rawFill.builderFee ?? "0").abs().toFixed(),
|
|
159
|
+
liquidation: rawFill.liquidation ? { mark: rawFill.liquidation.markPx } : null
|
|
43
160
|
});
|
|
44
|
-
return
|
|
161
|
+
return fills;
|
|
45
162
|
}, []);
|
|
46
163
|
}
|
|
47
|
-
|
|
48
|
-
|
|
164
|
+
async listExternalFunding({
|
|
165
|
+
since,
|
|
166
|
+
instruments
|
|
167
|
+
}) {
|
|
168
|
+
const instrumentSymbolToIdMap = instruments.reduce((acc, instrument) => {
|
|
169
|
+
acc[instrument.base] = instrument.id;
|
|
170
|
+
return acc;
|
|
171
|
+
}, {});
|
|
172
|
+
const PAGE_LIMIT = 500;
|
|
173
|
+
const allEvents = /* @__PURE__ */ new Map();
|
|
174
|
+
let cursor = since.getTime();
|
|
175
|
+
let batchSize;
|
|
176
|
+
do {
|
|
177
|
+
const batch = await fetchInfo(
|
|
178
|
+
{
|
|
179
|
+
type: "userFunding",
|
|
180
|
+
user: this.address,
|
|
181
|
+
startTime: cursor
|
|
182
|
+
},
|
|
183
|
+
this.demo
|
|
184
|
+
);
|
|
185
|
+
batchSize = batch.length;
|
|
186
|
+
for (const event of batch) {
|
|
187
|
+
allEvents.set(`${event.time}:${event.delta.coin}:${event.hash}`, event);
|
|
188
|
+
}
|
|
189
|
+
if (batchSize === PAGE_LIMIT) {
|
|
190
|
+
const nextCursor = batch[batch.length - 1]?.time ?? cursor;
|
|
191
|
+
if (nextCursor <= cursor) {
|
|
192
|
+
warnStalledPagination("funding", cursor);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
cursor = nextCursor;
|
|
196
|
+
}
|
|
197
|
+
} while (batchSize === PAGE_LIMIT);
|
|
198
|
+
return toFundingPaymentEntries([...allEvents.values()], instrumentSymbolToIdMap);
|
|
199
|
+
}
|
|
200
|
+
isReduceOnly(rawFill) {
|
|
201
|
+
return rawFill.dir.includes("Close");
|
|
49
202
|
}
|
|
50
203
|
}
|
|
204
|
+
function hyperliquidFillId(fill) {
|
|
205
|
+
return `${fill.time}:${fill.coin}:${fill.tid}`;
|
|
206
|
+
}
|
|
207
|
+
function warnStalledPagination(stream, cursor) {
|
|
208
|
+
console.warn(`Hyperliquid ${stream} pagination stalled at timestamp ${cursor}; returned history may be incomplete.`);
|
|
209
|
+
}
|
|
51
210
|
|
|
52
211
|
export { HyperliquidAccountSyncer };
|