@pear-protocol/exchanges-sdk 0.12.2 → 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 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
@@ -6,6 +6,16 @@ const HL_INFO_URLS = {
6
6
  mainnet: "https://api.hyperliquid.xyz/info",
7
7
  testnet: "https://api.hyperliquid-testnet.xyz/info"
8
8
  };
9
+ class HyperliquidInfoRequestError extends Error {
10
+ static {
11
+ __name(this, "HyperliquidInfoRequestError");
12
+ }
13
+ status;
14
+ constructor(status) {
15
+ super(`Hyperliquid info request failed: ${status}`);
16
+ this.status = status;
17
+ }
18
+ }
9
19
  async function fetchInfo(body, demo = false) {
10
20
  const url = demo ? HL_INFO_URLS.testnet : HL_INFO_URLS.mainnet;
11
21
  const res = await proxyFetch(url, {
@@ -16,7 +26,7 @@ async function fetchInfo(body, demo = false) {
16
26
  body: JSON.stringify(body)
17
27
  });
18
28
  if (!res.ok) {
19
- throw new Error(`Hyperliquid info request failed: ${res.status}`);
29
+ throw new HyperliquidInfoRequestError(res.status);
20
30
  }
21
31
  return res.json();
22
32
  }
@@ -85,4 +95,4 @@ async function fetchActiveAssetData(user, coin, demo = false) {
85
95
  }
86
96
  __name(fetchActiveAssetData, "fetchActiveAssetData");
87
97
 
88
- export { fetchActiveAssetData, fetchAllMids, fetchClearinghouseState, fetchDelegatorSummary, fetchInfo, fetchPerpDexs, fetchSpotClearinghouseState, fetchUserAbstraction, fetchUserRole, fetchUserVaultEquities };
98
+ export { HyperliquidInfoRequestError, fetchActiveAssetData, fetchAllMids, fetchClearinghouseState, fetchDelegatorSummary, fetchInfo, fetchPerpDexs, fetchSpotClearinghouseState, fetchUserAbstraction, fetchUserRole, fetchUserVaultEquities };
@@ -3,6 +3,17 @@ import { getLighterBaseUrl } from './constants';
3
3
 
4
4
  var __defProp = Object.defineProperty;
5
5
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
6
+ class LighterRequestError extends Error {
7
+ static {
8
+ __name(this, "LighterRequestError");
9
+ }
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.name = "LighterRequestError";
14
+ this.status = status;
15
+ }
16
+ }
6
17
  const INTENT_REQUEST_ATTEMPTS = 3;
7
18
  const INTENT_RETRY_BASE_DELAY_MS = 500;
8
19
  const RETRYABLE_INTENT_STATUSES = /* @__PURE__ */ new Set([
@@ -176,9 +187,11 @@ async function fetchAccountsByL1Address(l1Address, demo = false) {
176
187
  };
177
188
  }
178
189
  __name(fetchAccountsByL1Address, "fetchAccountsByL1Address");
179
- async function fetchAccount(accountIndex, authToken, demo = false) {
180
- const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/account?by=index&value=${accountIndex}`, authToken);
181
- return readLighterResponse(response, "/account");
190
+ async function fetchAccount(accountIndex, authToken, demo = false, throttle) {
191
+ return gatedLighterRead(throttle, "account", async () => {
192
+ const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/account?by=index&value=${accountIndex}`, authToken);
193
+ return readLighterResponse(response, "/account");
194
+ }, () => 0);
182
195
  }
183
196
  __name(fetchAccount, "fetchAccount");
184
197
  async function fetchPublicAccount(accountIndex, demo = false) {
@@ -232,7 +245,7 @@ async function fetchAssetDetails(demo) {
232
245
  return data.asset_details;
233
246
  }
234
247
  __name(fetchAssetDetails, "fetchAssetDetails");
235
- async function fetchTradesSince(accountIndex, authToken, since, demo = false) {
248
+ async function fetchTradesSince(accountIndex, authToken, since, demo = false, throttle) {
236
249
  const results = [];
237
250
  const seenCursors = /* @__PURE__ */ new Set();
238
251
  let cursor;
@@ -251,8 +264,10 @@ async function fetchTradesSince(accountIndex, authToken, since, demo = false) {
251
264
  cursor
252
265
  } : {}
253
266
  });
254
- const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/trades?${params.toString()}`, authToken);
255
- const data = await readLighterResponse(response, "/trades");
267
+ const data = await gatedLighterRead(throttle, "trades", async () => {
268
+ const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/trades?${params.toString()}`, authToken);
269
+ return readLighterResponse(response, "/trades");
270
+ }, (page2) => (page2.trades ?? []).length);
256
271
  const page = data.trades ?? [];
257
272
  results.push(...page.filter((trade) => trade.timestamp >= since.getTime()));
258
273
  const reachedSince = page.some((trade) => trade.timestamp < since.getTime());
@@ -269,7 +284,7 @@ async function fetchTradesSince(accountIndex, authToken, since, demo = false) {
269
284
  return Array.from(byId.values());
270
285
  }
271
286
  __name(fetchTradesSince, "fetchTradesSince");
