@pear-protocol/exchanges-sdk 0.13.0 → 0.14.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 +1 -0
- package/dist/index.d.ts +57 -20
- package/dist/tracker/binance/mapper.js +2 -0
- package/dist/tracker/binance/tracker.js +2 -0
- package/dist/tracker/bybit/mapper.js +2 -0
- package/dist/tracker/hyperliquid/balance.js +16 -0
- package/dist/tracker/hyperliquid/mapper.js +11 -0
- package/dist/tracker/lighter/balance.js +16 -0
- package/dist/tracker/lighter/mapper.js +21 -0
- package/dist/tracker/lighter/tracker.js +65 -38
- package/dist/tracker/okx/mapper.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -291,6 +291,7 @@ tracker.trackPosition((positions) => {
|
|
|
291
291
|
p.size;
|
|
292
292
|
p.entryPrice;
|
|
293
293
|
p.unrealizedPnl;
|
|
294
|
+
p.marginUsed; // string | null — null where the venue publishes none
|
|
294
295
|
p.leverage;
|
|
295
296
|
p.marginType; // "cross" | "isolated"
|
|
296
297
|
p.liquidationPrice; // string | null
|
package/dist/index.d.ts
CHANGED
|
@@ -1245,6 +1245,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1245
1245
|
stakedEquity: z.ZodNullable<z.ZodString>;
|
|
1246
1246
|
unrealizedPnl: z.ZodString;
|
|
1247
1247
|
maintenanceMargin: z.ZodString;
|
|
1248
|
+
marginUsed: z.ZodString;
|
|
1248
1249
|
crossMarginRatio: z.ZodString;
|
|
1249
1250
|
crossAccountLeverage: z.ZodString;
|
|
1250
1251
|
perDexState: z.ZodArray<z.ZodObject<{
|
|
@@ -1274,6 +1275,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1274
1275
|
portfolioValue: z.ZodNullable<z.ZodString>;
|
|
1275
1276
|
unrealizedPnl: z.ZodString;
|
|
1276
1277
|
perpsMaintenanceMargin: z.ZodString;
|
|
1278
|
+
marginUsed: z.ZodString;
|
|
1277
1279
|
unifiedAccountRatio: z.ZodString;
|
|
1278
1280
|
unifiedAccountLeverage: z.ZodNullable<z.ZodString>;
|
|
1279
1281
|
perDexState: z.ZodArray<z.ZodObject<{
|
|
@@ -1305,6 +1307,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1305
1307
|
portfolioValue: z.ZodNullable<z.ZodString>;
|
|
1306
1308
|
unrealizedPnl: z.ZodString;
|
|
1307
1309
|
perpsMaintenanceMargin: z.ZodString;
|
|
1310
|
+
marginUsed: z.ZodString;
|
|
1308
1311
|
portfolioMarginRatio: z.ZodNullable<z.ZodString>;
|
|
1309
1312
|
portfolioAccountLeverage: z.ZodNullable<z.ZodString>;
|
|
1310
1313
|
borrowCapUsed: z.ZodNullable<z.ZodString>;
|
|
@@ -1335,6 +1338,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1335
1338
|
stakingEquity: z.ZodNullable<z.ZodString>;
|
|
1336
1339
|
creditedInLit: z.ZodNullable<z.ZodString>;
|
|
1337
1340
|
unrealizedPnl: z.ZodNullable<z.ZodString>;
|
|
1341
|
+
marginUsed: z.ZodNullable<z.ZodString>;
|
|
1338
1342
|
crossLeverage: z.ZodNullable<z.ZodString>;
|
|
1339
1343
|
maintenanceMargin: z.ZodNullable<z.ZodString>;
|
|
1340
1344
|
crossMarginUsage: z.ZodNullable<z.ZodString>;
|
|
@@ -1360,6 +1364,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1360
1364
|
stakingEquity: z.ZodNullable<z.ZodString>;
|
|
1361
1365
|
creditedInLit: z.ZodNullable<z.ZodString>;
|
|
1362
1366
|
unrealizedPnl: z.ZodNullable<z.ZodString>;
|
|
1367
|
+
marginUsed: z.ZodNullable<z.ZodString>;
|
|
1363
1368
|
crossLeverage: z.ZodNullable<z.ZodString>;
|
|
1364
1369
|
maintenanceMargin: z.ZodNullable<z.ZodString>;
|
|
1365
1370
|
crossMarginUsage: z.ZodNullable<z.ZodString>;
|
|
@@ -1566,6 +1571,20 @@ interface AccountPosition {
|
|
|
1566
1571
|
size: string;
|
|
1567
1572
|
entryPrice: string;
|
|
1568
1573
|
unrealizedPnl: string;
|
|
1574
|
+
/**
|
|
1575
|
+
* The margin this one position holds, in the account's quote currency.
|
|
1576
|
+
*
|
|
1577
|
+
* The meaning is the venue's own, and the two margin modes state different things: an isolated
|
|
1578
|
+
* leg holds collateral of its own, while a cross position holds the initial margin its market
|
|
1579
|
+
* demands out of the shared pool.
|
|
1580
|
+
*
|
|
1581
|
+
* `null` where the venue publishes no per-position figure and none can be derived from what it
|
|
1582
|
+
* does publish — the margin is then unknown, which is not the same as a position holding none.
|
|
1583
|
+
*
|
|
1584
|
+
* The currency is the one that position's own venue settles in, and no field states which: a
|
|
1585
|
+
* Hyperliquid position on a non-USDC HIP-3 dex holds margin in that dex's collateral token.
|
|
1586
|
+
*/
|
|
1587
|
+
marginUsed: string | null;
|
|
1569
1588
|
leverage: string;
|
|
1570
1589
|
marginType: MarginMode;
|
|
1571
1590
|
liquidationPrice: string | null;
|
|
@@ -1953,6 +1972,7 @@ declare const HyperliquidStandardBalance: z.ZodObject<{
|
|
|
1953
1972
|
stakedEquity: z.ZodNullable<z.ZodString>;
|
|
1954
1973
|
unrealizedPnl: z.ZodString;
|
|
1955
1974
|
maintenanceMargin: z.ZodString;
|
|
1975
|
+
marginUsed: z.ZodString;
|
|
1956
1976
|
crossMarginRatio: z.ZodString;
|
|
1957
1977
|
crossAccountLeverage: z.ZodString;
|
|
1958
1978
|
perDexState: z.ZodArray<z.ZodObject<{
|
|
@@ -1984,6 +2004,7 @@ declare const HyperliquidUnifiedBalance: z.ZodObject<{
|
|
|
1984
2004
|
portfolioValue: z.ZodNullable<z.ZodString>;
|
|
1985
2005
|
unrealizedPnl: z.ZodString;
|
|
1986
2006
|
perpsMaintenanceMargin: z.ZodString;
|
|
2007
|
+
marginUsed: z.ZodString;
|
|
1987
2008
|
unifiedAccountRatio: z.ZodString;
|
|
1988
2009
|
unifiedAccountLeverage: z.ZodNullable<z.ZodString>;
|
|
1989
2010
|
perDexState: z.ZodArray<z.ZodObject<{
|
|
@@ -2043,6 +2064,7 @@ declare const HyperliquidPortfolioMarginBalance: z.ZodObject<{
|
|
|
2043
2064
|
portfolioValue: z.ZodNullable<z.ZodString>;
|
|
2044
2065
|
unrealizedPnl: z.ZodString;
|
|
2045
2066
|
perpsMaintenanceMargin: z.ZodString;
|
|
2067
|
+
marginUsed: z.ZodString;
|
|
2046
2068
|
portfolioMarginRatio: z.ZodNullable<z.ZodString>;
|
|
2047
2069
|
portfolioAccountLeverage: z.ZodNullable<z.ZodString>;
|
|
2048
2070
|
borrowCapUsed: z.ZodNullable<z.ZodString>;
|
|
@@ -2177,6 +2199,7 @@ declare const LighterClassicBalance: z.ZodObject<{
|
|
|
2177
2199
|
stakingEquity: z.ZodNullable<z.ZodString>;
|
|
2178
2200
|
creditedInLit: z.ZodNullable<z.ZodString>;
|
|
2179
2201
|
unrealizedPnl: z.ZodNullable<z.ZodString>;
|
|
2202
|
+
marginUsed: z.ZodNullable<z.ZodString>;
|
|
2180
2203
|
crossLeverage: z.ZodNullable<z.ZodString>;
|
|
2181
2204
|
maintenanceMargin: z.ZodNullable<z.ZodString>;
|
|
2182
2205
|
crossMarginUsage: z.ZodNullable<z.ZodString>;
|
|
@@ -2204,6 +2227,7 @@ declare const LighterUnifiedBalance: z.ZodObject<{
|
|
|
2204
2227
|
stakingEquity: z.ZodNullable<z.ZodString>;
|
|
2205
2228
|
creditedInLit: z.ZodNullable<z.ZodString>;
|
|
2206
2229
|
unrealizedPnl: z.ZodNullable<z.ZodString>;
|
|
2230
|
+
marginUsed: z.ZodNullable<z.ZodString>;
|
|
2207
2231
|
crossLeverage: z.ZodNullable<z.ZodString>;
|
|
2208
2232
|
maintenanceMargin: z.ZodNullable<z.ZodString>;
|
|
2209
2233
|
crossMarginUsage: z.ZodNullable<z.ZodString>;
|
|
@@ -2236,9 +2260,9 @@ declare class LighterAccountTracker extends BaseAccountTracker {
|
|
|
2236
2260
|
private startInFlight;
|
|
2237
2261
|
private lifecycleVersion;
|
|
2238
2262
|
private wsConnectionVersion;
|
|
2239
|
-
private
|
|
2263
|
+
private positionStateVersion;
|
|
2240
2264
|
private assetsByAssetId;
|
|
2241
|
-
private
|
|
2265
|
+
private positionsByMarketId;
|
|
2242
2266
|
private marketsByMarketId;
|
|
2243
2267
|
private marketsBySymbol;
|
|
2244
2268
|
private spotPriceBySymbol;
|
|
@@ -2287,10 +2311,10 @@ declare class LighterAccountTracker extends BaseAccountTracker {
|
|
|
2287
2311
|
/**
|
|
2288
2312
|
* Subscribes this socket epoch's account channels, and keeps trying until one attempt lands.
|
|
2289
2313
|
*
|
|
2290
|
-
* This is the sole subscriber of `account_all` and `user_stats`, and
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2314
|
+
* This is the sole subscriber of `account_all` and `user_stats`, and no balance exists without
|
|
2315
|
+
* both — see `buildBalance`. A mint that failed once and was never retried therefore left the
|
|
2316
|
+
* tracker connected but permanently unsubscribed: the 2s REST poll kept positions moving while
|
|
2317
|
+
* the balance stayed null until some incidental reconnect.
|
|
2294
2318
|
*
|
|
2295
2319
|
* The wait doubles up to a ceiling, and only a new connection epoch or teardown ends it.
|
|
2296
2320
|
*/
|
|
@@ -2305,13 +2329,15 @@ declare class LighterAccountTracker extends BaseAccountTracker {
|
|
|
2305
2329
|
/** Keeps both published scopes: cross drives every "Cross" field, total drives the account ones. */
|
|
2306
2330
|
private onUserStats;
|
|
2307
2331
|
/**
|
|
2308
|
-
* The account's balance,
|
|
2332
|
+
* The account's balance, from the freshest state each of its parts has.
|
|
2333
|
+
*
|
|
2334
|
+
* The two account scopes come from `user_stats` and the assets from `account_all`, both
|
|
2335
|
+
* websocket-only. The positions come from whichever source read the account last — a frame, or
|
|
2336
|
+
* the REST poll that keeps them moving while the socket says nothing (see
|
|
2337
|
+
* `performPositionPoll`). The market context is venue-wide metadata on its own timer.
|
|
2309
2338
|
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
* tracker re-reads on its own timer. This is the ONLY producer of the value `getBalance()`
|
|
2313
|
-
* returns while the tracker is running; `snapshot()` is the separate REST path and never writes
|
|
2314
|
-
* here.
|
|
2339
|
+
* This is the ONLY producer of the value `getBalance()` returns while the tracker is running;
|
|
2340
|
+
* `snapshot()` is the separate one-shot path and never writes here.
|
|
2315
2341
|
*
|
|
2316
2342
|
* Null until both channels have delivered a frame, because a balance built from one of them
|
|
2317
2343
|
* would be missing half the account.
|
|
@@ -2329,16 +2355,27 @@ declare class LighterAccountTracker extends BaseAccountTracker {
|
|
|
2329
2355
|
private retrieveRequiredAuthToken;
|
|
2330
2356
|
private pollPositions;
|
|
2331
2357
|
/**
|
|
2332
|
-
* Republishes positions
|
|
2358
|
+
* Republishes positions, per-market settings, and the balance those positions move.
|
|
2359
|
+
*
|
|
2360
|
+
* This is what keeps the account's open result alive. Lighter republishes `account_all` when the
|
|
2361
|
+
* account itself changes and not when the mark moves, so between fills the position rows hold
|
|
2362
|
+
* whatever the last frame left them at. A balance built on those rows stated a dead unrealized
|
|
2363
|
+
* PnL and a dead maintenance margin beside the live `user_stats` figures — and restated them on
|
|
2364
|
+
* every frame under a current timestamp, so nothing marked them as old. The venue's own account
|
|
2365
|
+
* read is the only source that carries the new figures, so the poll adopts it wholesale.
|
|
2366
|
+
*
|
|
2367
|
+
* The rows land in the same store `account_all` writes, which is what keeps `getPositions()` and
|
|
2368
|
+
* the balance's `unrealizedPnl` stating one account rather than two.
|
|
2333
2369
|
*
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2370
|
+
* A read is dropped whole when a frame moved the position state while it was in flight: that
|
|
2371
|
+
* frame is the newer account. `user_stats` deliberately does not invalidate one — it carries the
|
|
2372
|
+
* account scopes and no positions at all, and a venue publishing it every tick would otherwise
|
|
2373
|
+
* cancel every poll and freeze the very figures this read exists to move.
|
|
2338
2374
|
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
2341
|
-
*
|
|
2375
|
+
* No balance leaves here while the socket is down. The account scopes and the assets are dropped
|
|
2376
|
+
* on the next open rather than on the drop, so a balance published mid-outage would pair these
|
|
2377
|
+
* fresh positions with figures frozen at the drop — worst of it a cross margin ratio measured
|
|
2378
|
+
* against a portfolio value the account no longer has. Positions still publish: they are whole.
|
|
2342
2379
|
*/
|
|
2343
2380
|
private performPositionPoll;
|
|
2344
2381
|
/**
|
|
@@ -54,6 +54,8 @@ function mapPositions(raw) {
|
|
|
54
54
|
size: amt,
|
|
55
55
|
entryPrice: p.entryPrice ?? "0",
|
|
56
56
|
unrealizedPnl: p.unRealizedProfit ?? "0",
|
|
57
|
+
// Binance states `initialMargin` per position; mapping it is left to its own change.
|
|
58
|
+
marginUsed: null,
|
|
57
59
|
leverage: p.leverage ?? "0",
|
|
58
60
|
marginType: p.marginType,
|
|
59
61
|
liquidationPrice: p.liquidationPrice || null
|
|
@@ -214,6 +214,8 @@ class BinanceAccountTracker extends BaseAccountTracker {
|
|
|
214
214
|
size: amt,
|
|
215
215
|
entryPrice: risk.entryPrice ?? "0",
|
|
216
216
|
unrealizedPnl: risk.unRealizedProfit ?? "0",
|
|
217
|
+
// Mapped nowhere on Binance yet — see `mapPositions`.
|
|
218
|
+
marginUsed: null,
|
|
217
219
|
leverage: risk.leverage ?? "0",
|
|
218
220
|
marginType: risk.marginType,
|
|
219
221
|
liquidationPrice: risk.liquidationPrice || null
|
|
@@ -71,6 +71,8 @@ function mapSinglePosition(p, size) {
|
|
|
71
71
|
size: size === "0" || size === "" ? "0" : side === "short" ? `-${size}` : size,
|
|
72
72
|
entryPrice: p.avgPrice ?? "0",
|
|
73
73
|
unrealizedPnl: p.unrealisedPnl ?? "0",
|
|
74
|
+
// Bybit states `positionIM` per position; mapping it is left to its own change.
|
|
75
|
+
marginUsed: null,
|
|
74
76
|
leverage: p.leverage ?? "0",
|
|
75
77
|
marginType: p.tradeMode === "1" ? "isolated" : "cross",
|
|
76
78
|
liquidationPrice: p.liqPrice || null
|
|
@@ -46,6 +46,18 @@ const HyperliquidStandardBalance = AccountBalanceCore.extend({
|
|
|
46
46
|
unrealizedPnl: SignedDecimalString,
|
|
47
47
|
maintenanceMargin: DecimalString,
|
|
48
48
|
/**
|
|
49
|
+
* Margin held across every open position, both margin modes together.
|
|
50
|
+
*
|
|
51
|
+
* Read from the positions, never derived: `positionValue / leverage` reproduces a cross
|
|
52
|
+
* position's margin exactly, and misses an isolated one — measured across 683 live positions, all
|
|
53
|
+
* 662 cross matched and all 21 isolated drifted, because an isolated leg can be topped up and
|
|
54
|
+
* accrues its own PnL. The venue states each position's figure outright, so this sums those.
|
|
55
|
+
*
|
|
56
|
+
* Adds across collateral tokens, so an account holding a non-USDC HIP-3 dex reads a total in no
|
|
57
|
+
* single currency — the same limit `accountValue` and `unrealizedPnl` carry on this variant.
|
|
58
|
+
*/
|
|
59
|
+
marginUsed: DecimalString,
|
|
60
|
+
/**
|
|
49
61
|
* The cross pool's own utilisation: Σ `crossMaintenanceMarginUsed` over Σ `crossMarginSummary
|
|
50
62
|
* .accountValue`, aggregated across every dex before the single division.
|
|
51
63
|
*
|
|
@@ -90,6 +102,8 @@ const HyperliquidUnifiedBalance = AccountBalanceCore.extend({
|
|
|
90
102
|
unrealizedPnl: SignedDecimalString,
|
|
91
103
|
/** Cross maintenance only — isolated legs carry their own margin. */
|
|
92
104
|
perpsMaintenanceMargin: DecimalString,
|
|
105
|
+
/** Margin held across every open position, both modes — read per position, never derived. */
|
|
106
|
+
marginUsed: DecimalString,
|
|
93
107
|
unifiedAccountRatio: DecimalString,
|
|
94
108
|
/**
|
|
95
109
|
* Cross notional over the COLLATERAL backing it — the spot holdings of the dex collateral tokens,
|
|
@@ -158,6 +172,8 @@ const HyperliquidPortfolioMarginBalance = AccountBalanceCore.extend({
|
|
|
158
172
|
portfolioValue: DecimalString.nullable(),
|
|
159
173
|
unrealizedPnl: SignedDecimalString,
|
|
160
174
|
perpsMaintenanceMargin: DecimalString,
|
|
175
|
+
/** Margin held across every open position, both modes — read per position, never derived. */
|
|
176
|
+
marginUsed: DecimalString,
|
|
161
177
|
/**
|
|
162
178
|
* The venue's own `spotClearinghouseState.portfolioMarginRatio`, clipped at `1.0000` by the
|
|
163
179
|
* venue itself. Null when the wire states none.
|
|
@@ -29,12 +29,14 @@ function aggregateClearinghouseStates(states) {
|
|
|
29
29
|
position.position.marginUsed
|
|
30
30
|
];
|
|
31
31
|
})).toFixed();
|
|
32
|
+
const marginUsed = sum(positions.map((position) => position.position.marginUsed)).toFixed();
|
|
32
33
|
summary.accountValue = add(summary.accountValue, state.marginSummary.accountValue);
|
|
33
34
|
summary.crossAccountValue = add(summary.crossAccountValue, state.crossMarginSummary.accountValue);
|
|
34
35
|
summary.crossNotional = add(summary.crossNotional, state.crossMarginSummary.totalNtlPos);
|
|
35
36
|
summary.crossMaintenanceMarginUsed = add(summary.crossMaintenanceMarginUsed, state.crossMaintenanceMarginUsed);
|
|
36
37
|
summary.withdrawable = add(summary.withdrawable, state.withdrawable);
|
|
37
38
|
summary.totalUnrealizedPnl = add(summary.totalUnrealizedPnl, unrealizedPnl);
|
|
39
|
+
summary.totalMarginUsed = add(summary.totalMarginUsed, marginUsed);
|
|
38
40
|
assetPositions.push(...positions);
|
|
39
41
|
summary.perDexState.push({
|
|
40
42
|
dex: dexState.dex,
|
|
@@ -77,6 +79,7 @@ function emptyClearinghouseSummary() {
|
|
|
77
79
|
crossMaintenanceMarginUsed: "0",
|
|
78
80
|
withdrawable: "0",
|
|
79
81
|
totalUnrealizedPnl: "0",
|
|
82
|
+
totalMarginUsed: "0",
|
|
80
83
|
byCollateralToken: /* @__PURE__ */ new Map(),
|
|
81
84
|
perDexState: []
|
|
82
85
|
};
|
|
@@ -115,6 +118,8 @@ function mapPositions(raw) {
|
|
|
115
118
|
size: size.toFixed(),
|
|
116
119
|
entryPrice: position.entryPx || "0",
|
|
117
120
|
unrealizedPnl: position.unrealizedPnl || "0",
|
|
121
|
+
// Read, never derived: an isolated leg can be topped up, so its margin outruns value/leverage.
|
|
122
|
+
marginUsed: position.marginUsed || null,
|
|
118
123
|
leverage: position.leverage.value.toString(),
|
|
119
124
|
marginType: position.leverage.type,
|
|
120
125
|
liquidationPrice: position.liquidationPx || null
|
|
@@ -157,6 +162,8 @@ function mapStandardBalance(context) {
|
|
|
157
162
|
unrealizedPnl: signed(context.summary.totalUnrealizedPnl),
|
|
158
163
|
// Cross-only: isolated legs carry their own margin and liquidate independently.
|
|
159
164
|
maintenanceMargin: context.summary.crossMaintenanceMarginUsed,
|
|
165
|
+
// Both modes, unlike the maintenance figure above: every position's margin is margin committed.
|
|
166
|
+
marginUsed: context.summary.totalMarginUsed,
|
|
160
167
|
// Risk levers against ACCOUNT VALUE, not the cash above: one pool, one quotient, one division.
|
|
161
168
|
crossMarginRatio: ratio(context.summary.crossMaintenanceMarginUsed, context.summary.crossAccountValue),
|
|
162
169
|
crossAccountLeverage: accountLeverage(context.summary.crossNotional, context.summary.crossAccountValue),
|
|
@@ -189,6 +196,8 @@ function mapUnifiedBalance(context) {
|
|
|
189
196
|
unrealizedPnl: signed(context.summary.totalUnrealizedPnl),
|
|
190
197
|
// Cross-only: adding isolated maintenance overstated the figure by 2x against the venue's UI.
|
|
191
198
|
perpsMaintenanceMargin: context.summary.crossMaintenanceMarginUsed,
|
|
199
|
+
// Both modes, unlike the maintenance figure above: every position's margin is margin committed.
|
|
200
|
+
marginUsed: context.summary.totalMarginUsed,
|
|
192
201
|
unifiedAccountRatio: crossRatioAgainstFreeSpot(context),
|
|
193
202
|
unifiedAccountLeverage: unifiedAccountLeverage(context),
|
|
194
203
|
perDexState: context.summary.perDexState,
|
|
@@ -218,6 +227,8 @@ function mapPortfolioMarginBalance(context) {
|
|
|
218
227
|
portfolioValue: tradingEquity,
|
|
219
228
|
unrealizedPnl: signed(context.summary.totalUnrealizedPnl),
|
|
220
229
|
perpsMaintenanceMargin: context.summary.crossMaintenanceMarginUsed,
|
|
230
|
+
// Both modes, unlike the maintenance figure above: every position's margin is margin committed.
|
|
231
|
+
marginUsed: context.summary.totalMarginUsed,
|
|
221
232
|
portfolioMarginRatio: wireMarginRatio(context),
|
|
222
233
|
portfolioAccountLeverage: portfolioAccountLeverage(context),
|
|
223
234
|
borrowCapUsed: worstBorrowCapUsed(context),
|
|
@@ -34,6 +34,22 @@ const lighterShared = {
|
|
|
34
34
|
*/
|
|
35
35
|
unrealizedPnl: SignedDecimalString.nullable(),
|
|
36
36
|
/**
|
|
37
|
+
* Margin held across every open position, both margin modes together.
|
|
38
|
+
*
|
|
39
|
+
* Summed from the positions, because the venue publishes no one field for it: `/account` states
|
|
40
|
+
* the cross requirement alone and `user_stats` states neither. Over cross positions the sum
|
|
41
|
+
* reproduces the venue's own `cross_initial_margin_requirement` exactly — rounded half-up at six
|
|
42
|
+
* decimals, the way the venue states a requirement — and an isolated leg adds the collateral it
|
|
43
|
+
* holds on top, the same both-modes reading `maintenanceMargin` takes.
|
|
44
|
+
*
|
|
45
|
+
* Rounded once, over the exact total: the per-position figures round individually, so adding
|
|
46
|
+
* those up can miss this by a decimal's last place. This is the figure that matches the venue.
|
|
47
|
+
*
|
|
48
|
+
* Null when ANY position's own margin is unreadable, for the reason `unrealizedPnl` is: a total
|
|
49
|
+
* missing one term is not a smaller version of the answer.
|
|
50
|
+
*/
|
|
51
|
+
marginUsed: DecimalString.nullable(),
|
|
52
|
+
/**
|
|
37
53
|
* Cross exposure over cross equity.
|
|
38
54
|
*
|
|
39
55
|
* A MULTIPLE, not a ratio: `0.72` reads 0.72x, and the venue publishes it that way in both
|
|
@@ -28,6 +28,7 @@ function mapLiveBalance(stats, assets, positions, markets, timestamp) {
|
|
|
28
28
|
equity,
|
|
29
29
|
perCollateral,
|
|
30
30
|
unrealizedPnl: signed(sumUnrealizedPnl(openPositions)),
|
|
31
|
+
marginUsed: usdOrNull(asMarginRequirement(sumMarginUsed(risks))),
|
|
31
32
|
// The venue states leverage and usage outright here and REST derives them, so each pair meets
|
|
32
33
|
// at one formatter — otherwise the paths would agree only while the venue kept its precision.
|
|
33
34
|
// Usage arrives in percent points and leaves as a fraction, which is the venue's scale changed.
|
|
@@ -57,6 +58,7 @@ function mapRestBalance(account, markets, timestamp) {
|
|
|
57
58
|
equity,
|
|
58
59
|
perCollateral,
|
|
59
60
|
unrealizedPnl: signed(sumUnrealizedPnl(positions)),
|
|
61
|
+
marginUsed: usdOrNull(asMarginRequirement(sumMarginUsed(risks))),
|
|
60
62
|
crossLeverage: multipleOf(sumNotional(risks, "cross"), crossEquity),
|
|
61
63
|
crossMarginUsage: fractionOf(toDecimal(account.cross_initial_margin_requirement), crossEquity),
|
|
62
64
|
crossMarginRatio: fractionOf(crossMaintenance, crossEquity),
|
|
@@ -91,6 +93,7 @@ function mapPositions(raw, includeZeroPositions = false) {
|
|
|
91
93
|
size: size.isFinite() && !size.isZero() ? size.toFixed() : "0",
|
|
92
94
|
entryPrice: rawPosition.avg_entry_price || "0",
|
|
93
95
|
unrealizedPnl: rawPosition.unrealized_pnl || "0",
|
|
96
|
+
marginUsed: usdOrNull(asMarginRequirement(positionMarginUsed(rawPosition))),
|
|
94
97
|
leverage,
|
|
95
98
|
marginType,
|
|
96
99
|
liquidationPrice: rawPosition.liquidation_price && rawPosition.liquidation_price !== "0" ? rawPosition.liquidation_price : null
|
|
@@ -181,6 +184,7 @@ function toLighterBalance(figures) {
|
|
|
181
184
|
stakingEquity: null,
|
|
182
185
|
creditedInLit: null,
|
|
183
186
|
unrealizedPnl: figures.unrealizedPnl,
|
|
187
|
+
marginUsed: figures.marginUsed,
|
|
184
188
|
crossLeverage: figures.crossLeverage,
|
|
185
189
|
maintenanceMargin: figures.maintenanceMargin,
|
|
186
190
|
crossMarginUsage: figures.crossMarginUsage,
|
|
@@ -301,12 +305,29 @@ function toPositionRisks(positions, perpByMarketId) {
|
|
|
301
305
|
risks.push({
|
|
302
306
|
marginMode: position.margin_mode === 1 ? "isolated" : "cross",
|
|
303
307
|
notional,
|
|
308
|
+
marginUsed: positionMarginUsed(position),
|
|
304
309
|
maintenance: notional === null || maintenanceFraction === null ? null : notional.times(maintenanceFraction).div(MARGIN_FRACTION_SCALE)
|
|
305
310
|
});
|
|
306
311
|
}
|
|
307
312
|
return risks;
|
|
308
313
|
}
|
|
309
314
|
__name(toPositionRisks, "toPositionRisks");
|
|
315
|
+
function positionMarginUsed(position) {
|
|
316
|
+
if (position.margin_mode === 1) {
|
|
317
|
+
return toDecimal(position.allocated_margin);
|
|
318
|
+
}
|
|
319
|
+
const positionValue = toDecimal(position.position_value);
|
|
320
|
+
const initialMarginFraction = toDecimal(position.initial_margin_fraction);
|
|
321
|
+
if (positionValue === null || initialMarginFraction === null || initialMarginFraction.lte(0)) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return positionValue.abs().times(initialMarginFraction).div(PERCENT_SCALE);
|
|
325
|
+
}
|
|
326
|
+
__name(positionMarginUsed, "positionMarginUsed");
|
|
327
|
+
function sumMarginUsed(risks) {
|
|
328
|
+
return sumOf(risks.map((risk) => risk.marginUsed));
|
|
329
|
+
}
|
|
330
|
+
__name(sumMarginUsed, "sumMarginUsed");
|
|
310
331
|
function sumMaintenance(risks, marginMode) {
|
|
311
332
|
return sumOf(risks.filter((risk) => risk.marginMode === marginMode).map((risk) => risk.maintenance));
|
|
312
333
|
}
|
|
@@ -46,11 +46,13 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
46
46
|
startInFlight = null;
|
|
47
47
|
lifecycleVersion = 0;
|
|
48
48
|
wsConnectionVersion = 0;
|
|
49
|
-
|
|
49
|
+
// Bumped only where position state actually moves, so an in-flight REST read is invalidated by
|
|
50
|
+
// a frame that carries positions and never by one that carries the account figures alone.
|
|
51
|
+
positionStateVersion = 0;
|
|
50
52
|
assetsByAssetId = /* @__PURE__ */ new Map();
|
|
51
|
-
// Written
|
|
52
|
-
//
|
|
53
|
-
|
|
53
|
+
// Written by `account_all` and by the REST poll, whichever read the account last. The live
|
|
54
|
+
// balance takes its unrealized PnL and its maintenance margin from here.
|
|
55
|
+
positionsByMarketId = /* @__PURE__ */ new Map();
|
|
54
56
|
marketsByMarketId = /* @__PURE__ */ new Map();
|
|
55
57
|
marketsBySymbol = /* @__PURE__ */ new Map();
|
|
56
58
|
// Seeded empty, which still prices the quote asset — spot dollars are dollars before any
|
|
@@ -222,9 +224,9 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
222
224
|
handleWsOpen() {
|
|
223
225
|
const wsConnectionVersion = ++this.wsConnectionVersion;
|
|
224
226
|
this.clearSubscribeRetry();
|
|
225
|
-
this.
|
|
227
|
+
this.positionStateVersion += 1;
|
|
226
228
|
this.assetsByAssetId.clear();
|
|
227
|
-
this.
|
|
229
|
+
this.positionsByMarketId.clear();
|
|
228
230
|
this.stats = null;
|
|
229
231
|
this.hasAssets = false;
|
|
230
232
|
this.resetAccountState();
|
|
@@ -240,10 +242,10 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
240
242
|
/**
|
|
241
243
|
* Subscribes this socket epoch's account channels, and keeps trying until one attempt lands.
|
|
242
244
|
*
|
|
243
|
-
* This is the sole subscriber of `account_all` and `user_stats`, and
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
245
|
+
* This is the sole subscriber of `account_all` and `user_stats`, and no balance exists without
|
|
246
|
+
* both — see `buildBalance`. A mint that failed once and was never retried therefore left the
|
|
247
|
+
* tracker connected but permanently unsubscribed: the 2s REST poll kept positions moving while
|
|
248
|
+
* the balance stayed null until some incidental reconnect.
|
|
247
249
|
*
|
|
248
250
|
* The wait doubles up to a ceiling, and only a new connection epoch or teardown ends it.
|
|
249
251
|
*/
|
|
@@ -303,7 +305,7 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
303
305
|
}
|
|
304
306
|
}
|
|
305
307
|
onAccountAll(data) {
|
|
306
|
-
this.
|
|
308
|
+
this.positionStateVersion += 1;
|
|
307
309
|
const update = {};
|
|
308
310
|
const isSnapshot = data.type === "subscribed/account_all";
|
|
309
311
|
if (data.assets) {
|
|
@@ -320,20 +322,20 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
320
322
|
...data.positions
|
|
321
323
|
};
|
|
322
324
|
if (isSnapshot) {
|
|
323
|
-
this.
|
|
325
|
+
this.positionsByMarketId.clear();
|
|
324
326
|
}
|
|
325
327
|
for (const [marketId, position] of Object.entries(data.positions)) {
|
|
326
328
|
if (isZeroSize(position.position)) {
|
|
327
|
-
const previousPosition = this.
|
|
329
|
+
const previousPosition = this.positionsByMarketId.get(marketId);
|
|
328
330
|
if (previousPosition) {
|
|
329
331
|
positionsForMapping[marketId] = {
|
|
330
332
|
...position,
|
|
331
333
|
sign: previousPosition.sign
|
|
332
334
|
};
|
|
333
335
|
}
|
|
334
|
-
this.
|
|
336
|
+
this.positionsByMarketId.delete(marketId);
|
|
335
337
|
} else {
|
|
336
|
-
this.
|
|
338
|
+
this.positionsByMarketId.set(marketId, position);
|
|
337
339
|
}
|
|
338
340
|
}
|
|
339
341
|
const { positions, trackedAssets } = mapPositions(positionsForMapping, !isSnapshot);
|
|
@@ -347,7 +349,6 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
347
349
|
}
|
|
348
350
|
/** Keeps both published scopes: cross drives every "Cross" field, total drives the account ones. */
|
|
349
351
|
onUserStats(data) {
|
|
350
|
-
this.stateVersion += 1;
|
|
351
352
|
const stats = data.stats;
|
|
352
353
|
this.stats = {
|
|
353
354
|
accountType: toLighterAccountType(stats.account_trading_mode),
|
|
@@ -360,20 +361,22 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
360
361
|
});
|
|
361
362
|
}
|
|
362
363
|
/**
|
|
363
|
-
* The account's balance,
|
|
364
|
+
* The account's balance, from the freshest state each of its parts has.
|
|
364
365
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
366
|
+
* The two account scopes come from `user_stats` and the assets from `account_all`, both
|
|
367
|
+
* websocket-only. The positions come from whichever source read the account last — a frame, or
|
|
368
|
+
* the REST poll that keeps them moving while the socket says nothing (see
|
|
369
|
+
* `performPositionPoll`). The market context is venue-wide metadata on its own timer.
|
|
370
|
+
*
|
|
371
|
+
* This is the ONLY producer of the value `getBalance()` returns while the tracker is running;
|
|
372
|
+
* `snapshot()` is the separate one-shot path and never writes here.
|
|
370
373
|
*
|
|
371
374
|
* Null until both channels have delivered a frame, because a balance built from one of them
|
|
372
375
|
* would be missing half the account.
|
|
373
376
|
*/
|
|
374
377
|
buildBalance() {
|
|
375
378
|
if (!this.stats || !this.hasAssets) return null;
|
|
376
|
-
return mapLiveBalance(this.stats, this.assetsByAssetId.values(), this.
|
|
379
|
+
return mapLiveBalance(this.stats, this.assetsByAssetId.values(), this.positionsByMarketId.values(), this.marketContext(), Date.now());
|
|
377
380
|
}
|
|
378
381
|
/** The market data both read paths join account state against, as the latest read left it. */
|
|
379
382
|
marketContext() {
|
|
@@ -419,8 +422,8 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
419
422
|
pollPositions() {
|
|
420
423
|
if (this.positionPollInFlight) return this.positionPollInFlight;
|
|
421
424
|
const lifecycleVersion = this.lifecycleVersion;
|
|
422
|
-
const
|
|
423
|
-
const poll = this.performPositionPoll(lifecycleVersion,
|
|
425
|
+
const positionStateVersion = this.positionStateVersion;
|
|
426
|
+
const poll = this.performPositionPoll(lifecycleVersion, positionStateVersion).finally(() => {
|
|
424
427
|
if (this.positionPollInFlight === poll) {
|
|
425
428
|
this.positionPollInFlight = null;
|
|
426
429
|
}
|
|
@@ -429,28 +432,52 @@ class LighterAccountTracker extends BaseAccountTracker {
|
|
|
429
432
|
return poll;
|
|
430
433
|
}
|
|
431
434
|
/**
|
|
432
|
-
* Republishes positions
|
|
435
|
+
* Republishes positions, per-market settings, and the balance those positions move.
|
|
436
|
+
*
|
|
437
|
+
* This is what keeps the account's open result alive. Lighter republishes `account_all` when the
|
|
438
|
+
* account itself changes and not when the mark moves, so between fills the position rows hold
|
|
439
|
+
* whatever the last frame left them at. A balance built on those rows stated a dead unrealized
|
|
440
|
+
* PnL and a dead maintenance margin beside the live `user_stats` figures — and restated them on
|
|
441
|
+
* every frame under a current timestamp, so nothing marked them as old. The venue's own account
|
|
442
|
+
* read is the only source that carries the new figures, so the poll adopts it wholesale.
|
|
443
|
+
*
|
|
444
|
+
* The rows land in the same store `account_all` writes, which is what keeps `getPositions()` and
|
|
445
|
+
* the balance's `unrealizedPnl` stating one account rather than two.
|
|
433
446
|
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
447
|
+
* A read is dropped whole when a frame moved the position state while it was in flight: that
|
|
448
|
+
* frame is the newer account. `user_stats` deliberately does not invalidate one — it carries the
|
|
449
|
+
* account scopes and no positions at all, and a venue publishing it every tick would otherwise
|
|
450
|
+
* cancel every poll and freeze the very figures this read exists to move.
|
|
438
451
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
452
|
+
* No balance leaves here while the socket is down. The account scopes and the assets are dropped
|
|
453
|
+
* on the next open rather than on the drop, so a balance published mid-outage would pair these
|
|
454
|
+
* fresh positions with figures frozen at the drop — worst of it a cross margin ratio measured
|
|
455
|
+
* against a portfolio value the account no longer has. Positions still publish: they are whole.
|
|
442
456
|
*/
|
|
443
|
-
async performPositionPoll(lifecycleVersion,
|
|
457
|
+
async performPositionPoll(lifecycleVersion, positionStateVersion) {
|
|
444
458
|
try {
|
|
445
459
|
const account = await this.fetchPrimaryAccount();
|
|
446
460
|
if (lifecycleVersion !== this.lifecycleVersion) return;
|
|
447
|
-
if (!account ||
|
|
448
|
-
const
|
|
449
|
-
this.
|
|
461
|
+
if (!account || positionStateVersion !== this.positionStateVersion) return;
|
|
462
|
+
const rawPositions = account.positions ?? [];
|
|
463
|
+
this.positionsByMarketId.clear();
|
|
464
|
+
for (const position of rawPositions) {
|
|
465
|
+
if (isZeroSize(position.position)) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
this.positionsByMarketId.set(String(position.market_id), position);
|
|
469
|
+
}
|
|
470
|
+
const { positions, trackedAssets } = mapPositions(toPosRecord(rawPositions), true);
|
|
471
|
+
const update = {
|
|
450
472
|
positions,
|
|
451
473
|
positionsMode: "replace",
|
|
452
474
|
trackedAssets
|
|
453
|
-
}
|
|
475
|
+
};
|
|
476
|
+
const balance = this.isConnected ? this.buildBalance() : null;
|
|
477
|
+
if (balance) {
|
|
478
|
+
update.balance = balance;
|
|
479
|
+
}
|
|
480
|
+
this.onStateUpdate(update);
|
|
454
481
|
} catch (err) {
|
|
455
482
|
if (lifecycleVersion === this.lifecycleVersion) {
|
|
456
483
|
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
@@ -88,6 +88,8 @@ function mapSinglePosition(p) {
|
|
|
88
88
|
size,
|
|
89
89
|
entryPrice: p.avgPx ?? "0",
|
|
90
90
|
unrealizedPnl: p.upl ?? "0",
|
|
91
|
+
// OKX states `imr` per position; mapping it is left to its own change.
|
|
92
|
+
marginUsed: null,
|
|
91
93
|
leverage: p.lever ?? "0",
|
|
92
94
|
marginType,
|
|
93
95
|
liquidationPrice: p.liqPx || null
|