New:Socket for Asana Is Now Available.Learn more
Get Started

@pear-protocol/exchanges-sdk

Package Overview
Dependencies
Maintainers
4
Versions
55
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@pear-protocol/exchanges-sdk

Pear Protocol Exchanges SDK

npmnpm
Version
0.10.0
Version published
Weekly downloads
2K
75.19%
Maintainers
4
Weekly downloads
 
Created
Source

@pear-protocol/exchanges-sdk

Unified SDK for account state (balance, positions, per-asset leverage) across supported derivative exchanges — as a live WebSocket subscription or a stateless one-shot snapshot.

Supported Exchanges

ExchangeConnectorMarket
BinancebinanceUSDM Futures
BybitbybitLinear Perpetuals
HyperliquidhyperliquidPerpetuals
LighterlighterPerpetuals
OKXokxLinear SWAP

Installation

npm install @pear-protocol/exchanges-sdk

Quick Start

import PearSDK from '@pear-protocol/core-sdk';
import { ExchangesSDK } from '@pear-protocol/exchanges-sdk';

const sdk = new PearSDK({ /* ... */ });
const exchanges = new ExchangesSDK({
  sdk,
  options: {
    builderAddress: '0x...',
    builderFeeTenthsBps: 60,
    integratorAccountIndex: 1234,
    integratorMinPerpsMakerFee: 600,
    integratorMinPerpsTakerFee: 600,
  },
});

const connection = await exchanges.connect(tradeAccountId);

const status = await exchanges.status(connection);
if (status.status === 'warning') {
  console.warn(status.reasons);
}

const tracker = exchanges.createTracker(connection);
const syncer = exchanges.createSyncer(connection);
await tracker.start();

const offBalance = tracker.trackBalance((balance) => {
  if (!balance) return; // unsupported/unrepresentable account mode
  console.log(balance.accountMode, balance.totalEquity);
});

const offPositions = tracker.trackPosition((positions) => {
  for (const p of positions) console.log(p.symbol, p.side, p.size);
});

// Cleanup
offBalance();
offPositions();
await tracker.disconnect();

Pass demo: true to target each exchange's test or simulated-trading environment:

const exchanges = new ExchangesSDK({ sdk, demo: true });

Hyperliquid and Lighter public REST/WebSocket reads are routed to testnet. Lighter operations that require a backend-generated auth token or signature currently fail closed in demo mode because the existing supporting API signs for mainnet only; public snapshots and tracking remain available.

Connection

connect(tradeAccountId) fetches decrypted credentials and the trade account from the core API. It does not open a socket — the exchange WebSocket is created lazily when you call tracker.start() and torn down by tracker.disconnect(). Syncers perform one-shot REST reads. For an account snapshot with no socket, use snapshot().

The connector comes from the backend trade account.

const connection = await exchanges.connect(tradeAccountId);

connection.connector; // "hyperliquid"
connection.account.exchangeIdentifier;
connection.credentials;

For exchanges that need account health checks, pass status options when creating the SDK:

const exchanges = new ExchangesSDK({
  sdk,
  options: {
    builderAddress: '0x...',
    builderFeeTenthsBps: 60,
    integratorAccountIndex: 1234,
    integratorMinPerpsMakerFee: 600,
    integratorMinPerpsTakerFee: 600,
  },
});

Hyperliquid status requires the builder address and the minimum approved fee in tenths of a basis point. Lighter status requires the integrator account and minimum maker/taker approval caps in Lighter fee ticks (parts per million). These thresholds must match the fees configured by the trading backend.

Historical Hyperliquid fill synchronization identifies fills by bare tid and requests them aggregated by time, so partial fills of one crossing order arrive as a single fill. Consumers that persisted composite time:coin:tid identifiers must account for the transition overlap. Hyperliquid serves only the 10,000 most recent fills; an account with a longer history imports from that boundary forward, and the snapshot-alignment synthetics cover the residual gap. Synchronization fails closed when the exchange reports a maker rebate: the current core external-fill contract accepts only non-negative fees, so the SDK will not silently turn a rebate into a cost or advance the synchronization watermark past it.

Account Status

Use status(connection) to check whether the account is ready for trading through PEAR.

const status = await exchanges.status(connection);

if (status.status === 'warning') {
  for (const reason of status.reasons) {
    console.warn(reason);
  }
}

Possible warning reasons:

ReasonMeaning
api_key_invalidAPI credentials failed the exchange account check
hyperliquid_api_wallet_invalidHyperliquid agent wallet is missing or invalid
hyperliquid_builder_fee_not_approvedHyperliquid builder fee approval is missing
hyperliquid_account_mode_check_failedHyperliquid account mode could not be read
hyperliquid_account_mode_unsupportedHyperliquid reports an account mode this SDK does not model
lighter_api_key_invalidLighter API key cannot create an auth token
lighter_integrator_not_approvedLighter integrator approval is missing

One-Shot Snapshot (no WebSocket)

When you only need the current balance and positions once — a request/response read rather than a live subscription — use snapshot(). It fetches over REST, opens no WebSocket, and leaves nothing running.

