PearPear
API IntegrationImportant Terms

Position Key

The deterministic hash that decides whether a trade lands on an existing position or opens a new one, and how it differs from a basketKey.

A position key is a hash of the instruments a basket holds. Pear derives it from your trade account and the basket's instrument IDs, then uses it to find the position a trade belongs to. This is why opening the same basket twice adjusts one position instead of creating a second.

basketKey is the same kind of hash over a different input. Both are SHA-256 digests, printed as 64 lowercase hex characters. Neither is issued by the exchange. Pear computes both, the venues know nothing about either, and you can compute both yourself.

Two keys, two questions

positionKeybasketKey
AnswersWhich of my positions is this?Which basket is this, for anybody?
Hashed inputYour trade account ID, lowercased, plus every leg's instrument ID, sorted.The instrument IDs held long and the instrument IDs held short, each side sorted.
Carries the accountYes. Two traders in BTC/ETH hold two different position keys.No. Every trader in BTC/ETH reports one key.
Carries the sideNo. Long BTC / short ETH and short BTC / long ETH share one key.Yes. Those are two different baskets.
Carries the connectorNo. A trade account has exactly one exchange, so a position key is venue-bound in practice.No. But instrument IDs are venue-specific, so the same asset pair keys differently on two venues.
You get it fromGET /positions/lite, schedules, ladders, notifications.GET /markets/baskets.

The two are never equal. They hash different payloads, so a position key never appears where a basket key is expected and the reverse is rejected.

On the wire a positionKey is only ever validated as a non-empty string, so treat it as opaque. Do not test it against a hex pattern and do not try to read the instruments back out of it. A basketKey is validated: POST /markets/baskets/positions rejects anything that does not match 64 lowercase hex characters with a 400.

What changes a position key

Only the account and the instrument set. Nothing else about the trade is hashed.

ChangeNew key?Result
A different instrument in any legYesA new position. BTC/ETH and BTC/SOL never meet.
An extra leg, or one leg fewerYesA new position. BTC/ETH and BTC/ETH/SOL are separate baskets.
A different trade accountYesA new position, even on the same instruments.
A different leg size or notionalNoAdjusts the existing position.
Different leg weightsNoAdjusts the existing position.
The opposite side on the same instrumentsNoAdjusts the existing position. Side is not hashed.
The legs sent in a different orderNoThe instrument IDs are sorted before hashing.

The side row is the one that surprises people. If you hold long BTC / short ETH and you send short BTC / long ETH, Pear does not open a second, opposing position. It routes the trade into the position you already hold, where it reduces or flips the exposure. Read the current exposure from GET /positions before you send an opposing basket. See Synthetic Position.

Why the second open is an ADJUST

One trade account holds at most one open position per position key. Pear enforces that, so an open on a key that is already held has nowhere new to go.

POST /trade/open (SDK: sdk.core.trade.open) therefore does this on every call:

  1. Hash the trade account and the legs' symbol values into a position key.
  2. Look for a position on that key that is not closed.
  3. If one exists, adjust it. If none exists, open a new one.

The endpoint is the same either way, and the status is 202 either way. What changes is the response: execution.intent reads "ADJUST" instead of "OPEN", and an extra position object carries the exposure the adjustment starts from. Branch on execution.intent, never on the endpoint you called. Basket Trade shows both response shapes.

A position that has closed frees its key. The next open on those instruments creates a fresh position with a new id and the same positionKey.

Constraints counted per position key

Three limits are enforced against the key, not against a position ID. Two of them apply even when no position exists yet, because a resting instruction is keyed before it fires.

LimitEnforced onRejected with
One open position per key.POST /trade/openNothing. The trade becomes an ADJUST.
One active TWAP schedule per key, counting opening and closing schedules together.POST /schedules (SDK: sdk.core.schedules.create)409, errorCode RESOURCE_BUSY. The message names the schedule already holding the key.
50 active ladder rungs per key, summed across every ladder on that key.POST /ladders (SDK: sdk.core.ladders.create)400, errorCode VALIDATION_ERROR, Too many active ladder rungs for this position key.

