@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
| Binance | binance | USDM Futures |
| Bybit | bybit | Linear Perpetuals |
| Hyperliquid | hyperliquid | Perpetuals |
| Lighter | lighter | Perpetuals |
| OKX | okx | Linear 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;
console.log(balance.totalEquity, balance.unrealizedPnl);
});
const offPositions = tracker.trackPosition((positions) => {
for (const p of positions) console.log(p.symbol, p.side, p.size);
});
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, without authenticated account-tier enrichment.
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;
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 uses the exchange's globally unique time:coin:tid identity. Existing consumers that persisted bare tid identifiers must account for the transition overlap. Synchronization also 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:
api_key_invalid | API credentials failed the exchange account check |
hyperliquid_api_wallet_invalid | Hyperliquid agent wallet is missing or invalid |
hyperliquid_builder_fee_not_approved | Hyperliquid builder fee approval is missing |
hyperliquid_account_mode_check_failed | Hyperliquid account mode could not be read |
hyperliquid_account_mode_unsupported | Portfolio Margin or deprecated DEX Abstraction cannot be valued safely |
lighter_api_key_invalid | Lighter API key cannot create an auth token |
lighter_integrator_not_approved | Lighter 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?.totalEquity;
balance?.availableToTrade;
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 type cannot be represented safely. Hyperliquid currently supports Standard and Unified calculations; Portfolio Margin and deprecated DEX Abstraction fail closed and are 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;
assets.BTC?.marginType;
assets.ETH;
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.
| Binance | 1 | all-symbols positionRisk |
| Lighter | 2 | account read + market defaults |
| OKX | ceil(N/20) | leverage-info accepts 20 instIds per call |
| Bybit | 2 + k | settleCoin position lists; per-symbol fill-in for the k non-positioned symbols (direct per-symbol reads when N ≤ 2) |
| Hyperliquid | N (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.
Tracking
Each track* method returns an unsubscribe function. Callbacks fire with the current snapshot on subscribe (if available) and on every update.
Balance
tracker.trackBalance((balance) => {
if (!balance) return;
balance.totalEquity;
balance.walletBalance;
balance.unrealizedPnl;
balance.availableToTrade;
balance.initialMarginUsed;
balance.maintenanceMargin;
balance.marginRatio;
balance.accountType;
balance.accountTier;
for (const a of balance.assets) {
a.asset;
a.free;
a.used;
a.total;
a.usdValue;
}
});
Positions
tracker.trackPosition((positions) => {
for (const p of positions) {
p.symbol;
p.side;
p.size;
p.entryPrice;
p.unrealizedPnl;
p.leverage;
p.marginType;
p.liquidationPrice;
}
});
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:
| Binance | <BASE><QUOTE> | BTCUSDT |
| Bybit | <BASE><QUOTE> | BTCUSDT |
| Hyperliquid | base coin | BTC |
| Lighter | base coin | BTC |
| OKX | instId | BTC-USDT-SWAP |
Cached Reads (live tracker)
Synchronous getters for the latest cached state on a running tracker:
tracker.getBalance();
tracker.getPositions();
tracker.getTrackedAsset('ETH');
tracker.isConnected;
tracker.isInitialized;
Account Type Labels
balance.accountType is an exchange-specific enum. Render with:
import { ACCOUNT_TYPE_LABELS } from '@pear-protocol/exchanges-sdk';
ACCOUNT_TYPE_LABELS[balance.accountType];
Cleanup
await tracker.disconnect();
Credentials and the trade account are fetched automatically on connect; Hyperliquid and Lighter account identifiers come from connection.account.exchangeIdentifier.