const { balance, positions } = await exchanges.snapshot(tradeAccountId);

balance?.accountMode;
balance?.totalEquity;
balance?.availableMargin;
for (const p of positions) {
  console.log(p.symbol, p.side, p.leverage, p.liquidationPrice);
}

snapshot() returns the same AccountBalance / AccountPosition shapes the live tracker produces — { balance: AccountBalance | null; positions: AccountPosition[] } — so either path can feed the same consumer. balance is null when the account mode cannot be represented safely. Hyperliquid Standard, Unified and Portfolio Margin each have their own calculation; an abstraction string this SDK does not model fails closed and is surfaced by account status.

There is nothing to tear down: no disconnect(), no socket. This is the read a stateless caller (such as an MCP tool) wants.

Hyperliquid Unified balances come from the spot clearinghouse state and use the documented maximum per-collateral maintenance ratio. Standard balances use per-DEX margin summaries, including isolated positions.

Per-Asset Settings: snapshotAssets()

snapshot() carries leverage only for open positions. To read the currently configured leverage and margin mode for any asset — positioned or not — use snapshotAssets().

const assets = await exchanges.snapshotAssets(tradeAccountId, ['BTC', 'ETH']);

assets.BTC?.leverage; // e.g. '20'
assets.BTC?.marginType; // 'cross' | 'isolated'
assets.ETH; // null when the exchange has no readable setting for that asset

It returns Record<string, TrackedAssetInfo | null> keyed by the requested asset id — the same TrackedAssetInfo shape the live trackAsset() path emits, and the same per-exchange identifier it uses (Hyperliquid coin, Binance/Bybit symbol, OKX instId, Lighter market id).

The read is batch-first: each connector services the whole list with as few requests as its exchange allows.

ExchangeRequests for N assetsHow
Binance1all-symbols positionRisk
Lighter2account read + market defaults
OKXceil(N/20)leverage-info accepts 20 instIds per call
Bybit2 + ksettleCoin position lists; per-symbol fill-in for the k non-positioned symbols (direct per-symbol reads when N ≤ 2)
HyperliquidN (parallel)activeAssetData info request is per-coin (unauthenticated, cheap)

For Lighter, a custom margin setting on a market with no open position is not exposed by the REST account read, so it reports the market default — the same figure the live path shows before a position exists.

Basket Sizing

maxBasketSize() answers "how large can this basket be opened on this account". A basket is atomic — every leg executes or none do — so the answer is one gross notional for the whole structure, and the per-leg figures are that number spread back over the weights, never independently reachable maxima.

const sized = await exchanges.maxBasketSize(tradeAccountId, {
  legs: [
    { asset: 'BTC', side: 'BUY', weight: 60, leverage: '10', marginMode: 'cross' },
    { asset: 'ETH', side: 'SELL', weight: 40, leverage: '10', marginMode: 'cross' },
  ],
});

sized.maxNotional;       // string | null — the whole basket's max GROSS notional in USD
sized.bindingConstraint; // which pool or leg ran out first
for (const leg of sized.legs) {
  console.log(leg.asset, leg.notional, leg.marginRequired);
}

weight is unsigned and the set sums to 100; side does not enter the maths, because a margin requirement does not depend on direction.

The same call is on a running tracker, over either read path:

await tracker.maxBasketSize(input);         // live path
await tracker.snapshotMaxBasketSize(input); // REST only, no socket

Sizing charges each leg's margin to the collateral pool it actually draws on, and the basket stops at whichever pool runs out first. bindingConstraint names it: { kind: 'cross-pool', pool } when a pool's budget bound the basket, { kind: 'isolated-leg', asset } when one leg reached its own venue ceiling first.

A leg's margin fraction is 1 / min(requestedLeverage, venueMaxLeverage, currentAccountLeverage). The account's current leverage is in that minimum on purpose: the open path never sets leverage, so a requested leverage the venue has not been told about would overstate the size. On Hyperliquid each leg is additionally capped at its asset's next margin-tier boundary — a single-leg basket then reproduces the venue's own published maxTradeSzs exactly.

maxNotional is null rather than zero in two cases: the venue publishes no per-asset maximum leverage the SDK reads today (Binance, Bybit and OKX all return null), or a leg draws on a collateral pool the SDK could not value. Sizing a basket against a pool it cannot value is exactly the guess this call exists to remove.

Tracking

Each track* method returns an unsubscribe function. Callbacks fire with the current snapshot on subscribe (if available) and on every update.

Balance

AccountBalance is a discriminated union tagged by accountMode — venue and mode in one literal, so narrowing needs no separate venue check. Every variant carries the same core; the tag adds the fields that venue and mode actually publish, and no others.

tracker.trackBalance((balance) => {
  if (!balance) return; // a live mode change can explicitly invalidate the cached balance

  balance.accountMode;         // "hyperliquid:unified" | "binance:singleAsset" | ...
  balance.totalEquity;         // string | null
  balance.withdrawableBalance; // string | null — what can leave the account right now
  balance.availableMargin;     // string | null — a floor across the tracked assets, not a per-asset figure
  balance.poolsShared;         // boolean
  balance.timestamp;

  for (const pool of balance.perCollateral) {
    pool.asset;           // "USDT", "USDC", "HYPE", ...
    pool.equity;          // in THAT asset's own units
    pool.availableMargin; // string | null
    pool.withdrawable;    // string | null
  }
});