Two consequences worth planning around:

  • You cannot run a closing TWAP on a basket while an opening TWAP is still running. Cancel the first with PATCH /schedules/{scheduleId}/cancel, then create the second.
  • The ladder cap counts rungs, not ladders. Two ladders of 30 rungs on one basket do not fit. The second is rejected whole; no partial ladder is created. Cancelled and fired rungs stop counting, so the cap is on what is still resting.

A ladder also refuses to build on a busy basket. If a position on that key exists but is not OPEN, or has an execution running against it, POST /ladders returns 409 RESOURCE_BUSY.

Where the key appears

positionKey is the join between a position and the instructions working on the same basket. Nothing else joins them: none of the list endpoints take a positionKey filter, so match on it client-side.

ReadField
GET /positions/lite (SDK: sdk.core.positions.openLite)positions[].positionKey
GET /schedules and POST /schedulespositionKey on the schedule
GET /ladders, GET /ladders/{ladderId}, and POST /ladderspositionKey on the ladder
GET /notificationsparameters.positionKey, on the auto-rebalance and discarded-bracket notifications

GET /positions does not return it. The full position list carries the position id and no key. Call GET /positions/lite to map keys to IDs, then read the detail by ID. See Open Position.

Triggers and executions carry no positionKey on the wire either. A trigger names its position at context.position.id. See Orders and Executions.

Compute a key yourself

Both derivations are exported from @pear-protocol/utils, so you can predict a key without calling the API.

import { deriveBasketKey, generateDeterministicPositionKey } from '@pear-protocol/utils';

// A basket key: the instrument ids held long, then the ones held short.
// Long BTC ("0") against short ETH ("1") on Hyperliquid.
deriveBasketKey(['0'], ['1']);
// '232a1acc52e088e456f29b96abd05d6169a8be4c21f8d7f1d3ee1c71afd47fe9'

// Reverse the sides and it is a different basket.
deriveBasketKey(['1'], ['0']);
// 'c64dc186a401be5636110557374d90f1247c2abece0afb489fbc7aac0b6ed641'

// A position key: your trade account, plus every leg's instrument id.
generateDeterministicPositionKey({
  tradeAccountId: '9c1f0f2e-2b1a-4c33-9f0e-7d5b6a2c8e41',
  symbols: ['0', '1'],
});
// 'dbcb36bae05cc7d71c58da306ad1acafa1d8ced78e487472fcd6e534b6ed0b4a'

// Same account, same two instruments, either way round: the same key.
generateDeterministicPositionKey({
  tradeAccountId: '9c1f0f2e-2b1a-4c33-9f0e-7d5b6a2c8e41',
  symbols: ['1', '0'],
});
// 'dbcb36bae05cc7d71c58da306ad1acafa1d8ced78e487472fcd6e534b6ed0b4a'

Both functions sort their inputs, so leg order never matters and neither call mutates the array you pass.

Two rules for using a computed key:

  • Feed deriveBasketKey instrument IDs, not tickers. 'BTC' and '0' produce different keys, and only the ID matches what GET /markets/baskets reports on Hyperliquid. See Trade Idea.
  • generateDeterministicPositionKey lowercases the trade account ID for you, but nothing normalises the instrument IDs. Pass them exactly as the instruments endpoints spell them.

Compute the position key before a trade and check it against GET /positions/lite. If it is already there, your POST /trade/open will come back as an ADJUST, and you can tell the user before they send it.

One-sided baskets

Pear derives a basketKey for a position from the signs of its exposure, and a basket with legs on only one side gets its quote asset filled in on the empty side. A long-only USDC-quoted basket keys as long ["0"] against short ["USDC"], not against an empty list. That canonicalisation happens on Pear's side. If you need the key for a one-sided basket, read it from GET /markets/baskets rather than deriving it.

On this page