@pear-protocol/exchanges-sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +78 -12
  2. package/dist/client/hyperliquid/client.test.js +41 -0
  3. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  4. package/dist/client/hyperliquid/rest.js +26 -5
  5. package/dist/client/hyperliquid/ws.js +16 -4
  6. package/dist/client/index.js +2 -2
  7. package/dist/client/lighter/constants.js +9 -1
  8. package/dist/client/lighter/constants.test.js +22 -0
  9. package/dist/client/lighter/exchange-ws.js +2 -2
  10. package/dist/client/lighter/rest.js +101 -32
  11. package/dist/client/lighter/rest.test.js +152 -0
  12. package/dist/client/lighter/ws.js +7 -2
  13. package/dist/client/okx/rest.js +18 -9
  14. package/dist/helper/factory.js +2 -2
  15. package/dist/helper/hyperliquid/helper.js +28 -13
  16. package/dist/helper/hyperliquid/helper.test.js +45 -0
  17. package/dist/helper/lighter/helper.js +77 -16
  18. package/dist/helper/lighter/helper.test.js +289 -0
  19. package/dist/helper/lighter/types.js +9 -8
  20. package/dist/index.d.ts +239 -47
  21. package/dist/sdk.js +20 -16
  22. package/dist/sdk.test.js +101 -0
  23. package/dist/status/hyperliquid/status.js +18 -5
  24. package/dist/status/hyperliquid/status.test.js +81 -0
  25. package/dist/status/lighter/status.js +18 -11
  26. package/dist/status/lighter/status.test.js +75 -0
  27. package/dist/syncer/base.js +107 -25
  28. package/dist/syncer/base.test.js +131 -0
  29. package/dist/syncer/binance/syncer.js +48 -20
  30. package/dist/syncer/bybit/syncer.js +39 -15
  31. package/dist/syncer/hyperliquid/funding.js +24 -0
  32. package/dist/syncer/hyperliquid/funding.test.js +63 -0
  33. package/dist/syncer/hyperliquid/syncer.js +183 -24
  34. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  35. package/dist/syncer/lighter/funding.js +25 -0
  36. package/dist/syncer/lighter/funding.test.js +48 -0
  37. package/dist/syncer/lighter/syncer.js +93 -41
  38. package/dist/syncer/lighter/syncer.test.js +132 -6
  39. package/dist/syncer/okx/syncer.js +41 -17
  40. package/dist/tracker/base.js +23 -6
  41. package/dist/tracker/base.test.js +132 -0
  42. package/dist/tracker/binance/tracker.js +48 -18
  43. package/dist/tracker/binance/tracker.test.js +138 -0
  44. package/dist/tracker/bybit/tracker.js +78 -23
  45. package/dist/tracker/bybit/tracker.test.js +145 -0
  46. package/dist/tracker/hyperliquid/mapper.js +244 -44
  47. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  48. package/dist/tracker/hyperliquid/tracker.js +241 -74
  49. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  50. package/dist/tracker/index.js +15 -37
  51. package/dist/tracker/lighter/mapper.js +163 -33
  52. package/dist/tracker/lighter/mapper.test.js +199 -0
  53. package/dist/tracker/lighter/tracker.js +219 -45
  54. package/dist/tracker/lighter/tracker.test.js +515 -0
  55. package/dist/tracker/lighter/types.js +10 -2
  56. package/dist/tracker/okx/tracker.js +50 -16
  57. package/dist/tracker/okx/tracker.test.js +126 -0
  58. package/dist/tracker/types.js +2 -1
  59. package/dist/tracker/utils.test.js +8 -0
  60. package/package.json +6 -5
@@ -0,0 +1,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((acc, trade) => {
29
- if (!requestedInstrumentIds.has(trade.symbol)) {
30
- return acc;
53
+ return trades.reduce((fills, rawFill) => {
54
+ if (!requestedInstrumentIds.has(rawFill.symbol)) {
55
+ return fills;
31
56
  }
32
- const order = ordersBySymbolOrderId.get(this.orderKey(trade.symbol, trade.orderId));
33
- acc.push({
57
+ const order = ordersBySymbolOrderId.get(this.orderKey(rawFill.symbol, rawFill.orderId));
58
+ fills.push({
34
59
  connector: "binance",
35
- exchangeFillId: trade.id.toString(),
60
+ exchangeFillId: rawFill.id.toString(),
36
61
  cloid: order?.clientOrderId || null,
37
- symbol: trade.symbol,
38
- price: trade.price,
39
- quantity: trade.qty,
40
- side: trade.side,
41
- reduceOnly: this.isReduceOnly(trade),
42
- timestamp: trade.time,
43
- tradeFee: new BigNumber(trade.commission || 0).abs().toFixed(),
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 acc;
72
+ return fills;
48
73
  }, []);
49
74
  }
50
- isReduceOnly(trade) {
51
- if (trade.positionSide === "LONG") return trade.side === "SELL";
52
- if (trade.positionSide === "SHORT") return trade.side === "BUY";
53
- if (trade.positionSide === "BOTH") {
54
- return !new BigNumber(trade.realizedPnl || 0).isZero();
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((acc, execution) => {
14
- acc.push({
34
+ return executions.reduce((fills, rawFill) => {
35
+ fills.push({
15
36
  connector: "bybit",
16
- exchangeFillId: execution.execId,
17
- cloid: execution.orderLinkId || null,
18
- symbol: execution.symbol,
19
- price: execution.execPrice,
20
- quantity: execution.execQty,
21
- side: execution.side === "Buy" ? "BUY" : "SELL",
22
- reduceOnly: this.isReduceOnly(execution),
23
- timestamp: Number(execution.execTime),
24
- tradeFee: new BigNumber(execution.execFee || 0).abs().toFixed(),
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 acc;
49
+ return fills;
29
50
  }, []);
30
51
  }
31
- isReduceOnly(execution) {
32
- return Number(execution.closedSize || "0") > 0;
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 result = await fetchInfo({
19
- type: "userFillsByTime",
20
- user: this.address,
21
- startTime: since.getTime(),
22
- aggregateByTime: true
23
- });
24
- return result.reduce((acc, fill) => {
25
- const instrumentId = instrumentSymbolToIdMap[fill.coin];
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 ${fill.coin} not found in instrument symbol to id map.`);
28
- return acc;
138
+ console.warn(`Instrument symbol ${rawFill.coin} not found in instrument symbol to id map.`);
139
+ return fills;
29
140
  }
30
- acc.push({
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: fill.tid.toFixed(),
33
- cloid: fill.cloid ?? null,
149
+ exchangeFillId: hyperliquidFillId(rawFill),
150
+ cloid: rawFill.cloid ?? null,
34
151
  symbol: instrumentId,
35
- price: fill.px,
36
- quantity: fill.sz,
37
- side: fill.side === "B" ? "BUY" : "SELL",
38
- reduceOnly: this.isReduceOnly(fill),
39
- timestamp: fill.time,
40
- tradeFee: fill.fee,
41
- builderFee: fill.builderFee ?? "0",
42
- liquidation: fill.liquidation ? { mark: fill.liquidation.markPx } : null
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 acc;
161
+ return fills;
45
162
  }, []);
46
163
  }
47
- isReduceOnly(fill) {
48
- return fill.dir.includes("Close");
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 };