null means "this venue cannot tell us" — never zero. Every nullable field follows that one rule: a figure the venue does not publish, or one that cannot be derived without guessing, is null. A zero is a zero, and the two are never interchangeable.

perCollateral rows are in each asset's own units, never converted. A row is never turned into USD and two rows are never added together — a USDC row and an ETH row share a column and nothing else. The only USD figures in a variant are the scalars.

poolsShared says what those rows mean. true: one pool funds every position, so the rows are holdings inside it and only the scalars are spendable. false: the rows are separate budgets that cannot fund each other — on Binance single-asset a USDT-settled contract draws USDT margin and a USDC-settled one draws USDC, and neither can pay for the other.

On the three venues whose pools are separate and whose account-level figures cover one currency only — Binance single-asset, OKX Spot and OKX Futures — the core scalars carry the venue's own figure when exactly one pool holds a non-zero balance, and null once two or more do. They are never summed across pools. Hyperliquid splits by mode: Standard and Unified report poolsShared: false with one row per collateral token, while Portfolio Margin reports true — it lends against every holding at that coin's LTV, so the rows are holdings in one pool. All three keep their scalars in every case: availableMargin is already a per-asset floor.

Narrowing on accountMode

Fields beyond the core belong to a variant. Switch on the tag to reach them, and write no default: clause — then a mode added in a later release fails the build here instead of silently returning undefined.

import type { AccountBalance } from '@pear-protocol/exchanges-sdk';

function unrealizedPnlOf(balance: AccountBalance): string | null {
  switch (balance.accountMode) {
    // Hyperliquid and Lighter carry full per-mode detail.
    case 'hyperliquid:standard':
    case 'hyperliquid:unified':
    case 'hyperliquid:portfolioMargin':
    case 'lighter:classic':
    case 'lighter:unified':
      return balance.unrealizedPnl;

    // The three CEXs carry the core only, so there is no figure to return.
    case 'binance:singleAsset':
    case 'binance:multiAsset':
    case 'bybit:isolatedMargin':
    case 'bybit:regularMargin':
    case 'bybit:portfolioMargin':
    case 'okx:spot':
    case 'okx:futures':
    case 'okx:multiCurrencyMargin':
    case 'okx:portfolioMargin':
      return null;
  }
}

Each variant is exported as both a zod schema and its inferred type — HyperliquidStandardBalance, HyperliquidUnifiedBalance, HyperliquidPortfolioMarginBalance, LighterClassicBalance, LighterUnifiedBalance, BinanceSingleAssetBalance, BinanceMultiAssetBalance, BybitIsolatedMarginBalance, BybitRegularMarginBalance, BybitPortfolioMarginBalance, OkxSpotBalance, OkxFuturesBalance, OkxMultiCurrencyMarginBalance, OkxPortfolioMarginBalance — alongside AccountBalanceCore and CollateralBalance.

Positions

tracker.trackPosition((positions) => {
  for (const p of positions) {
    p.symbol;
    p.side;             // "long" | "short" | "both"
    p.size;
    p.entryPrice;
    p.unrealizedPnl;
    p.leverage;
    p.marginType;       // "cross" | "isolated"
    p.liquidationPrice; // string | null
  }
});

Closed positions (size === '0') are dropped automatically.

Per-Asset Leverage & Margin

tracker.trackAsset('ETH', (info) => {
  info.coin;
  info.leverage;
  info.marginType;
});

Asset symbol format per exchange:

ExchangeFormatExample
Binance<BASE><QUOTE>BTCUSDT
Bybit<BASE><QUOTE>BTCUSDT
Hyperliquidbase coinBTC
Lighterbase coinBTC
OKXinstIdBTC-USDT-SWAP

Cached Reads (live tracker)

Synchronous getters for the latest cached state on a running tracker:

tracker.getBalance();            // AccountBalance | null
tracker.getPositions();          // AccountPosition[]
tracker.getTrackedAsset('ETH');  // TrackedAssetInfo | null
tracker.isConnected;
tracker.isInitialized;           // true after first balance/position state (asset metadata alone does not count)

Account Mode Labels

balance.accountMode names the venue and the mode together. Render it with the label the venue's own account page uses:

import { ACCOUNT_MODE_LABELS } from '@pear-protocol/exchanges-sdk';

ACCOUNT_MODE_LABELS['hyperliquid:unified']; // "Unified Account"
ACCOUNT_MODE_LABELS['binance:singleAsset']; // "Single-Asset Mode"
ACCOUNT_MODE_LABELS[balance.accountMode];

The map covers every member of AccountMode, so a mode added later cannot leave a gap.

Cleanup

await tracker.disconnect(); // closes the WebSocket opened by start()

Credentials and the trade account are fetched automatically on connect; Hyperliquid and Lighter account identifiers come from connection.account.exchangeIdentifier.

FAQs

Package last updated on 23 Aug 2026

Related posts