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

GET/api/v1/positionsAPI key
Scope positions:read. ?externalUserId= your own id for a player, ?status=open|closed|settled, ?limit= 1–200 (default 50), ?cursor=.
GET/api/v1/positions/{id}API key
One position by its positionId. 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.
200 application/json
{
  "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
}
FieldMeaning
externalUserIdYour own id for the player. Predicta’s internal uuid is never published.
contractIdThe opaque ctr_… contract. Settlement follows this contract and no other.
quantityContracts held. Each settles at $1.00 or $0.00. Size an exit with this.
averagePriceWeighted average entry price.
statusopen, closed or settled.
operatorMoney.costBasisCash already spent on what is STILL held.
operatorMoney.realisedPnlBooked 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.

shell
# 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"}'
typescript
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 quantity and books into realisedPnl. The remainder stays open at the same averagePrice — 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.