Market Data
Exchange-native chart candles, order books, and historical funding, read straight from the venue with @pear-protocol/market-sdk.
@pear-protocol/market-sdk reads market data straight from the exchange, not through the Pear gateway. Reach for it when the gateway does not hold what you need: chart candles for a whole basket, a live order book, or a historical funding series.
It covers Binance, Bybit, Hyperliquid, OKX, and Lighter behind one exchange-agnostic interface.
| Question | Package |
|---|---|
| What can I trade, and at what price right now? | core-sdk, through the gateway. See Trade Idea. |
| What has this pair done for the last month, bar by bar? | market-sdk |
| What does the book look like ten levels deep? | market-sdk |
| What has this basket cost in funding over the last year? | market-sdk |
This SDK is optional. Only core-sdk is required, and nothing here places an order, reads a position, or needs a Pear credential. It is a read-only view of public venue data.
npm install @pear-protocol/market-sdkThree classes, one transport between them:
| Class | Gives you |
|---|---|
Chart | OHLC bars for a whole basket, historical and live. |
Orderbook | Aggregated depth for one market, plus best bid, best ask, and spread. |
FundingRate | Historical funding per asset or netted across the basket, annualized. |
The transport
CreateTransport(connector) opens one WebSocket to the venue. Every class takes it as transport.
import { CreateTransport } from '@pear-protocol/market-sdk';
const transport = CreateTransport('hyperliquid');connector is one of hyperliquid, binance, bybit, okx, or lighter. Anything else throws Unsupported connector: <name>.
A second argument carries options:
| Option | Rule |
|---|---|
authToken | An async function returning a token, or undefined. Only Lighter reads it, where authenticated traffic earns a wider rate limit on the same public data. Omit it to stay anonymous. |
The SDK never holds venue credentials. You mint the token; the SDK only calls your function and caches what it returns.
Who destroys what
One transport can carry every class at once, and several instances of each. Each object cleans up only itself. The transport is destroyed separately, by whoever created it, last.
chart.destroy();
book.destroy();
funding.destroy();
transport.destroy(); // Closes the socket. Nothing above does this for you.Destroying an object never closes the socket, so a Chart and an Orderbook sharing a transport survive each other.
Three things to get right
These bite on every class.
1. Times are milliseconds. startTime, endTime, Bar.time, FundingRateEntry.time, and OrderbookSnapshot.ts are all epoch milliseconds. Charting libraries commonly want seconds. Convert at the boundary, not in the middle.
2. Weights are percents. A TokenSelection is { symbol, weight }, and the SDK divides weight by 100 before it uses it. The gateway is the other way round: its request bodies take fractions summing to 1. A gateway basket weighted 0.5 / 0.5 is 50 / 50 here. See Weighted Price Ratio.
Nothing validates the weights. Passing 0.5 where 50 was meant does not fail; it raises each price to a hundredth of the intended power and quietly plots the wrong curve.
For a plain one-long / one-short pair, 100 / 100 is the other common setting. It gives the raw BTC / ETH ratio, where 50 / 50 gives its square root. Both trend the same way, and direction is what a ratio is read for, so pick one and stay with it.
3. Symbols are the venue's own market name. These classes talk to the exchange, so they take the exchange's name for the market, not the gateway's instrument ID. On Binance, Bybit, OKX, and Lighter the two are the same string. On Hyperliquid they differ: here you pass the ticker BTC, where a gateway trade leg takes "0". base from GET /instruments is the name this SDK wants.
Chart
import { Chart, CreateTransport } from '@pear-protocol/market-sdk';
const transport = CreateTransport('hyperliquid');
const chart = new Chart({
transport,
longTokens: [{ symbol: 'BTC', weight: 100 }],
shortTokens: [{ symbol: 'ETH', weight: 100 }],
candleInterval: '1h',
});
const endTime = Date.now();
const startTime = endTime - 30 * 24 * 60 * 60 * 1000;
const bars = await chart.getBars('weighted-ratio', startTime, endTime);
// [{ time: 1787674401408, open: 24.3117, high: 24.5502, low: 24.0181, close: 24.4008 }, ...]
const subId = chart.subscribeRealtimeBars('weighted-ratio', (bar) => render(bar));
// Later.
chart.unsubscribeRealtimeBars(subId);
chart.destroy();
transport.destroy();Options
| Option | Default | Rule |
|---|---|---|
transport | — | Required. From CreateTransport. |
longTokens | [] | { symbol, weight }[]. Weight is a percent. |
shortTokens | [] | Same shape. |
candleInterval | 1h | See Intervals. |
Both token lists are optional, so a chart with neither is legal and returns no bars. Change them later with setTokens(longTokens, shortTokens) and setCandleInterval(interval); both clear the cache for what they invalidate.
Modes
getBars(mode, startTime, endTime) takes the mode as its first argument.
| Mode | What it plots |
|---|---|
weighted-ratio | The weighted geometric ratio: each price raised to weight / 100, positive for longs and negative for shorts, multiplied together. The measure a weighted_ratio trigger condition is evaluated against. |
price-ratio | The weighted sum of the long prices divided by the weighted sum of the short prices. Both sides scale together, so only the weights within each side change the result. |
performance | A notional 1000 portfolio. Each leg is allocated weight / 100 × 1000; longs grow with their price and shorts grow inversely. Starts near 1000 when the weights sum to 100. |
The exported ChartType also lists price, which does not work. getBars('price', …) throws Unsupported chart type: price, and subscribeRealtimeBars('price', …) is worse: it returns an id and then never fires. To chart one asset, use getAssetBars and subscribeRealtimeAssetBars below.
performance returns flat bars, where open, high, low, and close all hold the portfolio value. Render it as a line, not as candles. The other two modes return real OHLC.
Intervals
1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 3d, 1w, 1M.
Two venues do not honour the whole list:
- Lighter serves
1m,5m,15m,30m,1h,4h,12h, and1dnatively, and the SDK synthesizes1wfrom daily candles. The other five,3m,2h,8h,3d, and1M, throwLighter does not support the '<interval>' interval. - Bybit has no 3-day resolution, so
3dsilently returns daily bars. Check the gap betweenBar.timevalues if that matters to you.
Historical bars
const bars = await chart.getBars('weighted-ratio', startTime, endTime);Bar is { time, open, high, low, close }, all numbers, time in milliseconds, oldest first.
A basket bar exists only where every leg has a candle at that exact timestamp. A leg that was not listed yet, or a venue gap in one leg, drops the whole bar rather than plotting a partial basket. If any one leg returns no candles at all, getBars returns [].
That is also what a failed fetch looks like. A venue error on one leg is logged, not thrown: the leg comes back empty and the basket comes back empty. getBars resolves rather than rejecting, so an empty array means "no data or a failed read", never "the basket was flat". Nothing about the failure is cached, so calling again retries.
getEffectiveDataBoundary() returns the oldest timestamp the basket can be plotted from, in milliseconds, or null before anything is fetched. It is the youngest of the legs' listing dates, so an infinite-scroll chart should stop paging back there instead of requesting windows that can only come back empty.
clearCache() drops every fetched candle for this chart.
Live bars
const subId = chart.subscribeRealtimeBars('weighted-ratio', (bar) => render(bar));
chart.unsubscribeRealtimeBars(subId);The first subscribe attaches the venue's candle stream to the transport. Each frame recomputes the current bar from the latest candle of every leg and calls you with it, so you receive the same open bar repeatedly as it updates, not one bar per interval.
Three behaviours to plan for:
- Nothing arrives until every leg has ticked. One quiet leg holds the whole basket back.
performanceneeds a baseline first. It emits nothing untilgetBars('performance', …)has run once and returned bars, because the baseline prices come from that call.- The subscription id is derived from the mode and the tokens, not from the callback. Subscribing twice with the same mode and the same tokens replaces the first callback instead of adding a second.
A callback that throws is caught and logged, and the stream carries on.
One asset at a time
const btc = await chart.getAssetBars('BTC', startTime, endTime);
const subId = chart.subscribeRealtimeAssetBars('BTC', (bar) => render(bar));
chart.unsubscribeRealtimeBars(subId);Both take a symbol that is already in longTokens or shortTokens, and throw Symbol "X" is not part of the configured long or short tokens otherwise. unsubscribeRealtimeBars cancels both kinds of subscription.
Orderbook
import { CreateTransport, Orderbook } from '@pear-protocol/market-sdk';
const transport = CreateTransport('binance');
const book = new Orderbook({
transport,
symbol: 'BTCUSDT',
aggregation: 10,
depth: 10,
});
const subId = book.subscribe((snapshot) => render(snapshot.bids, snapshot.asks));
// Widen the buckets. Listeners are called again immediately with the new view.
book.setAggregation(100);
book.unsubscribe(subId);
book.destroy();
transport.destroy();The constructor connects. It subscribes to the venue's depth stream as soon as it is built, before you call subscribe. Build one per market you want.
Options
| Option | Default | Rule |
|---|---|---|
transport | — | Required. |
symbol | — | Required. One market, the venue's own name. |
aggregation | 0 | Price bucket size, such as 0.1, 1, 10. 0 means no aggregation. |
depth | 10 | Levels returned per side. |
snapshottedPrice | — | A recent mid price. Hyperliquid only, see below. |
The surface
| Method | Does |
|---|---|
subscribe(cb) | Registers the callback and returns the symbol as the subscription id. |
unsubscribe(id) | Clears the callback. Throws Subscription "<id>" not found if the id is not the symbol. |
setAggregation(n) | Changes the bucket size and re-emits at once, without waiting for the next frame. |
getAggregation() | The bucket size in force. |
setSnapshottedPrice(p) | Updates the reference mid price. Hyperliquid only. |
getSnapshot() | The book right now, or null before the first frame lands. |
bbo | A getter: best bid, best ask, spread, and spread percent. |
destroy() | Stops the stream and drops the callback. |
There is one callback, not a list. A second subscribe replaces the first. Fan out yourself if more than one part of your app needs the book.
The snapshot
{
"symbol": "BTCUSDT",
"bids": [
{ "price": 99990, "size": 12.418 },
{ "price": 99980, "size": 8.902 }
],
"asks": [
{ "price": 100010, "size": 9.771 },
{ "price": 100020, "size": 14.03 }
],
"aggregation": 10,
"ts": 1787678001408
}| Field | Meaning |
|---|---|
symbol | The market, echoed back. |
bids | { price, size }[], price-descending, at most depth entries. |
asks | { price, size }[], price-ascending, at most depth entries. |
aggregation | The bucket size these levels were built at. |
ts | Milliseconds since epoch of the last applied update. |
size is in base-asset units. Both price and size are plain JSON numbers here, not decimal strings.
Bids round down to their bucket boundary and asks round up, so a raw price never lands on the wrong side at an exact boundary, and the two sides never overlap.
Best bid and offer
const { bestBid, bestAsk, spread, spreadPct } = book.bbo;{ "bestBid": "99995", "bestAsk": "100005", "spread": "10", "spreadPct": "0.01" }All four are decimal strings, or null before the first frame arrives. spreadPct is the spread over the mid price, times 100, so "0.01" is one basis point.
bbo is not aggregated on any venue but Hyperliquid. It reads the raw book, while getSnapshot() buckets it. In the example above the raw best bid is 99995 and the aggregated top bid bucket is 99990. Quote the spread from bbo; draw the ladder from getSnapshot(). On Hyperliquid the venue aggregates before the SDK sees it, so both views agree.
Aggregation, and the Hyperliquid exception
Aggregation runs locally on Binance, Bybit, OKX, and Lighter: the SDK keeps the full book and buckets it on the way out.
Hyperliquid aggregates server-side, and needs a reference price to do it. It converts your bucket size into the venue's own significant-figure parameters, which is only possible when both aggregation and snapshottedPrice are above zero. Set aggregation alone on Hyperliquid and you silently get an unaggregated book. Pass snapshottedPrice in the constructor, and refresh it with setSnapshottedPrice() when the market moves far enough that the old one no longer represents the mid.
setAggregation() on Hyperliquid resubscribes to the venue with new parameters, so the next frame is a fresh snapshot rather than an immediate local re-bucket.
Which bucket sizes to offer
getAvailableAggregations(connector, config) returns the sensible bucket sizes for a market, so a UI can render a dropdown instead of guessing.
import { getAvailableAggregations } from '@pear-protocol/market-sdk';
getAvailableAggregations('hyperliquid', { maxDecimals: 1, midPrice: 100000 });
// [10, 100, 1000]
getAvailableAggregations('binance', { tickSize: 0.1, midPrice: 100000 });
// [0.1, 1, 10, 100, 1000]| Connector | Config |
|---|---|
hyperliquid | { maxDecimals, midPrice } |
| everything else | { tickSize, midPrice } |
Take maxDecimals from precision.price.decimals and tickSize from precision.price.tick on GET /instruments, and midPrice from GET /prices.
The list is the base increment times 1, 10, 100, 1000, and 10000, capped so no bucket reaches the mid price and none leaves fewer than two significant figures. Hyperliquid returns fewer options, because the venue also caps aggregation at five significant figures, which drops the finest buckets from the list.
FundingRate
import { CreateTransport, FundingRate } from '@pear-protocol/market-sdk';
const transport = CreateTransport('hyperliquid');
const funding = new FundingRate({
transport,
longTokens: [{ symbol: 'BTC', weight: 50 }],
shortTokens: [{ symbol: 'ETH', weight: 50 }],
});
// One leg, bucketed by UTC day, over the last month.
const btc = await funding.getAssetRates('BTC', '1d', '1m');
// [{ time: 1787529600000, rate: 0.000312, annualizedRate: 0.11388 }, ...]
// The whole basket, netted.
const basket = await funding.getBasketRates('1d', '1m');
// [{ time: 1787529600000, rate: -0.000056, annualizedRate: -0.02044 }, ...]
funding.destroy();
transport.destroy();FundingRate opens no socket of its own; it reads the venue's REST history. It still takes the transport, which supplies the connector and, on Lighter, the auth token.
Options
| Option | Default | Rule |
|---|---|---|
transport | — | Required. |
longTokens | [] | { symbol, weight }[]. Weight is a percent. |
shortTokens | [] | Same shape. |
setTokens(longTokens, shortTokens) swaps the basket and drops the cache for anything added or removed. clearCache() drops all of it.
The two reads
| Method | Returns |
|---|---|
getAssetRates(symbol, aggregation?, duration?) | One leg's own series. Throws Symbol "X" is not part of the configured long or short tokens for a symbol that is not in the basket. |
getBasketRates(aggregation?, duration?) | The basket's netted series. |
FundingRateEntry is { time, rate, annualizedRate? }. time is the bucket's start in milliseconds, oldest first.
There is no start or end argument. duration selects a trailing window that ends at the top of the current hour. To chart a fixed historical range, fetch the widest duration you need and slice the result yourself.
| Argument | Values | Default |
|---|---|---|
aggregation | none, 1d, 1w, 1M | 1d |
duration | 1w, 1m, 1y | 1m |
1M is a calendar month bucket; 1m is a one-month window. They are different arguments, and the case matters.
duration: '1y' with aggregation: 'none' is silently upgraded to 1d, because a year of raw hourly rates is not a series anyone plots.
Buckets are UTC. Days start at midnight, weeks start Monday, months start on the 1st.
How a bucket is built
Two steps, in this order.
- Sum, do not average. Every raw rate inside the bucket is added, because each one was actually charged. A UTC day of Hyperliquid's hourly funding is 24 rates summed.
- Multiply to a year. A bucketed series uses a fixed factor:
365for1d,52for1w,12for1M. An unbucketed series divides the milliseconds in a 365-day year by the average observed gap between entries, so the venue's real interval is inferred rather than assumed.
An unbucketed series of fewer than two entries has no gap to infer from, so it comes back with no annualizedRate field at all. Treat the field as optional.
rate and annualizedRate are ratios, not percents. 0.11388 is 11.388% a year.
The sign is the trader's-cost one
getBasketRates subtracts each long leg's rate and adds each short leg's, weighted, so on this series:
- Negative means you pay to hold the basket.
- Positive means you are paid.
netFunding on GET /markets/baskets uses the opposite convention, where positive means you pay, and it ignores weights entirely. The same basket reads with one sign here and the other sign there. Do not colour a number before you know which one you are holding. See Net Funding for the comparison table and a worked example.
getAssetRates does no netting and no sign flip. It is the venue's own rate for that market, passed through.
A leg with a weight of 0 contributes nothing, and a timestamp that is missing from any leg is dropped from the basket series, the same rule the chart uses.
A failed fetch is cached
A venue error is logged, not thrown, and the leg is cached as empty for that window. Because the cache then looks complete, later calls over the same window do not retry: they keep returning nothing. Call clearCache() before retrying after a failure.
What throws
Everything else degrades quietly instead.
| Message | Cause |
|---|---|
Unsupported connector: <name> | CreateTransport with a venue Pear does not support. |
Unsupported chart type: price | getBars('price', …). Use getAssetBars instead. |
Symbol "X" is not part of the configured long or short tokens | getAssetBars, subscribeRealtimeAssetBars, or getAssetRates naming a symbol outside the basket. |
Subscription "<id>" not found | Orderbook.unsubscribe with anything but the symbol. |
Lighter does not support the '<interval>' interval | A chart interval Lighter has no resolution for. |
The quiet failures matter more, and there are four:
- A venue error while fetching candles returns an empty chart, not a rejection.
- A venue error while fetching funding returns an empty series, and caches it.
subscribeRealtimeBars('price', …)returns an id and never fires.aggregationwithoutsnapshottedPriceon Hyperliquid leaves the book unaggregated.
Log what the SDK warns about. It is the only signal for all four.
Where this fits
| You want | Go here |
|---|---|
| Tradable baskets, live prices, funding right now | Trade Idea |
| Your own fills, positions, and order events | Websocket |
| Your venue account's balance, leverage, and sizing headroom | Venue Accounts |