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
positionKey | basketKey | |
|---|---|---|
| Answers | Which of my positions is this? | Which basket is this, for anybody? |
| Hashed input | Your 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 account | Yes. Two traders in BTC/ETH hold two different position keys. | No. Every trader in BTC/ETH reports one key. |
| Carries the side | No. Long BTC / short ETH and short BTC / long ETH share one key. | Yes. Those are two different baskets. |
| Carries the connector | No. 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 from | GET /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.
| Change | New key? | Result |
|---|---|---|
| A different instrument in any leg | Yes | A new position. BTC/ETH and BTC/SOL never meet. |
| An extra leg, or one leg fewer | Yes | A new position. BTC/ETH and BTC/ETH/SOL are separate baskets. |
| A different trade account | Yes | A new position, even on the same instruments. |
| A different leg size or notional | No | Adjusts the existing position. |
| Different leg weights | No | Adjusts the existing position. |
| The opposite side on the same instruments | No | Adjusts the existing position. Side is not hashed. |
| The legs sent in a different order | No | The 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:
- Hash the trade account and the legs'
symbolvalues into a position key. - Look for a position on that key that is not closed.
- 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.
| Limit | Enforced on | Rejected with |
|---|---|---|
| One open position per key. | POST /trade/open | Nothing. 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.
| Read | Field |
|---|---|
GET /positions/lite (SDK: sdk.core.positions.openLite) | positions[].positionKey |
GET /schedules and POST /schedules | positionKey on the schedule |
GET /ladders, GET /ladders/{ladderId}, and POST /ladders | positionKey on the ladder |
GET /notifications | parameters.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
deriveBasketKeyinstrument IDs, not tickers.'BTC'and'0'produce different keys, and only the ID matches whatGET /markets/basketsreports on Hyperliquid. See Trade Idea. generateDeterministicPositionKeylowercases 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.
Net Funding
What it costs to hold a basket once the paid and received funding legs net out, and why the sign on GET /markets/baskets is the opposite of the trader's-cost convention.
Synthetic Position
Why Pear's positions, entry prices, and PnL differ from the exchange's own view, and the fields that tell you when the basket view is incomplete.