Trading
Positions and exits
A fill folds into a position. Positions are held per player, per contract, per side, and are readable and exitable over the API.
How a position is built
A filled order writes four things in one transaction: the fill, the order state, the fee row and the position. They are a single financial fact, and a partial application of them is the one outcome with no clean recovery — a filled order with no position means a player was charged for something they do not hold.
A second buy on the same contract and side folds into the existing position at a weighted average rather than creating a second row. The average is recomputed in SQL against the current row, so two concurrent fills cannot overwrite each other.
Reading positions
/api/v1/positionsAPI keypositions:read. ?externalUserId= your own id for a player, ?status=open|closed|settled, ?limit= 1–200 (default 50), ?cursor=./api/v1/positions/{id}API keypositionId. A settlement record and a settlement webhook both key on that id, so a partner processing one has an id and a single question — what was this? Paging a growing feed to answer it would be absurd.{
"positions": [
{
"positionId": "pos_...",
"externalUserId": "player-8421",
"contractId": "ctr_9f2b...",
"side": "YES",
"status": "open",
"quantity": 34.3056,
"averagePrice": 0.72,
"openedAt": "2026-08-21T04:43:40.282Z",
"updatedAt": "2026-08-21T04:43:40.282Z",
"operatorMoney": { "currency": "USD", "costBasis": "24.70", "realisedPnl": "0.00" }
}
],
"count": 1,
"nextCursor": null
}| Field | Meaning |
|---|---|
externalUserId | Your own id for the player. Predicta’s internal uuid is never published. |
contractId | The opaque ctr_… contract. Settlement follows this contract and no other. |
quantity | Contracts held. Each settles at $1.00 or $0.00. Size an exit with this. |
averagePrice | Weighted average entry price. |
status | open, closed or settled. |
operatorMoney.costBasis | Cash already spent on what is STILL held. |
operatorMoney.realisedPnl | Booked by exits and settlements. Negative on a loss. |
A position carries facts, not instructions
costBasis is cash already debited on the way in, not cash to debit now. realisedPnl is a fact about closed quantity, not a credit to post. The instructions live where they are generated — on the order (operatorMoney, operatorExitMoney) and on the settlement (settlementCredit, voidCredit).
A position that also carried an amount to move would give you two places to book the same dollar from, and the duplicate would only ever show up as a balance that will not reconcile.
There is no mark-to-market, deliberately
Valuing an open position against the current bid would make this response change between two identical calls because a price feed moved, and a number that moves on its own is not something a wallet can reconcile against. Want the exit value? Quote a sell — a real, honourable price rather than an estimate.
Why the cursor sorts on openedAt
A keyset cursor needs an immutable sort key. updatedAt would be more useful for polling and is unusable here: a position sold down mid-page jumps to the front, and the row it displaced is never returned. openedAt never moves.
Closing a position, in full or in part
An exit is an ordinary quote-then-order, with action: "sell". There is no separate close endpoint: one execution path means one set of safety rules, one idempotency index and one money contract.
# 1. Price the exit. Sized in CONTRACTS, from the position's own quantity.
curl -s -X POST "$PREDICTA_BASE/api/v1/quotes" \
-H "Authorization: Bearer $PREDICTA_KEY" \
-H "X-Predicta-User: $PREDICTA_USER" \
-H "content-type: application/json" \
-d '{"marketId":"ctr_9f2b...","side":"YES","action":"sell","contracts":17.15}'
# 2. Spend the quote, exactly like a buy.
curl -s -X POST "$PREDICTA_BASE/api/v1/orders" \
-H "Authorization: Bearer $PREDICTA_KEY" \
-H "X-Predicta-User: $PREDICTA_USER" \
-H "content-type: application/json" \
-d '{"quoteId":"...","clientOrderId":"exit-9031"}'const exitQuote = await player.quoteSell({
contractId: held.contractId,
side: held.side,
contracts: held.quantity / 2,
});
const exit = await player.submitOrder({
quoteId: exitQuote.quoteId,
clientOrderId: `exit-${reservation.id}`,
});
await wallet.credit(exit.operatorExitMoney!.sellProceeds);An exit is sized in contracts, and it credits
A cash-sized sell is refused, and the refusal is right: inverting the fee rounding either oversells or strands a fraction, so “close this out” cannot be expressed in dollars. Pass the position's quantity.
The response carries operatorExitMoney.sellProceeds and operatorMoney: null. sellProceeds is already net of the fees reported beside it; subtracting them again is a double charge, and the null is there so a wallet applying the entry contract by habit cannot debit a player for selling their own position.
- A partial exit reduces
quantityand books intorealisedPnl. The remainder stays open at the sameaveragePrice— an exit does not re-price what is left. - Selling more than is held is refused with
422 insufficient_position. Read the position first; do not infer the quantity from your own order history. - Fee attribution at settlement is pro-rated by the share of bought contracts still held, so a partial close does not over-refund a void.
Partial fills
partially_filled is a real state that a live book will produce. filledQuantity is what the player actually holds and may be less than the quote's contracts.
The money contract already answers it: operatorMoney.actualDebit is what the fill consumed and operatorMoney.releaseAmount is the rest of the authorization, to be handed back. An operator that debits the full stake on a partial fill is keeping money that is not theirs. See The money contract.

