Venue Accounts
Read balance, positions, leverage, and basket capacity straight from the exchange with @pear-protocol/exchanges-sdk.
@pear-protocol/exchanges-sdk reads the exchange account itself: its balance, its positions, its per-asset leverage and margin mode, and how large a basket it can still open. It talks to Binance, Bybit, Hyperliquid, Lighter, and OKX directly, behind one interface.
Reach for it when the gateway does not hold what you need. The gateway serves Pear's own baskets, orders, and history. It does not serve the venue's account state, so a balance widget, a margin check before an order, or a "max size" button reads from here.
npm install @pear-protocol/exchanges-sdk @pear-protocol/core-sdkIt reads credentials through core-sdk, so it works on any trade account you already connected. It obtains them with POST /trade-accounts/{id}/credentials, which is a write-scoped call: a read API key and a read OAuth grant are both refused with 403.
Construct it
import PearSDK from '@pear-protocol/core-sdk';
import { ExchangesSDK } from '@pear-protocol/exchanges-sdk';
const sdk = new PearSDK({ /* … */ });
const exchanges = new ExchangesSDK({ sdk });| Field | Required | Meaning |
|---|---|---|
sdk | Yes | A @pear-protocol/core-sdk instance. It fetches the credentials and mints Lighter's auth tokens. |
demo | No | Defaults to false. true points every venue read at its test environment. |
proxy | No | { enabled, url? }. Sends the exchange REST calls to url with the original venue host in an X-Proxy-Target header. It is a constructor field because the SDK also runs in the browser, where environment variables do not exist. A config with enabled: false or no url turns the proxy off. |
There is no options key. The approval targets status() needs are a per-call argument, not constructor state. See Check readiness.
The demo flag
demo: true is the only way to reach a venue's test environment from this SDK. No gateway endpoint takes a testnet flag; this is a direct venue read, so the routing is local to the SDK.
| Venue | Where demo: true points |
|---|---|
| Binance | Futures testnet. |
| Bybit | Demo trading. |
| Hyperliquid | Testnet REST and WebSocket. |
| Lighter | Testnet REST and WebSocket. |
| OKX | Simulated trading, on the same host, through the venue's simulated-trading header. |
Two Lighter limits apply in demo mode. Anything that needs a backend-signed payload or an auth token fails closed, because the signing service signs for mainnet only. status() therefore always reports lighter_api_key_invalid on a demo Lighter account without contacting the venue. Public snapshots and live tracking still work.
Choose a read path
| Call | Opens a socket | Returns |
|---|---|---|
snapshot(tradeAccountId) | No | Balance and open positions, once. |
snapshotAssets(tradeAccountId, coins) | No | Configured leverage and margin mode per asset, once. |
maxBasketSize(tradeAccountId, input) | No | How large a basket the account can open. |
connect(tradeAccountId) | No | A connection the tracker, the status check, and the syncer all take. |
createTracker(connection) + tracker.start() | Yes | A live subscription to balance, positions, and per-asset settings. |
Every stateless call takes a trade account id and fetches its credentials itself. Nothing is left running, so there is nothing to tear down.
Read the account once
const { balance, positions } = await exchanges.snapshot(tradeAccountId);
if (balance) console.log(balance.accountMode, balance.totalEquity);
for (const p of positions) console.log(p.symbol, p.side, p.size);balance is null when the account mode cannot be represented safely. That is not a zero balance. Treat it as "unknown", and read the reason from status().
{
"accountMode": "hyperliquid:unified",
"totalEquity": "125430.18",
"withdrawableBalance": "98210.44",
"availableMargin": "97880.1",
"poolsShared": false,
"perCollateral": [
{ "asset": "USDC", "equity": "104300.5", "availableMargin": "97880.1", "withdrawable": "98210.44" },
{ "asset": "HYPE", "equity": "480.25", "availableMargin": null, "withdrawable": "480.25" }
],
"timestamp": 1787678001408,
"tradingEquity": "121200.55",
"vaultEquity": "4229.63",
"stakedEquity": "0",
"portfolioValue": "125430.18",
"unrealizedPnl": "+1820.44",
"perpsMaintenanceMargin": "3310.02",
"unifiedAccountRatio": "0.0264",
"unifiedAccountLeverage": "0.71",
"perDexState": [
{
"dex": "",
"collateralToken": 0,
"accountValue": "121200.55",
"crossNotional": "86400.00",
"crossMaintenanceMargin": "3310.02",
"withdrawable": "98210.44"
}
]
}Every venue and every mode fills these six core fields:
| Field | Type | Meaning |
|---|---|---|
totalEquity | decimal string or null | The whole account in USD. null when the venue cannot state one spendable figure. |
withdrawableBalance | decimal string or null | What can leave the trading account right now. |
availableMargin | decimal string or null | Collateral usable as margin for every tracked asset. A floor across that set, not a per-asset figure. |
poolsShared | boolean | true: one pool funds every position, so only the scalars are spendable. false: the perCollateral rows are separate budgets that cannot fund each other. |
perCollateral | array | One row per collateral pool, each in that asset's own units. Never converted, never summed. |
timestamp | number | Milliseconds since epoch. |
Each perCollateral row is { asset, equity, availableMargin, withdrawable }, the last two nullable.
null always means "the venue cannot tell us", never zero. A blank field on a venue's account endpoint is "not stated". Rendering it as 0 reports a number the venue never published.
Every figure is a string, never a JS number. Precision is load-bearing at these sizes.
A position looks like this:
[
{
"symbol": "BTC",
"side": "long",
"size": "1.5",
"entryPrice": "104200.5",
"unrealizedPnl": "2130.64",
"leverage": "10",
"marginType": "cross",
"liquidationPrice": "62110.0"
},
{
"symbol": "ETH",
"side": "short",
"size": "-25.0",
"entryPrice": "4210.35",
"unrealizedPnl": "-310.20",
"leverage": "10",
"marginType": "cross",
"liquidationPrice": null
}
]| Field | Notes |
|---|---|
symbol | The venue's own market name, not a gateway instrument ID. |
side | long, short, or both. both appears only when a venue reports a position without a direction. |
size | Signed: negative on a short. |
unrealizedPnl | The venue's own signed figure. |
marginType | cross or isolated. |
liquidationPrice | null when the venue publishes none. |
A position whose size reaches '0' is dropped, so a closed position never lingers in the list.
Per-asset settings
snapshot() carries leverage only for assets you hold. To read what an asset is configured to, positioned or not, use snapshotAssets():
const assets = await exchanges.snapshotAssets(tradeAccountId, ['BTC', 'ETH']);{
"BTC": { "coin": "BTC", "leverage": "10", "marginType": "cross" },
"ETH": null
}The map is keyed by the asset id you asked for. null means the venue publishes no readable setting for it. An empty list returns {}, and duplicates are read once.
Use each venue's own identifier, the same one trackAsset() takes:
| Venue | Format | Example |
|---|---|---|
| Binance | <BASE><QUOTE> | BTCUSDT |
| Bybit | <BASE><QUOTE> | BTCUSDT |
| Hyperliquid | Base coin | BTC |
| Lighter | Base coin | BTC |
| OKX | Instrument id | BTC-USDT-SWAP |
On Lighter, an asset with no open position reports the market default, because the venue's account read does not publish a custom setting until a position exists.
Account modes
AccountBalance is a discriminated union tagged by accountMode. The tag names the venue and the mode in one literal, so narrowing needs no separate venue check. Narrow on it to reach the fields that venue and mode actually publish.
accountMode | Venue's own label | Beyond the six core fields |
|---|---|---|
hyperliquid:standard | Standard | spotEquity, perpsEquity, vaultEquity, stakedEquity, unrealizedPnl, maintenanceMargin, crossMarginRatio, crossAccountLeverage, perDexState |
hyperliquid:unified | Unified Account | tradingEquity, vaultEquity, stakedEquity, portfolioValue, unrealizedPnl, perpsMaintenanceMargin, unifiedAccountRatio, unifiedAccountLeverage, perDexState |
hyperliquid:portfolioMargin | Portfolio Margin | tradingEquity, vaultEquity, stakedEquity, portfolioValue, unrealizedPnl, perpsMaintenanceMargin, portfolioMarginRatio, portfolioAccountLeverage, borrowCapUsed, perDexState. Its perCollateral rows are signed, because a borrowed coin holds a negative balance, and add ltv and borrowCapUsed. |
lighter:classic | Classic | tradingEquity, perpetualsEquity, spotEquity, publicPoolsEquity, stakingEquity, creditedInLit, unrealizedPnl, crossLeverage, maintenanceMargin, crossMarginUsage, crossMarginRatio |
lighter:unified | Unified | The Classic set, plus portfolioMargin. |
binance:singleAsset | Single-Asset Mode | Core only. |
binance:multiAsset | Multi-Asset Mode | Core only. |
bybit:isolatedMargin | Isolated Margin | Core only. |
bybit:regularMargin | Cross Margin | Core only. |
bybit:portfolioMargin | Portfolio Margin | Core only. |
okx:spot | Spot Mode | Core only. |
okx:futures | Futures Mode | Core only. |
okx:multiCurrencyMargin | Multi-Currency Margin | Core only. |
okx:portfolioMargin | Portfolio Margin | Core only. |
hyperliquid:standard covers the venue's default and disabled abstractions, which publish identical figures.
Write the switch with no default: clause. A mode added in a later release then fails your build instead of silently returning undefined:
import type { AccountBalance } from '@pear-protocol/exchanges-sdk';
function unrealizedPnlOf(balance: AccountBalance): string | null {
switch (balance.accountMode) {
case 'hyperliquid:standard':
case 'hyperliquid:unified':
case 'hyperliquid:portfolioMargin':
case 'lighter:classic':
case 'lighter:unified':
return balance.unrealizedPnl;
// The three CEXs publish no account-level PnL.
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;
}
}Render the tag with ACCOUNT_MODE_LABELS, which maps every mode to 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"Track the account live
const connection = await exchanges.connect(tradeAccountId);
const tracker = exchanges.createTracker(connection);
await tracker.start();
const offBalance = tracker.trackBalance((balance) => {
if (!balance) return;
render(balance.accountMode, balance.totalEquity);
});
const offPositions = tracker.trackPosition((positions) => renderPositions(positions));
const offAsset = tracker.trackAsset('ETH', (info) => render(info.leverage, info.marginType));
// Later
offBalance();
offPositions();
offAsset();
await tracker.disconnect();connect() opens no socket. start() opens the venue WebSocket, and disconnect() closes it.
| Member | Does |
|---|---|
start() | Opens the venue WebSocket and loads the first state. |
disconnect() | Closes the socket and drops every listener. |
trackBalance(cb) | Subscribes to balance updates. Returns an unsubscribe function. |
trackPosition(cb) | Subscribes to the whole position list on every change. Returns an unsubscribe function. |
trackAsset(asset, cb) | Subscribes to one asset's leverage and margin mode. Returns an unsubscribe function. |
getBalance() | The cached AccountBalance, or null before the first update. Synchronous. |
getPositions() | The cached AccountPosition[]. Synchronous. |
getTrackedAsset(coin) | The cached TrackedAssetInfo for one asset, or null. Synchronous. |
snapshot() | The same one-shot read as exchanges.snapshot(), on an existing connection. |
snapshotAssets(coins) | The same one-shot per-asset read, on an existing connection. |
maxBasketSize(input) | Basket sizing over the live path. |
snapshotMaxBasketSize(input) | Basket sizing over REST alone, so it works before start(). |
isConnected | Whether the socket is up. Read-only. |
isInitialized | Whether the first balance or position state has arrived. Asset metadata alone does not count. Read-only. |
hasListeners | Whether anything is still subscribed. Read-only. |
Every track* method returns an unsubscribe function. Each callback also fires immediately with the current value if one has already arrived, so you do not need a separate first read.
Dropping the last listener on an asset releases that asset's venue subscription. disconnect() clears all three listener sets, so calling the unsubscribe functions afterwards is harmless but pointless.
A callback that throws does not break the tracker. The error is reported and the remaining listeners still run.
Check readiness
An account can be stored, and still be unable to trade, because a key was revoked at the venue or an approval is missing. status() names the problem.
const health = await exchanges.status(connection, {
builderAddress,
builderFeeTenthsBps: 60,
});{ "status": "warning", "reasons": ["hyperliquid_builder_fee_not_approved"] }status is active or warning. reasons is empty on active, and lists every problem found on warning:
| Reason | Venue | Meaning |
|---|---|---|
api_key_invalid | Binance, Bybit, OKX | The venue rejected the stored credentials. |
hyperliquid_api_wallet_invalid | Hyperliquid | The agent wallet is missing, expired, or not the one the account approved. |
hyperliquid_builder_fee_not_approved | Hyperliquid | The builder approval is missing or below the threshold you passed. |
hyperliquid_account_mode_check_failed | Hyperliquid | The account mode could not be read. Retry. |
hyperliquid_account_mode_unsupported | Hyperliquid | The account is in a mode Pear does not model. |
lighter_api_key_invalid | Lighter | The API key cannot mint an auth token. |
lighter_integrator_not_approved | Lighter | The integrator approval is missing or below the thresholds you passed. |
ConnectOptions has five fields, all optional:
| Field | Venue | Meaning |
|---|---|---|
builderAddress | Hyperliquid | The builder address whose approval to check. |
builderFeeTenthsBps | Hyperliquid | The minimum approved builder fee, in tenths of a basis point. |
integratorAccountIndex | Lighter | The integrator account whose approval to check. |
integratorMinPerpsMakerFee | Lighter | The minimum approved perps maker fee, in exchange fee ticks (parts per million). |
integratorMinPerpsTakerFee | Lighter | The minimum approved perps taker fee, in the same units. |
Three rules, and the first two are the common mistakes:
- The options are a per-call argument, not constructor state. The approval targets can change, so you fetch the current values and pass them on every check. Omit them and the approval checks are skipped silently:
status()still reports a broken key, but it can never reporthyperliquid_builder_fee_not_approvedorlighter_integrator_not_approved. - A target without its threshold throws.
builderAddresswith nobuilderFeeTenthsBpsthrows, and so doesintegratorAccountIndexwithout both Lighter fees. A configured target with no threshold to compare against would pass an under-approved account, so the check fails closed instead. - The address or index comes from
GET /fee/recipient, the thresholds do not. That endpoint's whole response is{ recipient? }. You supplybuilderFeeTenthsBps,integratorMinPerpsMakerFee, andintegratorMinPerpsTakerFeeyourself, and they must match what the trading backend is configured with. Set them too high andstatus()reports an approval as missing when it is not.
recipient is absent when no recipient is configured for your role. Orders then name no builder or integrator, so there is nothing to approve: leave the target out of ConnectOptions rather than passing undefined.
Connect an Exchange shows this inside the full connection walkthrough, with a helper that builds ConnectOptions per connector, and the fix for each reason.
status() takes a connection from connect(), not a trade account id.
Size a basket
maxBasketSize() answers one question: how large a basket can this account open. It opens no socket.
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' },
],
});{
"maxNotional": "978801",
"legs": [
{ "asset": "BTC", "notional": "587280.6", "marginRequired": "58728.06" },
{ "asset": "ETH", "notional": "391520.4", "marginRequired": "39152.04" }
],
"bindingConstraint": { "kind": "cross-pool", "pool": "USDC" }
}The input is { legs }, one or more legs:
| Field | Type | Rule |
|---|---|---|
asset | string | The venue's own market name, as in the table above. |
side | BUY or SELL | Carried through, but it does not enter the maths: a margin requirement does not depend on direction. |
weight | number | Unsigned and greater than 0. The set sums to 100. |
leverage | decimal string | The leverage you want. It is capped before it is used. |
marginMode | cross or isolated | Carried through, so your basket passes through unchanged. |
The output:
| Field | Meaning |
|---|---|
maxNotional | The whole basket's maximum gross notional in USD, or null. Not per side and not per asset. |
legs | Each leg's notional and marginRequired at that basket size. These are derived from maxNotional, not independently reachable per-leg maxima. |
bindingConstraint | What ran out first: { "kind": "cross-pool", "pool": … } when a collateral pool's budget bound the basket, { "kind": "isolated-leg", "asset": … } when one leg reached its own venue ceiling. null when the basket could not be sized. |
A basket is atomic: every leg executes or none do. So the answer is one ceiling for the whole structure. Two constraints combine. Each collateral pool is a budget summed across the legs it funds, and the basket stops at the tightest pool. Each leg also has its own venue ceiling, and the tightest of those applies too. Pools cannot fund each other, so a USDT-settled leg and a USDC-settled leg draw on separate budgets.
A leg's margin fraction is 1 / min(requested leverage, venue maximum, the account's current leverage). The account's current setting is in that minimum on purpose: opening a basket does not change leverage, so a leverage the venue has not been told about would overstate the size. On Hyperliquid each leg is capped again at its asset's next margin-tier boundary.
maxNotional is null, never zero, when the basket cannot be sized. Two cases produce it:
- The venue publishes no per-asset maximum leverage this SDK reads today. Binance, Bybit, and OKX all return
nullwith an emptylegsarray. Only Hyperliquid and Lighter are sized today. - A leg draws on a collateral pool that could not be valued. Sizing against a pool nobody can price is exactly the guess this call exists to remove.
A null is not "no collateral". Do not render it as $0.
Sync venue activity
createSyncer(connection) returns an AccountSyncer with synchronizeFills() and synchronizeFunding(). Each runs a whole cycle and returns an outcome rather than throwing on the normal failure paths: 'synced', 'deferred' (fills only), or 'aborted'.
const syncer = exchanges.createSyncer(connection);
const fills = await syncer.synchronizeFills();
const funding = await syncer.synchronizeFunding();This is what makes a position you opened directly on the venue appear in Pear. Syncing Venue Activity documents the whole story, including what each outcome means and what still throws.
Venue helpers
createHelper('hyperliquid' | 'lighter') returns the venue helper already pointed at the network this SDK is configured for:
const hyperliquid = exchanges.createHelper('hyperliquid');
const lighter = exchanges.createHelper('lighter');These are the same objects as the standalone createHyperliquidHelper() and createLighterHelper(sdk) factories. There are two entry points, not two helpers. Use the standalone factories before you have an ExchangesSDK, such as during onboarding, and createHelper afterwards so the network and the SDK instance are already set.
The helpers wrap the on-chain and gateway round trips each venue needs before its credentials work: the Hyperliquid agent wallet and builder-fee approvals, and the Lighter deposit, API key, and integrator approval. Connect an Exchange documents every method.
Related
- Trade Accounts, the accounts this SDK reads.
- Connect an Exchange, the per-venue setup and the readiness walkthrough.
- Syncing Venue Activity, the syncer in full.
- Instrument ID, why this SDK's
symbolis not the gateway's.