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.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,realizedPnLare strings to preserve decimal precision. Parse with a big-decimal library, notparseFloat.unrealizedPnLPercentis a percentage (unrealizedPnL / |costBasis| * 100), and is0whencostBasisis0.holdingWalletidentifies which wallet holds the shares on venues that route per wallet. It is""on venues that don’t.netSize < 0means 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 = truewithredeemable = truemeans the user won but hasn’t redeemed yet. Redemption is performed by the order-execution service, not the RPC API.marketResolved = truewithredeemable = falseandnetSize != 0means the user lost (worthless shares). These won’t appear inonlyOpen=trueresults.
Errors
Gotchas
Price fields are all-or-nothing, and can benull.currentPrice,currentValue,unrealizedPnL, andunrealizedPnLPercentmay benullwhen 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, andmarketStatuscome 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 aplatformfilter. With noplatformfilter, 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 narrowestplatformfilter 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 seeingtotal_volume: 0until the cache refreshes.positions.getPositionsreads live position state rather than that cached proxy, so it is not subject to that staleness.
positions.getPosition
Fetch a single position by ID.position:read · Rate limit: queries
Input
Example
Response
Same shape as onepositions[] 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’snetSize, 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.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’snetSize, 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.
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 likerecalculatePosition 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.
