@pear-protocol/exchanges-sdk 0.13.0 → 0.14.1

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 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 stateVersion;
2263
+ private positionStateVersion;
2240
2264
  private assetsByAssetId;
2241
- private wsPositionsByMarketId;
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 the live balance is built
2291
- * from those two channels alone — see `buildBalance`. A mint that failed once and was never
2292
- * retried therefore left the tracker connected but permanently unsubscribed: the 2s REST poll
2293
- * kept positions moving while the balance silently froze until some incidental reconnect.
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, built from websocket state alone.
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
- * Every input is websocket-sourced — `user_stats` for the two account scopes, `account_all` for
2311
- * the assets and positions — except the market context, which is venue-wide metadata this
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 and per-market settings from REST.
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
- * Deliberately publishes NO balance. Every field of both Lighter variants comes off the two
2335
- * websocket channels — `user_stats` carries both account scopes and `account_all` carries the
2336
- * assets and positions behind them — so a REST balance here would only overwrite a live value
2337
- * with an equal one, while making `getBalance()` REST-derived most of the time.
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
- * It also leaves `wsPositionsByMarketId` alone. That store feeds the live balance's unrealized
2340
- * PnL and maintenance margin, so writing REST rows into it would make a balance labelled live
2341
- * partly REST-derived without any reader being able to tell.
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
- stateVersion = 0;
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 only by `account_all`. The live balance reads its unrealized PnL and maintenance
52
- // margin from here, so keeping REST out of it is what makes that balance wholly websocket-derived.
53
- wsPositionsByMarketId = /* @__PURE__ */ new Map();
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.stateVersion += 1;
227
+ this.positionStateVersion += 1;
226
228
  this.assetsByAssetId.clear();
227
- this.wsPositionsByMarketId.clear();
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 the live balance is built
244
- * from those two channels alone — see `buildBalance`. A mint that failed once and was never
245
- * retried therefore left the tracker connected but permanently unsubscribed: the 2s REST poll
246
- * kept positions moving while the balance silently froze until some incidental reconnect.
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.stateVersion += 1;
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.wsPositionsByMarketId.clear();
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.wsPositionsByMarketId.get(marketId);
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.wsPositionsByMarketId.delete(marketId);
336
+ this.positionsByMarketId.delete(marketId);
335
337
  } else {
336
- this.wsPositionsByMarketId.set(marketId, position);
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, built from websocket state alone.
364
+ * The account's balance, from the freshest state each of its parts has.
364
365
  *
365
- * Every input is websocket-sourced — `user_stats` for the two account scopes, `account_all` for
366
- * the assets and positions — except the market context, which is venue-wide metadata this
367
- * tracker re-reads on its own timer. This is the ONLY producer of the value `getBalance()`
368
- * returns while the tracker is running; `snapshot()` is the separate REST path and never writes
369
- * here.
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.wsPositionsByMarketId.values(), this.marketContext(), Date.now());
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 stateVersion = this.stateVersion;
423
- const poll = this.performPositionPoll(lifecycleVersion, stateVersion).finally(() => {
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 and per-market settings from REST.
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
- * Deliberately publishes NO balance. Every field of both Lighter variants comes off the two
435
- * websocket channels — `user_stats` carries both account scopes and `account_all` carries the
436
- * assets and positions behind them — so a REST balance here would only overwrite a live value
437
- * with an equal one, while making `getBalance()` REST-derived most of the time.
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
- * It also leaves `wsPositionsByMarketId` alone. That store feeds the live balance's unrealized
440
- * PnL and maintenance margin, so writing REST rows into it would make a balance labelled live
441
- * partly REST-derived without any reader being able to tell.
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, stateVersion) {
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 || stateVersion !== this.stateVersion) return;
448
- const { positions, trackedAssets } = mapPositions(toPosRecord(account.positions ?? []), true);
449
- this.onStateUpdate({
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/exchanges-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pear Protocol Exchanges SDK",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,9 +27,9 @@
27
27
  "test:watch": "vitest"
28
28
  },
29
29
  "dependencies": {
30
- "@pear-protocol/core-sdk": "^1.20.0",
31
- "@pear-protocol/types": "^1.32.0",
32
- "@pear-protocol/utils": "^0.8.1",
30
+ "@pear-protocol/core-sdk": "^2.0.0",
31
+ "@pear-protocol/types": "^2.0.0",
32
+ "@pear-protocol/utils": "^1.0.0",
33
33
  "bignumber.js": "^11.1.1",
34
34
  "partysocket": "^1.0.3",
35
35
  "viem": "^2.48.8",