Skip to main content
The positions.* router is the authoritative source for a user’s current holdings across every venue Kairos routes to — polymarket, kalshi (with kalshi_offchain canonicalised to it), predictfun, hyperliquid, and opinion. Reach for it when a bot needs to know what it currently holds, what a position is worth, or how to close one. It reads from the Kairos database — the same positions state that drives the UI’s portfolio screen and the bot engine’s fill tracking — so it reflects any fill the moment our executor records it. For programmatic bots / liquidation scripts, prefer positions.getPositions over the public GET /trader-stats/positions/{wallet_address} REST endpoint. That REST endpoint proxies third-party on-chain indexing for Polymarket and can lag or miss positions that were settled off-exchange (walk-the-book partial fills, redeemed positions, etc.). Auth: getPositions and getPosition accept an API key with the position:read scope (not the general read scope — see API Keys); closePosition requires the trade scope. Pass your credential as the X-Client-Id / X-Api-Key / X-Api-Secret headers. Position management/maintenance procedures are not exposed to API keys.

positions.getPositions

List the authenticated user’s positions across all linked wallets.
Access: Invited — API key with the position:read scope (API keys skip the invite gate) · Rate limit: queries bucket (3600/min default)

Input

There is no checkRedeemable input — redeemable is read from stored position state, never probed on-chain per request.

Example — find stuck Polymarket positions for a bot

Response

hasMore is computed as offset + limit < total. total is the count of matching rows, resolved from a COUNT query only when the first page is full or offset > 0; otherwise it is the returned row count.

Field notes

  • netSize, avgEntryPrice, costBasis, totalFees, realizedPnL are strings to preserve decimal precision. Parse with a big-decimal library, not parseFloat.
  • unrealizedPnLPercent is a percentage (unrealizedPnL / |costBasis| * 100), and is 0 when costBasis is 0.
  • holdingWallet identifies which wallet holds the shares on venues that route per wallet. It is "" on venues that don’t.
  • netSize < 0 means a SELL-to-open (short) position. Kairos treats shorts as synthetic; on Polymarket this manifests as owning the opposite outcome’s shares. When closing, send the absolute quantity.
  • marketResolved = true with redeemable = true means the user won but hasn’t redeemed yet. Redemption is performed by the order-execution service, not the RPC API.
  • marketResolved = true with redeemable = false and netSize != 0 means the user lost (worthless shares). These won’t appear in onlyOpen=true results.

Errors

Gotchas

Price fields are all-or-nothing, and can be null. currentPrice, currentValue, unrealizedPnL, and unrealizedPnLPercent may be null when no quote resolves for the token (fresh markets, resolved markets, Kalshi off-hours). They are all four null together. Your code must handle the null case.
The enrichment fields are absent, not empty, on a cache miss. marketTitle, eventTitle, image, category, endDate, and marketStatus come from the market-metadata cache and are omitted entirely on a miss. The cache is deliberately not consulted for resolved markets, and the lookup is hard-capped (600 ms by default) so a cold cache never stalls the read. Treat all of them as optional, never as a signal about the position.
The provider gate widens without a platform filter. With no platform filter, an API-key request checks every provider the user holds positions on, and again over the providers actually returned. One disabled provider fails the whole call. Pass the narrowest platform filter you can when you only need one venue.
Don’t fall back to trader-stats when this call is slow. The trader-stats REST endpoint (GET /trader-stats/positions/{wallet}) caches responses keyed by wallet + query params. If a transient upstream outage causes a zero-volume response to be cached, subsequent calls can keep seeing total_volume: 0 until the cache refreshes. positions.getPositions reads live position state rather than that cached proxy, so it is not subject to that staleness.

positions.getPosition

Fetch a single position by ID.
Access: Invited · Scope: position:read · Rate limit: queries

Input

Example

Response

Same shape as one positions[] entry from getPositions, enriched with current price and PnL — returned bare, not wrapped in a positions array. The metadata-cache enrichment fields are not populated on this path, so marketTitle and friends are absent.

Errors

404 is deliberately ambiguous. “Doesn’t exist” and “isn’t yours” return the same Position not found, so position IDs can’t be enumerated. Note that closePosition makes the opposite choice and returns 403 for another user’s position.

positions.closePosition

Derive the order parameters needed to close an existing position. The handler validates ownership, reads the position’s netSize, and returns a ready-to-submit order spec — side (SELL for longs, BUY for shorts), size (|netSize|), and the market/token identifiers.
It does not place the order. closePosition only computes the spec; you submit it yourself to the order execution service. It rejects with 400 "Position is already closed" when netSize == 0.
Access: InvitedMutation · Scope: trade · Rate limit: mutations bucket (1800/min default) This is the only order-related mutation on the RPC server. There is no orders.submitOrder / orders.cancelOrder RPC procedure — submitting and cancelling orders are REST endpoints on the order execution service (https://execution.kairos.trade/), authenticated with the same three API-key headers under the trade:execute scope. See API Keys for the endpoint list. closePosition’s orderData is shaped for that service but uses different field names — see the example below.

Input

Example — close every open position

closePosition runs on the RPC server (trade scope); the actual submission runs on the order execution service (trade:execute scope) with snake_case field names, so orderData must be translated rather than forwarded as-is:

Response

Errors

Gotchas

orderData.price is exactly whatever you passed as limitPrice. The server does not look up a mark, a bid, or a mid. For a market close it will be null unless you supply one. When you submit the derived order to the order execution service, you must fill in a live same-outcome executable price yourself — the current bid for the held outcome is the usual choice. Kairos rejects missing prices at submission time and does not infer prices from another outcome.

positions.recalculatePosition

Force-recompute a position’s netSize, avgEntryPrice, costBasis, and realizedPnL from the underlying trade history. Useful when fills arrived out-of-order or a fix has been applied to the reconciler.
Access: InvitedMutation · Rate limit: mutations · Not available to API keys — web app (session auth) only.

Input

Response

position is omitted (leaving {"success": true}) if the read-back after the upsert fails.

Errors

Why the wallet-routed venues refuse. A wallet-blind recalculation on a venue that keys positions per holding wallet would create a phantom, unsellable row, so it is rejected rather than attempted.

Other maintenance mutations

These exist on the router and the web app uses them, but like recalculatePosition none accept API keys. Listed so callers aren’t surprised by a 403: All five are InvitedMutation and require session auth plus a CSRF token; all but resyncOnchainShares sit on the mutations bucket.
Redemption is not on this router. Redeeming a resolved winner is owned end-to-end by the order-execution service, which runs the on-chain redeem and writes the position result atomically. There is no positions.redeem.