@pear-protocol/exchanges-sdk 0.3.0 → 0.5.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/binance/rest.js +4 -3
- package/dist/client/bybit/rest.js +2 -1
- package/dist/client/hyperliquid/client.test.js +41 -0
- package/dist/client/hyperliquid/exchange-ws.js +3 -2
- package/dist/client/hyperliquid/rest.js +28 -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 +110 -37
- package/dist/client/lighter/rest.test.js +152 -0
- package/dist/client/lighter/ws.js +7 -2
- package/dist/client/okx/rest.js +20 -10
- 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 +189 -38
- package/dist/sdk.js +22 -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 +6 -5
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { vi, describe, beforeEach, it, expect } from 'vitest';
|
|
2
|
+
import { HyperliquidAccountSyncer } from './syncer';
|
|
3
|
+
|
|
4
|
+
const { fetchInfoMock } = vi.hoisted(() => ({ fetchInfoMock: vi.fn() }));
|
|
5
|
+
vi.mock("../../client/hyperliquid/rest", () => ({ fetchInfo: fetchInfoMock }));
|
|
6
|
+
const account = {
|
|
7
|
+
exchangeIdentifier: "0x1111111111111111111111111111111111111111"
|
|
8
|
+
};
|
|
9
|
+
const instrument = {
|
|
10
|
+
id: "HL-BTC",
|
|
11
|
+
base: "BTC"
|
|
12
|
+
};
|
|
13
|
+
function rawFill(time, tid = time) {
|
|
14
|
+
return {
|
|
15
|
+
coin: "BTC",
|
|
16
|
+
px: "100000",
|
|
17
|
+
sz: "0.1",
|
|
18
|
+
side: "B",
|
|
19
|
+
time,
|
|
20
|
+
startPosition: "0",
|
|
21
|
+
dir: "Open Long",
|
|
22
|
+
closedPnl: "0",
|
|
23
|
+
hash: "0xabc",
|
|
24
|
+
oid: 123,
|
|
25
|
+
crossed: true,
|
|
26
|
+
fee: "1",
|
|
27
|
+
builderFee: "0.2",
|
|
28
|
+
tid,
|
|
29
|
+
feeToken: "USDC",
|
|
30
|
+
twapId: null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function createSyncer() {
|
|
34
|
+
return new HyperliquidAccountSyncer({
|
|
35
|
+
sdk: {},
|
|
36
|
+
connector: "hyperliquid",
|
|
37
|
+
account,
|
|
38
|
+
credentials: { signer_key: "sk" },
|
|
39
|
+
demo: true
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
describe("HyperliquidAccountSyncer fill mapping", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
fetchInfoMock.mockReset().mockImplementation(async (body) => {
|
|
45
|
+
if (body.type === "userTwapSliceFillsByTime") {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
coin: "BTC",
|
|
51
|
+
px: "100000",
|
|
52
|
+
sz: "0.1",
|
|
53
|
+
side: "B",
|
|
54
|
+
time: 17e11,
|
|
55
|
+
startPosition: "0",
|
|
56
|
+
dir: "Open Long",
|
|
57
|
+
closedPnl: "0",
|
|
58
|
+
hash: "0xabc",
|
|
59
|
+
oid: 123,
|
|
60
|
+
crossed: true,
|
|
61
|
+
fee: "1",
|
|
62
|
+
builderFee: "0.2",
|
|
63
|
+
tid: 456,
|
|
64
|
+
feeToken: "USDC",
|
|
65
|
+
twapId: null
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it("uses a globally unique identity, excludes builder fee from trade fee, and routes to testnet", async () => {
|
|
71
|
+
const syncer = createSyncer();
|
|
72
|
+
const fills = await syncer.listExternalFills({
|
|
73
|
+
since: /* @__PURE__ */ new Date(1699999e6),
|
|
74
|
+
instruments: [instrument],
|
|
75
|
+
openPositions: [],
|
|
76
|
+
provisionalFills: []
|
|
77
|
+
});
|
|
78
|
+
expect(fills).toEqual([
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
exchangeFillId: "1700000000000:BTC:456",
|
|
81
|
+
tradeFee: "0.8",
|
|
82
|
+
builderFee: "0.2"
|
|
83
|
+
})
|
|
84
|
+
]);
|
|
85
|
+
expect(fetchInfoMock).toHaveBeenCalledWith(expect.objectContaining({ type: "userFillsByTime" }), true);
|
|
86
|
+
expect(fetchInfoMock).toHaveBeenCalledWith(
|
|
87
|
+
expect.objectContaining({ type: "userFillsByTime", aggregateByTime: false }),
|
|
88
|
+
true
|
|
89
|
+
);
|
|
90
|
+
expect(fetchInfoMock).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({ type: "userTwapSliceFillsByTime", aggregateByTime: false }),
|
|
92
|
+
true
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
it("fails closed when a maker rebate cannot satisfy the backend external-fill fee contract", async () => {
|
|
96
|
+
fetchInfoMock.mockImplementation(async (body) => {
|
|
97
|
+
if (body.type === "userTwapSliceFillsByTime") return [];
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
coin: "BTC",
|
|
101
|
+
px: "100000",
|
|
102
|
+
sz: "0.1",
|
|
103
|
+
side: "B",
|
|
104
|
+
time: 17e11,
|
|
105
|
+
startPosition: "0",
|
|
106
|
+
dir: "Open Long",
|
|
107
|
+
closedPnl: "0",
|
|
108
|
+
hash: "0xabc",
|
|
109
|
+
oid: 123,
|
|
110
|
+
crossed: false,
|
|
111
|
+
fee: "-0.08",
|
|
112
|
+
builderFee: "0.02",
|
|
113
|
+
tid: 456,
|
|
114
|
+
feeToken: "USDC",
|
|
115
|
+
twapId: null
|
|
116
|
+
}
|
|
117
|
+
];
|
|
118
|
+
});
|
|
119
|
+
const syncer = createSyncer();
|
|
120
|
+
await expect(
|
|
121
|
+
syncer.listExternalFills({
|
|
122
|
+
since: /* @__PURE__ */ new Date(1699999e6),
|
|
123
|
+
instruments: [instrument],
|
|
124
|
+
openPositions: [],
|
|
125
|
+
provisionalFills: []
|
|
126
|
+
})
|
|
127
|
+
).rejects.toThrow(
|
|
128
|
+
"Hyperliquid fill 1700000000000:BTC:456 has a negative net trade fee (-0.1); the backend external-fill contract does not accept Hyperliquid rebates."
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
it("warns when inclusive fill pagination cannot move past the current timestamp", async () => {
|
|
132
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => void 0);
|
|
133
|
+
const event = {
|
|
134
|
+
coin: "BTC",
|
|
135
|
+
px: "100000",
|
|
136
|
+
sz: "0.1",
|
|
137
|
+
side: "B",
|
|
138
|
+
time: 1e3,
|
|
139
|
+
startPosition: "0",
|
|
140
|
+
dir: "Open Long",
|
|
141
|
+
closedPnl: "0",
|
|
142
|
+
hash: "0xabc",
|
|
143
|
+
oid: 123,
|
|
144
|
+
crossed: true,
|
|
145
|
+
fee: "1",
|
|
146
|
+
builderFee: "0.2",
|
|
147
|
+
tid: 456,
|
|
148
|
+
feeToken: "USDC",
|
|
149
|
+
twapId: null
|
|
150
|
+
};
|
|
151
|
+
fetchInfoMock.mockImplementation(
|
|
152
|
+
async (body) => body.type === "userFillsByTime" ? Array.from({ length: 2e3 }, () => event) : []
|
|
153
|
+
);
|
|
154
|
+
const syncer = createSyncer();
|
|
155
|
+
await syncer.listExternalFills({
|
|
156
|
+
since: /* @__PURE__ */ new Date(1e3),
|
|
157
|
+
instruments: [instrument],
|
|
158
|
+
openPositions: [],
|
|
159
|
+
provisionalFills: []
|
|
160
|
+
});
|
|
161
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
162
|
+
"Hyperliquid fills pagination stalled at timestamp 1000; returned history may be incomplete."
|
|
163
|
+
);
|
|
164
|
+
expect(fetchInfoMock).toHaveBeenCalledTimes(2);
|
|
165
|
+
consoleWarn.mockRestore();
|
|
166
|
+
});
|
|
167
|
+
it("counts raw fills before rejecting history older than the 10,000-fill retention boundary", async () => {
|
|
168
|
+
let page = 0;
|
|
169
|
+
fetchInfoMock.mockImplementation(async (body) => {
|
|
170
|
+
if (body.type !== "userFillsByTime") return [];
|
|
171
|
+
const firstTime = 1e3 + page * 2e3;
|
|
172
|
+
page += 1;
|
|
173
|
+
return Array.from({ length: 2e3 }, (_, index) => rawFill(firstTime + index));
|
|
174
|
+
});
|
|
175
|
+
const syncer = createSyncer();
|
|
176
|
+
await expect(
|
|
177
|
+
syncer.listExternalFills({
|
|
178
|
+
since: /* @__PURE__ */ new Date(0),
|
|
179
|
+
instruments: [instrument],
|
|
180
|
+
openPositions: [],
|
|
181
|
+
provisionalFills: []
|
|
182
|
+
})
|
|
183
|
+
).rejects.toThrow(
|
|
184
|
+
"Hyperliquid fill history is incomplete: requested since 0 predates oldest reachable fill 1000 within the 10000-fill retention limit."
|
|
185
|
+
);
|
|
186
|
+
expect(fetchInfoMock).toHaveBeenCalledTimes(5);
|
|
187
|
+
expect(fetchInfoMock).toHaveBeenCalledWith(expect.objectContaining({ aggregateByTime: false }), true);
|
|
188
|
+
});
|
|
189
|
+
it("accepts a request exactly at the oldest reachable fill at the retention boundary", async () => {
|
|
190
|
+
let page = 0;
|
|
191
|
+
fetchInfoMock.mockImplementation(async (body) => {
|
|
192
|
+
if (body.type === "userTwapSliceFillsByTime") return [];
|
|
193
|
+
if (page >= 5) return [];
|
|
194
|
+
const firstTime = 1e3 + page * 2e3;
|
|
195
|
+
page += 1;
|
|
196
|
+
return Array.from({ length: 2e3 }, (_, index) => rawFill(firstTime + index));
|
|
197
|
+
});
|
|
198
|
+
const syncer = createSyncer();
|
|
199
|
+
const fills = await syncer.listExternalFills({
|
|
200
|
+
since: /* @__PURE__ */ new Date(1e3),
|
|
201
|
+
instruments: [instrument],
|
|
202
|
+
openPositions: [],
|
|
203
|
+
provisionalFills: []
|
|
204
|
+
});
|
|
205
|
+
expect(fills).toHaveLength(1e4);
|
|
206
|
+
expect(fetchInfoMock).toHaveBeenCalledTimes(7);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe("HyperliquidAccountSyncer position and funding snapshots", () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
fetchInfoMock.mockReset();
|
|
212
|
+
});
|
|
213
|
+
it("de-duplicates DEX descriptors and derives mark price from current position value", async () => {
|
|
214
|
+
fetchInfoMock.mockImplementation(async (body) => {
|
|
215
|
+
if (body.type === "perpDexs") return [null, null, { name: "xyz" }];
|
|
216
|
+
if (body.type === "clearinghouseState" && body.dex === "") {
|
|
217
|
+
return {
|
|
218
|
+
assetPositions: [
|
|
219
|
+
{
|
|
220
|
+
position: {
|
|
221
|
+
coin: "BTC",
|
|
222
|
+
szi: "2",
|
|
223
|
+
entryPx: "150",
|
|
224
|
+
positionValue: "400"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (body.type === "clearinghouseState") return { assetPositions: [] };
|
|
231
|
+
throw new Error(`Unexpected request: ${body.type}`);
|
|
232
|
+
});
|
|
233
|
+
const syncer = createSyncer();
|
|
234
|
+
const positions = await syncer.listExternalPositions({
|
|
235
|
+
instruments: [
|
|
236
|
+
{
|
|
237
|
+
...instrument,
|
|
238
|
+
precision: { quantity: { contractSize: 1 } }
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
});
|
|
242
|
+
expect(positions).toEqual([
|
|
243
|
+
expect.objectContaining({ symbol: "HL-BTC", size: "2", entryPrice: "150", markPrice: "200" })
|
|
244
|
+
]);
|
|
245
|
+
expect(fetchInfoMock).toHaveBeenCalledTimes(3);
|
|
246
|
+
});
|
|
247
|
+
it("de-duplicates an inclusive funding boundary and stops when its cursor cannot advance", async () => {
|
|
248
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => void 0);
|
|
249
|
+
const event = {
|
|
250
|
+
time: 1e3,
|
|
251
|
+
hash: "0xabc",
|
|
252
|
+
delta: {
|
|
253
|
+
type: "funding",
|
|
254
|
+
coin: "BTC",
|
|
255
|
+
usdc: "-2",
|
|
256
|
+
szi: "4",
|
|
257
|
+
fundingRate: "0.0001",
|
|
258
|
+
nSamples: null
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
fetchInfoMock.mockResolvedValue(Array.from({ length: 500 }, () => event));
|
|
262
|
+
const syncer = createSyncer();
|
|
263
|
+
const payments = await syncer.listExternalFunding({
|
|
264
|
+
since: /* @__PURE__ */ new Date(1e3),
|
|
265
|
+
instruments: [instrument]
|
|
266
|
+
});
|
|
267
|
+
expect(payments).toHaveLength(1);
|
|
268
|
+
expect(fetchInfoMock).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
270
|
+
"Hyperliquid funding pagination stalled at timestamp 1000; returned history may be incomplete."
|
|
271
|
+
);
|
|
272
|
+
consoleWarn.mockRestore();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
|
|
3
|
+
function toFundingPaymentEntries(events, instrumentIds) {
|
|
4
|
+
return events.reduce((entries, event) => {
|
|
5
|
+
const instrumentId = event.market_id.toString();
|
|
6
|
+
if (!instrumentIds.has(instrumentId)) {
|
|
7
|
+
console.warn(`Lighter market id ${event.market_id} not found in instrument id map.`);
|
|
8
|
+
return entries;
|
|
9
|
+
}
|
|
10
|
+
const unsignedSize = new BigNumber(event.position_size).abs();
|
|
11
|
+
const signedSize = event.position_side === "short" ? unsignedSize.negated() : unsignedSize;
|
|
12
|
+
const fundingPerUnit = signedSize.isZero() ? "0" : new BigNumber(event.change).div(signedSize).toFixed();
|
|
13
|
+
entries.push({
|
|
14
|
+
connector: "lighter",
|
|
15
|
+
instrumentId,
|
|
16
|
+
fundingRate: event.rate,
|
|
17
|
+
notional: event.change,
|
|
18
|
+
fundingPerUnit,
|
|
19
|
+
timestamp: event.timestamp
|
|
20
|
+
});
|
|
21
|
+
return entries;
|
|
22
|
+
}, []);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { toFundingPaymentEntries };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { toFundingPaymentEntries } from './funding';
|
|
3
|
+
|
|
4
|
+
describe("Lighter funding mapping", () => {
|
|
5
|
+
it("derives funding per unit against signed long and short exposure", () => {
|
|
6
|
+
const result = toFundingPaymentEntries(
|
|
7
|
+
[
|
|
8
|
+
fundingEvent({ funding_id: 1, position_side: "long", change: "-2" }),
|
|
9
|
+
fundingEvent({ funding_id: 2, position_side: "short", change: "2" })
|
|
10
|
+
],
|
|
11
|
+
/* @__PURE__ */ new Set(["1"])
|
|
12
|
+
);
|
|
13
|
+
expect(result).toEqual([
|
|
14
|
+
{
|
|
15
|
+
connector: "lighter",
|
|
16
|
+
instrumentId: "1",
|
|
17
|
+
fundingRate: "0.0001",
|
|
18
|
+
notional: "-2",
|
|
19
|
+
fundingPerUnit: "-0.5",
|
|
20
|
+
timestamp: 1e3
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
connector: "lighter",
|
|
24
|
+
instrumentId: "1",
|
|
25
|
+
fundingRate: "0.0001",
|
|
26
|
+
notional: "2",
|
|
27
|
+
fundingPerUnit: "-0.5",
|
|
28
|
+
timestamp: 1e3
|
|
29
|
+
}
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
it("skips funding for unknown instruments", () => {
|
|
33
|
+
expect(toFundingPaymentEntries([fundingEvent({})], /* @__PURE__ */ new Set())).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
function fundingEvent(overrides) {
|
|
37
|
+
return {
|
|
38
|
+
timestamp: 1e3,
|
|
39
|
+
market_id: 1,
|
|
40
|
+
funding_id: 1,
|
|
41
|
+
change: "-2",
|
|
42
|
+
discount: "0",
|
|
43
|
+
rate: "0.0001",
|
|
44
|
+
position_size: "4",
|
|
45
|
+
position_side: "long",
|
|
46
|
+
...overrides
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { fetchAccount, fetchTradesSince } from '../../client/lighter/rest';
|
|
2
|
+
import { fetchAccount, fetchTradesSince, fetchPositionFundingSince } from '../../client/lighter/rest';
|
|
3
3
|
import { BaseAccountSyncer } from '../base';
|
|
4
|
+
import { toFundingPaymentEntries } from './funding';
|
|
4
5
|
|
|
5
6
|
const LIGHTER_FEE_TICK_DENOMINATOR = new BigNumber(1e6);
|
|
6
7
|
class LighterAccountSyncer extends BaseAccountSyncer {
|
|
@@ -20,7 +21,7 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
20
21
|
const contractSizeByInstrumentId = new Map(
|
|
21
22
|
instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
|
|
22
23
|
);
|
|
23
|
-
const response = await fetchAccount(this.accountIndex);
|
|
24
|
+
const response = await fetchAccount(this.accountIndex, this.demo);
|
|
24
25
|
const rawPositions = response.accounts?.[0]?.positions ?? [];
|
|
25
26
|
return rawPositions.reduce((positions, rawPosition) => {
|
|
26
27
|
const instrumentId = rawPosition.market_id.toString();
|
|
@@ -29,12 +30,13 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
29
30
|
const contractSize = contractSizeByInstrumentId.get(instrumentId) ?? 1;
|
|
30
31
|
const size = signedSize.abs().times(contractSize).toFixed();
|
|
31
32
|
const side = rawPosition.sign === 1 ? "BUY" : "SELL";
|
|
33
|
+
const markPrice = new BigNumber(rawPosition.position_value).abs().div(size).toFixed();
|
|
32
34
|
positions.push({
|
|
33
35
|
symbol: instrumentId,
|
|
34
36
|
size,
|
|
35
37
|
side,
|
|
36
38
|
entryPrice: rawPosition.avg_entry_price,
|
|
37
|
-
markPrice
|
|
39
|
+
markPrice
|
|
38
40
|
});
|
|
39
41
|
return positions;
|
|
40
42
|
}, []);
|
|
@@ -43,12 +45,13 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
43
45
|
since,
|
|
44
46
|
instruments
|
|
45
47
|
}) {
|
|
48
|
+
this.assertAuthenticatedSyncAvailable();
|
|
46
49
|
const { token } = await this.sdk.core.misc.lighter.createAuthToken({
|
|
47
50
|
accountIndex: this.accountIndex,
|
|
48
51
|
apiKeyIndex: this.apiKeyIndex,
|
|
49
52
|
apiPrivateKey: this.apiPrivateKey
|
|
50
53
|
});
|
|
51
|
-
const trades = await fetchTradesSince(this.accountIndex, token, since);
|
|
54
|
+
const trades = await fetchTradesSince(this.accountIndex, token, since, this.demo);
|
|
52
55
|
const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
|
|
53
56
|
return trades.toSorted((a, b) => a.timestamp - b.timestamp).reduce((fills, rawFill) => {
|
|
54
57
|
const instrumentId = rawFill.market_id.toString();
|
|
@@ -76,8 +79,19 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
76
79
|
return fills;
|
|
77
80
|
}, []);
|
|
78
81
|
}
|
|
79
|
-
async listExternalFunding(
|
|
80
|
-
|
|
82
|
+
async listExternalFunding({
|
|
83
|
+
since,
|
|
84
|
+
instruments
|
|
85
|
+
}) {
|
|
86
|
+
this.assertAuthenticatedSyncAvailable();
|
|
87
|
+
const { token } = await this.sdk.core.misc.lighter.createAuthToken({
|
|
88
|
+
accountIndex: this.accountIndex,
|
|
89
|
+
apiKeyIndex: this.apiKeyIndex,
|
|
90
|
+
apiPrivateKey: this.apiPrivateKey
|
|
91
|
+
});
|
|
92
|
+
const fundingEvents = await fetchPositionFundingSince(this.accountIndex, token, since, this.demo);
|
|
93
|
+
const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
|
|
94
|
+
return toFundingPaymentEntries(fundingEvents, instrumentIds);
|
|
81
95
|
}
|
|
82
96
|
isReduceOnly(rawFill) {
|
|
83
97
|
if (rawFill.type === "liquidation" || rawFill.type === "deleverage" || rawFill.type === "market-settlement") {
|
|
@@ -87,6 +101,10 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
87
101
|
if (!accountSide) {
|
|
88
102
|
return false;
|
|
89
103
|
}
|
|
104
|
+
const positionSignChanged = this.isMaker(rawFill, accountSide) ? rawFill.maker_position_sign_changed : rawFill.taker_position_sign_changed;
|
|
105
|
+
if (positionSignChanged) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
90
108
|
const positionSizeBefore = this.getPositionSizeBefore(rawFill, accountSide);
|
|
91
109
|
if (positionSizeBefore.isZero()) {
|
|
92
110
|
return false;
|
|
@@ -116,6 +134,12 @@ class LighterAccountSyncer extends BaseAccountSyncer {
|
|
|
116
134
|
const rateTick = this.isMaker(rawFill, side) ? rawFill.integrator_maker_fee : rawFill.integrator_taker_fee;
|
|
117
135
|
return this.feeTickToAmount(rateTick, rawFill.usd_amount);
|
|
118
136
|
}
|
|
137
|
+
/** Prevents testnet reads from silently minting a mainnet auth token. */
|
|
138
|
+
assertAuthenticatedSyncAvailable() {
|
|
139
|
+
if (this.demo) {
|
|
140
|
+
throw new Error("Lighter demo authenticated sync is unavailable because backend signing is mainnet-only");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
119
143
|
// Lighter fees come as ppm rates (÷1e6), not amounts — the charged fee is that rate times the trade's USD notional.
|
|
120
144
|
feeTickToAmount(rateTick, usdAmount) {
|
|
121
145
|
const rate = new BigNumber(rateTick ?? 0).abs().div(LIGHTER_FEE_TICK_DENOMINATOR);
|
|
@@ -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
|
});
|
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);
|