272
- async function fetchPositionFundingSince(accountIndex, authToken, since, demo = false) {
287
+ async function fetchPositionFundingSince(accountIndex, authToken, since, demo = false, throttle) {
273
288
  const results = /* @__PURE__ */ new Map();
274
289
  const seenCursors = /* @__PURE__ */ new Set();
275
290
  let cursor;
@@ -284,8 +299,10 @@ async function fetchPositionFundingSince(accountIndex, authToken, since, demo =
284
299
  cursor
285
300
  } : {}
286
301
  });
287
- const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/positionFunding?${params.toString()}`, authToken);
288
- const data = await readLighterResponse(response, "/positionFunding");
302
+ const data = await gatedLighterRead(throttle, "positionFunding", async () => {
303
+ const response = await authenticatedLighterFetch(`${getLighterBaseUrl(demo)}/api/v1/positionFunding?${params.toString()}`, authToken);
304
+ return readLighterResponse(response, "/positionFunding");
305
+ }, (page) => (page.position_fundings ?? []).length);
289
306
  for (const funding of data.position_fundings ?? []) {
290
307
  if (funding.timestamp >= sinceSeconds) {
291
308
  results.set(`${funding.market_id}:${funding.funding_id}`, funding);
@@ -374,6 +391,21 @@ async function fetchUserReferrals(params, demo = false) {
374
391
  };
375
392
  }
376
393
  __name(fetchUserReferrals, "fetchUserReferrals");
394
+ async function gatedLighterRead(throttle, requestType, run, countItems) {
395
+ if (!throttle) {
396
+ return run();
397
+ }
398
+ await throttle.beforeRequest(requestType);
399
+ try {
400
+ const result = await run();
401
+ throttle.afterRequest(requestType, countItems(result));
402
+ return result;
403
+ } catch (error) {
404
+ throttle.afterRequestError(requestType, error);
405
+ throw error;
406
+ }
407
+ }
408
+ __name(gatedLighterRead, "gatedLighterRead");
377
409
  async function authenticatedLighterFetch(url, authToken, init) {
378
410
  const headers = new Headers(init?.headers);
379
411
  headers.set("authorization", authToken);
@@ -440,10 +472,10 @@ async function readLighterResponse(response, endpoint, acceptedCodes = [
440
472
  data = void 0;
441
473
  }
442
474
  if (!data) {
443
- throw new Error(`Lighter ${endpoint} returned ${response.status}`);
475
+ throw new LighterRequestError(`Lighter ${endpoint} returned ${response.status}`, response.status);
444
476
  }
445
477
  if (!acceptedCodes.includes(data.code)) {
446
- throw new Error(`Lighter ${endpoint} returned ${data.code}: ${data.message ?? `HTTP ${response.status}`}`);
478
+ throw new LighterRequestError(`Lighter ${endpoint} returned ${data.code}: ${data.message ?? `HTTP ${response.status}`}`, response.status);
447
479
  }
448
480
  return data;
449
481
  }
@@ -493,4 +525,4 @@ function readReferralAmount(value) {
493
525
  }
494
526
  __name(readReferralAmount, "readReferralAmount");
495
527
 
496
- export { createCctpDepositIntentAddress, fetchAccount, fetchAccountLimits, fetchAccountMetadata, fetchAccountsByL1Address, fetchApiKeys, fetchAssetDetails, fetchDepositHistory, fetchDepositNetworks, fetchOrderBookDetails, fetchPositionFundingSince, fetchPublicAccount, fetchPublicOrderBookDetails, fetchTradesSince, fetchUserReferrals, fetchWithdrawHistory, submitReferralCode };
528
+ export { LighterRequestError, createCctpDepositIntentAddress, fetchAccount, fetchAccountLimits, fetchAccountMetadata, fetchAccountsByL1Address, fetchApiKeys, fetchAssetDetails, fetchDepositHistory, fetchDepositNetworks, fetchOrderBookDetails, fetchPositionFundingSince, fetchPublicAccount, fetchPublicOrderBookDetails, fetchTradesSince, fetchUserReferrals, fetchWithdrawHistory, submitReferralCode };
package/dist/index.d.ts CHANGED
@@ -6,9 +6,30 @@ import { WalletClient, Transport, Chain, Account } from 'viem';
6
6
  import { z } from 'zod';
7
7
 
8
8
  type SupportedCredentials = HttpTypes.ExchangeCredentialsResponse['credentials'];
9
+ /**
10
+ * Optional pacing seam around the syncer's exchange info requests. The SDK stays
11
+ * rate-limit-agnostic: it only exposes when a request starts, what it returned,
12
+ * and when it failed — the caller owns all budget math and waiting.
13
+ */
14
+ type SyncerThrottle = {
15
+ /** Awaited before the request fires; the caller blocks here until its budget allows the call. */
16
+ beforeRequest(requestType: string): Promise<void>;
17
+ /** Called after a successful response; `itemCount` is the response array length (0 for object responses). */
18
+ afterRequest(requestType: string, itemCount: number): void;
19
+ /** Called when the request threw, before the error propagates; lets the caller inspect rate-limit rejections. */
20
+ afterRequestError(requestType: string, error: unknown): void;
21
+ };
9
22
  type ExchangesSDKConfig = {
10
23
  sdk: PearSDK;
11
24
  demo?: boolean;
25
+ /** Optional pacing seam around the syncer's exchange info requests; absent = unthrottled (current behavior). */
26
+ throttle?: SyncerThrottle;
27
+ /**
28
+ * Optional pre-fetched Hyperliquid perp-dex names ('' = the main dex). The list is
29
+ * global (identical for every account), so a batch caller fetches it once per run and
30
+ * passes it here; absent = the syncer fetches it per sync (current behavior).
31
+ */
32
+ hyperliquidPerpDexNames?: string[];
12
33
  /**
13
34
  * Optional forward-proxy for the exchange REST calls. When enabled, requests are re-pointed at
14
35
  * `proxy.url` with the original venue host in `X-Proxy-Target`. Injected here because the SDK
@@ -918,6 +939,10 @@ interface AccountSyncerConfig {
918
939
  account: TradeAccount;
919
940
  credentials: SupportedCredentials;
920
941
  demo: boolean;
942
+ /** Optional pacing seam around the exchange info requests; absent = unthrottled (current behavior). */
943
+ throttle?: SyncerThrottle;
944
+ /** Optional pre-fetched Hyperliquid perp-dex names ('' = the main dex); absent = fetched per sync. */
945
+ hyperliquidPerpDexNames?: string[];
921
946
  }
922
947
  /**
923
948
  * Outcome of one fill sync cycle, so batch/cron callers can tell a completed
@@ -959,6 +984,7 @@ declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer
959
984
  protected account: TradeAccount;
960
985
  protected credentials: SupportedCredentials;
961
986
  protected demo: boolean;
987
+ protected throttle: SyncerThrottle | undefined;
962
988
  constructor(config: AccountSyncerConfig);
963
989
  /**
964
990
  * Fetch the account's currently-open positions from the exchange, normalized
@@ -1125,11 +1151,19 @@ type UserFillsByTimeResponse = {
1125
1151
  type HyperliquidRawFill = UserFillsByTimeResponse;
1126
1152
  declare class HyperliquidAccountSyncer extends BaseAccountSyncer<HyperliquidRawFill> {
1127
1153
  private readonly address;
1154
+ private readonly perpDexNames;
1128
1155
  constructor(config: AccountSyncerConfig);
1129
1156
  protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
1130
1157
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
1131
1158
  protected listExternalFunding({ since, instruments, }: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
1132
1159
  protected isReduceOnly(rawFill: HyperliquidRawFill): boolean;
1160
+ /** Fetch the perp-dex descriptors and reduce them to unique dex names ('' = the main dex). */
1161
+ private fetchPerpDexNames;
1162
+ /**
1163
+ * Route one info request through the optional throttle seam: gate before the
1164
+ * call, report the response size after, surface a failure before it propagates.
1165
+ */
1166
+ private fetchInfoGated;
1133
1167
  }
1134
1168
 
1135
1169
  type RawFill$1 = LighterRawTrade;
@@ -1211,6 +1245,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
1211
1245
  stakedEquity: z.ZodNullable<z.ZodString>;
1212
1246
  unrealizedPnl: z.ZodString;
1213
1247
  maintenanceMargin: z.ZodString;
1248
+ marginUsed: z.ZodString;
1214
1249
  crossMarginRatio: z.ZodString;
1215
1250
  crossAccountLeverage: z.ZodString;
1216
1251
  perDexState: z.ZodArray<z.ZodObject<{
@@ -1240,6 +1275,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
1240
1275
  portfolioValue: z.ZodNullable<z.ZodString>;
1241
1276
  unrealizedPnl: z.ZodString;
1242
1277
  perpsMaintenanceMargin: z.ZodString;
1278
+ marginUsed: z.ZodString;
1243
1279
  unifiedAccountRatio: z.ZodString;
1244
1280
  unifiedAccountLeverage: z.ZodNullable<z.ZodString>;
1245
1281
  perDexState: z.ZodArray<z.ZodObject<{
@@ -1271,6 +1307,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
1271
1307
  portfolioValue: z.ZodNullable<z.ZodString>;
1272
1308
  unrealizedPnl: z.ZodString;
1273
1309
  perpsMaintenanceMargin: z.ZodString;
1310
+ marginUsed: z.ZodString;
1274
1311
  portfolioMarginRatio: z.ZodNullable<z.ZodString>;
1275
1312
  portfolioAccountLeverage: z.ZodNullable<z.ZodString>;
1276
1313
  borrowCapUsed: z.ZodNullable<z.ZodString>;
@@ -1301,6 +1338,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
1301
1338
  stakingEquity: z.ZodNullable<z.ZodString>;
1302
1339
  creditedInLit: z.ZodNullable<z.ZodString>;
1303
1340
  unrealizedPnl: z.ZodNullable<z.ZodString>;
1341
+ marginUsed: z.ZodNullable<z.ZodString>;
1304
1342
  crossLeverage: z.ZodNullable<z.ZodString>;
1305
1343
  maintenanceMargin: z.ZodNullable<z.ZodString>;
1306
1344
  crossMarginUsage: z.ZodNullable<z.ZodString>;
@@ -1326,6 +1364,7 @@ declare const AccountBalance: z.ZodDiscriminatedUnion<[z.ZodObject<{
1326
1364
  stakingEquity: z.ZodNullable<z.ZodString>;
1327
1365
  creditedInLit: z.ZodNullable<z.ZodString>;
1328
1366
  unrealizedPnl: z.ZodNullable<z.ZodString>;
1367
+ marginUsed: z.ZodNullable<z.ZodString>;
1329
1368
  crossLeverage: z.ZodNullable<z.ZodString>;
1330
1369
  maintenanceMargin: z.ZodNullable<z.ZodString>;
1331
1370
  crossMarginUsage: z.ZodNullable<z.ZodString>;
@@ -1532,6 +1571,20 @@ interface AccountPosition {
1532
1571
  size: string;
1533
1572
  entryPrice: string;
1534
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;
1535
1588
  leverage: string;
1536
1589
  marginType: MarginMode;
1537
1590
  liquidationPrice: string | null;
@@ -1919,6 +1972,7 @@ declare const HyperliquidStandardBalance: z.ZodObject<{
1919
1972
  stakedEquity: z.ZodNullable<z.ZodString>;
1920
1973
  unrealizedPnl: z.ZodString;
1921
1974
  maintenanceMargin: z.ZodString;
1975
+ marginUsed: z.ZodString;
1922
1976
  crossMarginRatio: z.ZodString;
1923
1977
  crossAccountLeverage: z.ZodString;
1924
1978
  perDexState: z.ZodArray<z.ZodObject<{
@@ -1950,6 +2004,7 @@ declare const HyperliquidUnifiedBalance: z.ZodObject<{
1950
2004
  portfolioValue: z.ZodNullable<z.ZodString>;
1951
2005
  unrealizedPnl: z.ZodString;
1952
2006
  perpsMaintenanceMargin: z.ZodString;
2007
+ marginUsed: z.ZodString;
1953
2008
  unifiedAccountRatio: z.ZodString;
1954
2009
  unifiedAccountLeverage: z.ZodNullable<z.ZodString>;
1955
2010
  perDexState: z.ZodArray<z.ZodObject<{
@@ -2009,6 +2064,7 @@ declare const HyperliquidPortfolioMarginBalance: z.ZodObject<{
2009
2064
  portfolioValue: z.ZodNullable<z.ZodString>;
2010
2065
  unrealizedPnl: z.ZodString;
2011
2066
  perpsMaintenanceMargin: z.ZodString;
2067
+ marginUsed: z.ZodString;
2012
2068
  portfolioMarginRatio: z.ZodNullable<z.ZodString>;
2013
2069
  portfolioAccountLeverage: z.ZodNullable<z.ZodString>;
2014
2070
  borrowCapUsed: z.ZodNullable<z.ZodString>;
@@ -2143,6 +2199,7 @@ declare const LighterClassicBalance: z.ZodObject<{
2143
2199
  stakingEquity: z.ZodNullable<z.ZodString>;
2144
2200
  creditedInLit: z.ZodNullable<z.ZodString>;
2145
2201
  unrealizedPnl: z.ZodNullable<z.ZodString>;
2202
+ marginUsed: z.ZodNullable<z.ZodString>;
2146
2203
  crossLeverage: z.ZodNullable<z.ZodString>;
2147
2204
  maintenanceMargin: z.ZodNullable<z.ZodString>;
2148
2205
  crossMarginUsage: z.ZodNullable<z.ZodString>;
@@ -2170,6 +2227,7 @@ declare const LighterUnifiedBalance: z.ZodObject<{
2170
2227
  stakingEquity: z.ZodNullable<z.ZodString>;
2171
2228
  creditedInLit: z.ZodNullable<z.ZodString>;
2172
2229
  unrealizedPnl: z.ZodNullable<z.ZodString>;
2230
+ marginUsed: z.ZodNullable<z.ZodString>;
2173
2231
  crossLeverage: z.ZodNullable<z.ZodString>;
2174
2232
  maintenanceMargin: z.ZodNullable<z.ZodString>;
2175
2233
  crossMarginUsage: z.ZodNullable<z.ZodString>;
@@ -2202,9 +2260,9 @@ declare class LighterAccountTracker extends BaseAccountTracker {
2202
2260
  private startInFlight;
2203
2261
  private lifecycleVersion;
2204
2262
  private wsConnectionVersion;
2205
- private stateVersion;
2263
+ private positionStateVersion;
2206
2264
  private assetsByAssetId;
2207
- private wsPositionsByMarketId;
2265
+ private positionsByMarketId;
2208
2266
  private marketsByMarketId;
2209
2267
  private marketsBySymbol;
2210
2268
  private spotPriceBySymbol;
@@ -2253,10 +2311,10 @@ declare class LighterAccountTracker extends BaseAccountTracker {
2253
2311
  /**
2254
2312
  * Subscribes this socket epoch's account channels, and keeps trying until one attempt lands.
2255
2313
  *
2256
- * This is the sole subscriber of `account_all` and `user_stats`, and the live balance is built
2257
- * from those two channels alone — see `buildBalance`. A mint that failed once and was never
2258
- * retried therefore left the tracker connected but permanently unsubscribed: the 2s REST poll
2259
- * 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.
2260
2318
  *
2261
2319
  * The wait doubles up to a ceiling, and only a new connection epoch or teardown ends it.
2262
2320
  */
@@ -2271,13 +2329,15 @@ declare class LighterAccountTracker extends BaseAccountTracker {
2271
2329
  /** Keeps both published scopes: cross drives every "Cross" field, total drives the account ones. */
2272
2330
  private onUserStats;
2273
2331
  /**
2274
- * The account's balance, built from websocket state alone.
2332
+ * The account's balance, from the freshest state each of its parts has.
2275
2333
  *
2276
- * Every input is websocket-sourced — `user_stats` for the two account scopes, `account_all` for
2277
- * the assets and positions — except the market context, which is venue-wide metadata this
2278
- * tracker re-reads on its own timer. This is the ONLY producer of the value `getBalance()`
2279
- * returns while the tracker is running; `snapshot()` is the separate REST path and never writes
2280
- * here.
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.
2338
+ *
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.
2281
2341
  *
2282
2342
  * Null until both channels have delivered a frame, because a balance built from one of them
2283
2343
  * would be missing half the account.
@@ -2295,16 +2355,27 @@ declare class LighterAccountTracker extends BaseAccountTracker {
2295
2355
  private retrieveRequiredAuthToken;
2296
2356
  private pollPositions;
2297
2357
  /**
2298
- * 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.
2299
2369
  *
2300
- * Deliberately publishes NO balance. Every field of both Lighter variants comes off the two
2301
- * websocket channels — `user_stats` carries both account scopes and `account_all` carries the
2302
- * assets and positions behind them — so a REST balance here would only overwrite a live value
2303
- * 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.
2304
2374
  *
2305
- * It also leaves `wsPositionsByMarketId` alone. That store feeds the live balance's unrealized
2306
- * PnL and maintenance margin, so writing REST rows into it would make a balance labelled live
2307
- * 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.
2308
2379
  */
2309
2380
  private performPositionPoll;
2310
2381
  /**
@@ -2531,6 +2602,8 @@ declare function createAccountTracker(exchange: Connector, config: CreateAccount
2531
2602
  declare class ExchangesSDK {
2532
2603
  private sdk;
2533
2604
  private demo;
2605
+ private throttle;
2606
+ private hyperliquidPerpDexNames;
2534
2607
  constructor(config: ExchangesSDKConfig);
2535
2608
  connect(tradeAccountId: string): Promise<ExchangeConnection>;
2536
2609
  /**
@@ -2548,4 +2621,4 @@ declare class ExchangesSDK {
2548
2621
  private fetchCredentials;
2549
2622
  }
2550
2623
 
2551
- export { ACCOUNT_MODE_LABELS, ASSET_DECIMALS, AccountBalance, AccountBalanceCore, type AccountHealthStatus, AccountMode, type AccountPosition, type AccountSnapshot, type AccountStatus, type AccountSyncer, type AccountSyncerConfig, type AccountTierCallback, type AccountTracker, type AccountTrackerConfig, type AccountWarningReason, type ApiKey, type ApproveIntegratorParams, type ApproveIntegratorResult, AssetId, type AssetSnapshotMap, type BalanceCallback, BaseAccountSyncer, BaseAccountTracker, BaseWs, BinanceAccountSyncer, BinanceAccountTracker, type BinanceAccountTrackerConfig, BinanceExchangeWs, BinanceMultiAssetBalance, BinanceSingleAssetBalance, BinanceUserDataWs, type BinanceUserDataWsConfig, BinanceWsApi, type BinanceWsApiConfig, BybitAccountSyncer, BybitAccountTracker, type BybitAccountTrackerConfig, BybitExchangeWs, BybitIsolatedMarginBalance, BybitPortfolioMarginBalance, BybitRegularMarginBalance, BybitWs, type BybitWsConfig, type CctpDepositAddressParams, type CctpDepositAddressResult, type CctpDepositHistorySettlement, type CctpDepositParams, type CctpDepositRegistrationSettlement, type CctpDepositResult, type CctpDepositSettlement, type ChangeAccountTierParams, type ChangeAccountTierResult, type CheckApiWalletParams, CollateralBalance, type ConnectOptions, type ConnectWalletParams, type ConnectWalletResult, type CreateAccountTrackerConfig, type CreateExchangeWsConfig, DecimalString, type EthereumDepositParams, type EthereumDepositResult, type ExchangeConnection, type ExchangeHelperExchange, type ExchangeHelperMap, type ExchangeState, ExchangeWs, type ExchangeWsByConnector, ExchangesSDK, type ExchangesSDKConfig, FUNXYZ_DEPOSIT_MINIMUMS_USD, FUNXYZ_ETHEREUM_MAINNET_CHAIN_ID, FUNXYZ_EVM_CHAIN_NAMES, type FastWithdrawParams, type FastWithdrawResult, type FillSyncOutcome, type FundingSyncOutcome, type FunxyzAccountState, type FunxyzAsset, type FunxyzDepositAddressParams, type FunxyzDepositAddressResult, type FunxyzDepositCredit, type FunxyzDepositParams, type FunxyzDepositResult, type FunxyzDepositSettlement, type FunxyzDepositSource, type FunxyzMarket, HyperliquidAccountSyncer, HyperliquidAccountTracker, type HyperliquidAccountTrackerConfig, HyperliquidDexState, HyperliquidExchangeWs, type HyperliquidHelper, HyperliquidPortfolioMarginBalance, HyperliquidPortfolioMarginCollateral, HyperliquidStandardBalance, HyperliquidUnifiedBalance, HyperliquidWs, type IntegratorApprovalRequirements, L2_ASSET_DECIMALS, LIGHTER_NATIVE_USDC_ADDRESSES, LIGHTER_USDC_DECIMALS, LighterAccountSyncer, type LighterAccountTier, LighterAccountTracker, type LighterAccountTrackerConfig, type LighterApplyReferralCodeParams, type LighterAuthCredentials, LighterClassicBalance, type LighterDeposit, type LighterDepositAddressParams, type LighterDepositAddressResult, type LighterDepositCredit, type LighterDepositHistoryParams, type LighterDepositNetwork, type LighterDepositPage, type LighterDepositParams, type LighterDepositProgress, type LighterDepositResult, type LighterDepositStatus, type LighterDepositWalletAdapter, LighterExchangeWs, type LighterHelper, type LighterLookupResult, type LighterOnboardingStatus, type LighterReferral, type LighterReferralTotals, type LighterReferralTradeStats, LighterUnifiedBalance, type LighterUserReferrals, type LighterUserReferralsParams, type LighterWithdraw, type LighterWithdrawHistoryParams, type LighterWithdrawPage, type LighterWithdrawStatus, type LighterWithdrawType, LighterWs, type ListExternalFillsParams, type ListExternalFundingParams, type ListExternalPositionsParams, MAX_API_KEY_INDEX, MIN_LIGHTER_CCTP_DEPOSIT_USDC, MIN_LIGHTER_FAST_WITHDRAW_USDC, MIN_USER_API_KEY_INDEX, MarginMode, type MessageListener, OkxAccountSyncer, OkxAccountTracker, type OkxAccountTrackerConfig, OkxExchangeWs, OkxFuturesBalance, OkxMultiCurrencyMarginBalance, OkxPortfolioMarginBalance, OkxSpotBalance, OkxWs, type OkxWsConfig, type OpenListener, type PositionsCallback, RouteType, Side, SignedDecimalString, type StateUpdate, type SupportedCredentials, type TrackedAssetCallback, type TrackedAssetInfo, type WalletClientWithAccount, type WithdrawParams, type WithdrawResult, type WsMessage, createAccountSyncer, createAccountTracker, createExchangeWs, createHyperliquidHelper, createLighterHelper, findAvailableApiKeyIndex, getAssetDecimals, getLighterNativeUsdcAddress, listFunxyzTransferableChains, readFunxyzChainSource, readFunxyzMinimumUsd, retrieveLighterAuthToken, usdcAbi };
2624
+ export { ACCOUNT_MODE_LABELS, ASSET_DECIMALS, AccountBalance, AccountBalanceCore, type AccountHealthStatus, AccountMode, type AccountPosition, type AccountSnapshot, type AccountStatus, type AccountSyncer, type AccountSyncerConfig, type AccountTierCallback, type AccountTracker, type AccountTrackerConfig, type AccountWarningReason, type ApiKey, type ApproveIntegratorParams, type ApproveIntegratorResult, AssetId, type AssetSnapshotMap, type BalanceCallback, BaseAccountSyncer, BaseAccountTracker, BaseWs, BinanceAccountSyncer, BinanceAccountTracker, type BinanceAccountTrackerConfig, BinanceExchangeWs, BinanceMultiAssetBalance, BinanceSingleAssetBalance, BinanceUserDataWs, type BinanceUserDataWsConfig, BinanceWsApi, type BinanceWsApiConfig, BybitAccountSyncer, BybitAccountTracker, type BybitAccountTrackerConfig, BybitExchangeWs, BybitIsolatedMarginBalance, BybitPortfolioMarginBalance, BybitRegularMarginBalance, BybitWs, type BybitWsConfig, type CctpDepositAddressParams, type CctpDepositAddressResult, type CctpDepositHistorySettlement, type CctpDepositParams, type CctpDepositRegistrationSettlement, type CctpDepositResult, type CctpDepositSettlement, type ChangeAccountTierParams, type ChangeAccountTierResult, type CheckApiWalletParams, CollateralBalance, type ConnectOptions, type ConnectWalletParams, type ConnectWalletResult, type CreateAccountTrackerConfig, type CreateExchangeWsConfig, DecimalString, type EthereumDepositParams, type EthereumDepositResult, type ExchangeConnection, type ExchangeHelperExchange, type ExchangeHelperMap, type ExchangeState, ExchangeWs, type ExchangeWsByConnector, ExchangesSDK, type ExchangesSDKConfig, FUNXYZ_DEPOSIT_MINIMUMS_USD, FUNXYZ_ETHEREUM_MAINNET_CHAIN_ID, FUNXYZ_EVM_CHAIN_NAMES, type FastWithdrawParams, type FastWithdrawResult, type FillSyncOutcome, type FundingSyncOutcome, type FunxyzAccountState, type FunxyzAsset, type FunxyzDepositAddressParams, type FunxyzDepositAddressResult, type FunxyzDepositCredit, type FunxyzDepositParams, type FunxyzDepositResult, type FunxyzDepositSettlement, type FunxyzDepositSource, type FunxyzMarket, HyperliquidAccountSyncer, HyperliquidAccountTracker, type HyperliquidAccountTrackerConfig, HyperliquidDexState, HyperliquidExchangeWs, type HyperliquidHelper, HyperliquidPortfolioMarginBalance, HyperliquidPortfolioMarginCollateral, HyperliquidStandardBalance, HyperliquidUnifiedBalance, HyperliquidWs, type IntegratorApprovalRequirements, L2_ASSET_DECIMALS, LIGHTER_NATIVE_USDC_ADDRESSES, LIGHTER_USDC_DECIMALS, LighterAccountSyncer, type LighterAccountTier, LighterAccountTracker, type LighterAccountTrackerConfig, type LighterApplyReferralCodeParams, type LighterAuthCredentials, LighterClassicBalance, type LighterDeposit, type LighterDepositAddressParams, type LighterDepositAddressResult, type LighterDepositCredit, type LighterDepositHistoryParams, type LighterDepositNetwork, type LighterDepositPage, type LighterDepositParams, type LighterDepositProgress, type LighterDepositResult, type LighterDepositStatus, type LighterDepositWalletAdapter, LighterExchangeWs, type LighterHelper, type LighterLookupResult, type LighterOnboardingStatus, type LighterReferral, type LighterReferralTotals, type LighterReferralTradeStats, LighterUnifiedBalance, type LighterUserReferrals, type LighterUserReferralsParams, type LighterWithdraw, type LighterWithdrawHistoryParams, type LighterWithdrawPage, type LighterWithdrawStatus, type LighterWithdrawType, LighterWs, type ListExternalFillsParams, type ListExternalFundingParams, type ListExternalPositionsParams, MAX_API_KEY_INDEX, MIN_LIGHTER_CCTP_DEPOSIT_USDC, MIN_LIGHTER_FAST_WITHDRAW_USDC, MIN_USER_API_KEY_INDEX, MarginMode, type MessageListener, OkxAccountSyncer, OkxAccountTracker, type OkxAccountTrackerConfig, OkxExchangeWs, OkxFuturesBalance, OkxMultiCurrencyMarginBalance, OkxPortfolioMarginBalance, OkxSpotBalance, OkxWs, type OkxWsConfig, type OpenListener, type PositionsCallback, RouteType, Side, SignedDecimalString, type StateUpdate, type SupportedCredentials, type SyncerThrottle, type TrackedAssetCallback, type TrackedAssetInfo, type WalletClientWithAccount, type WithdrawParams, type WithdrawResult, type WsMessage, createAccountSyncer, createAccountTracker, createExchangeWs, createHyperliquidHelper, createLighterHelper, findAvailableApiKeyIndex, getAssetDecimals, getLighterNativeUsdcAddress, listFunxyzTransferableChains, readFunxyzChainSource, readFunxyzMinimumUsd, retrieveLighterAuthToken, usdcAbi };
package/dist/sdk.js CHANGED
@@ -12,9 +12,13 @@ class ExchangesSDK {
12
12
  }
13
13
  sdk;
14
14
  demo;
15
+ throttle;
16
+ hyperliquidPerpDexNames;
15
17
  constructor(config) {
16
18
  this.sdk = config.sdk;
17
19
  this.demo = config.demo ?? false;
20
+ this.throttle = config.throttle;
21
+ this.hyperliquidPerpDexNames = config.hyperliquidPerpDexNames;
18
22
  if (config.proxy) configureProxy(config.proxy);
19
23
  }
20
24
  async connect(tradeAccountId) {
@@ -67,7 +71,9 @@ class ExchangesSDK {
67
71
  connector: connection.connector,
68
72
  account: connection.account,
69
73
  credentials: connection.credentials,
70
- demo: this.demo
74
+ demo: this.demo,
75
+ throttle: this.throttle,
76
+ hyperliquidPerpDexNames: this.hyperliquidPerpDexNames
71
77
  });
72
78
  }
73
79
  buildTracker(connection) {
@@ -12,12 +12,14 @@ class BaseAccountSyncer {
12
12
  account;
13
13
  credentials;
14
14
  demo;
15
+ throttle;
15
16
  constructor(config) {
16
17
  this.sdk = config.sdk;
17
18
  this.connector = config.connector;
18
19
  this.account = config.account;
19
20
  this.credentials = config.credentials;
20
21
  this.demo = config.demo;
22
+ this.throttle = config.throttle;
21
23
  }
22
24
  // Phase A: Load current Pear state (positions, fills, instruments, watermark).
23
25
  async loadPearState() {
@@ -11,9 +11,11 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
11
11
  __name(this, "HyperliquidAccountSyncer");
12
12
  }
13
13
  address;
14
+ perpDexNames;
14
15
  constructor(config) {
15
16
  super(config);
16
17
  this.address = config.account.exchangeIdentifier;
18
+ this.perpDexNames = config.hyperliquidPerpDexNames;
17
19
  }
18
20
  async listExternalPositions({ instruments }) {
19
21
  const instrumentByBase = new Map(instruments.map((instrument) => [
@@ -23,17 +25,12 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
23
25
  contractSize: instrument.precision.quantity.contractSize
24
26
  }
25
27
  ]));
26
- const perpDexs = await fetchInfo({
27
- type: "perpDexs"
28
- }, this.demo);
29
- const dexNames = [
30
- ...new Set(perpDexs.map((perpDex) => perpDex?.name ?? ""))
31
- ];
32
- const states = await Promise.all(dexNames.map((dex) => fetchInfo({
28
+ const dexNames = this.perpDexNames ?? await this.fetchPerpDexNames();
29
+ const states = await Promise.all(dexNames.map((dex) => this.fetchInfoGated({
33
30
  type: "clearinghouseState",
34
31
  user: this.address,
35
32
  dex
36
- }, this.demo)));
33
+ })));
37
34
  const assetPositions = states.flatMap((state) => state.assetPositions);
38
35
  return assetPositions.reduce((positions, { position: rawPosition }) => {
39
36
  const szi = new BigNumber(rawPosition.szi);
@@ -72,12 +69,12 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
72
69
  let cursor = since.getTime();
73
70
  let batchSize;
74
71
  do {
75
- const batch = await fetchInfo({
72
+ const batch = await this.fetchInfoGated({
76
73
  type: "userFillsByTime",
77
74
  user: this.address,
78
75
  startTime: cursor,
79
76
  aggregateByTime: true
80
- }, this.demo);
77
+ });
81
78
  batchSize = batch.length;
82
79
  for (const rawFill of batch) {
83
80
  addFill(rawFill);
@@ -93,12 +90,12 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
93
90
  } while (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT);
94
91
  cursor = since.getTime();
95
92
  do {
96
- const twapBatch = await fetchInfo({
93
+ const twapBatch = await this.fetchInfoGated({
97
94
  type: "userTwapSliceFillsByTime",
98
95
  user: this.address,
99
96
  startTime: cursor,
100
97
  aggregateByTime: true
101
- }, this.demo);
98
+ });
102
99
  batchSize = twapBatch.length;
103
100
  for (const item of twapBatch) {
104
101
  addFill(item.fill);
@@ -149,11 +146,11 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
149
146
  let cursor = since.getTime();
150
147
  let batchSize;
151
148
  do {
152
- const batch = await fetchInfo({
149
+ const batch = await this.fetchInfoGated({
153
150
  type: "userFunding",
154
151
  user: this.address,
155
152
  startTime: cursor
156
- }, this.demo);
153
+ });
157
154
  batchSize = batch.length;
158
155
  for (const event of batch) {
159
156
  allEvents.set(`${event.time}:${event.delta.coin}:${event.hash}`, event);
@@ -174,6 +171,33 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
174
171
  isReduceOnly(rawFill) {
175
172
  return rawFill.dir.includes("Close");
176
173
  }
174
+ /** Fetch the perp-dex descriptors and reduce them to unique dex names ('' = the main dex). */
175
+ async fetchPerpDexNames() {
176
+ const perpDexs = await this.fetchInfoGated({
177
+ type: "perpDexs"
178
+ });
179
+ return [
180
+ ...new Set(perpDexs.map((perpDex) => perpDex?.name ?? ""))
181
+ ];
182
+ }
183
+ /**
184
+ * Route one info request through the optional throttle seam: gate before the
185
+ * call, report the response size after, surface a failure before it propagates.
186
+ */
187
+ async fetchInfoGated(body) {
188
+ if (!this.throttle) {
189
+ return fetchInfo(body, this.demo);
190
+ }
191
+ await this.throttle.beforeRequest(body.type);
192
+ try {
193
+ const response = await fetchInfo(body, this.demo);
194
+ this.throttle.afterRequest(body.type, Array.isArray(response) ? response.length : 0);
195
+ return response;
196
+ } catch (error) {
197
+ this.throttle.afterRequestError(body.type, error);
198
+ throw error;
199
+ }
200
+ }
177
201
  }
178
202
  function warnStalledPagination(stream, cursor) {
179
203
  console.warn(`Hyperliquid ${stream} pagination stalled at timestamp ${cursor}; returned history may be incomplete.`);
@@ -36,7 +36,7 @@ class LighterAccountSyncer extends BaseAccountSyncer {
36
36
  instrument.precision.quantity.contractSize
37
37
  ]));
38
38
  const token = await this.retrieveAuthToken();
39
- const response = await fetchAccount(this.accountIndex, token, this.demo);
39
+ const response = await fetchAccount(this.accountIndex, token, this.demo, this.throttle);
40
40
  const rawPositions = response.accounts?.[0]?.positions ?? [];
41
41
  return rawPositions.reduce((positions, rawPosition) => {
42
42
  const instrumentId = rawPosition.market_id.toString();
@@ -59,7 +59,7 @@ class LighterAccountSyncer extends BaseAccountSyncer {
59
59
  async listExternalFills({ since, instruments }) {
60
60
  this.assertAuthenticatedSyncAvailable();
61
61
  const token = await this.retrieveAuthToken();
62
- const trades = await fetchTradesSince(this.accountIndex, token, since, this.demo);
62
+ const trades = await fetchTradesSince(this.accountIndex, token, since, this.demo, this.throttle);
63
63
  const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
64
64
  return trades.toSorted((a, b) => a.timestamp - b.timestamp).reduce((fills, rawFill) => {
65
65
  const instrumentId = rawFill.market_id.toString();
@@ -92,7 +92,7 @@ class LighterAccountSyncer extends BaseAccountSyncer {
92
92
  async listExternalFunding({ since, instruments }) {
93
93
  this.assertAuthenticatedSyncAvailable();
94
94
  const token = await this.retrieveAuthToken();
95
- const fundingEvents = await fetchPositionFundingSince(this.accountIndex, token, since, this.demo);
95
+ const fundingEvents = await fetchPositionFundingSince(this.accountIndex, token, since, this.demo, this.throttle);
96
96
  const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
97
97
  return toFundingPaymentEntries(fundingEvents, instrumentIds);
98
98
  }
@@ -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.12.2",
3
+ "version": "0.14.0",
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.19.3",
31
- "@pear-protocol/types": "^1.31.0",
32
- "@pear-protocol/utils": "^0.8.0",
30
+ "@pear-protocol/core-sdk": "^1.20.0",
31
+ "@pear-protocol/types": "^1.32.0",
32
+ "@pear-protocol/utils": "^0.8.1",
33
33
  "bignumber.js": "^11.1.1",
34
34
  "partysocket": "^1.0.3",
35
35
  "viem": "^2.48.8",