Skip to main content
GET
/
v2
/
wallets
/
{address}
/
positions
/
onchain
Onchain positions & P&L
curl --request GET \
  --url https://api.polynode.dev/v2/wallets/{address}/positions/onchain \
  --header 'x-api-key: <api-key>'
{
  "wallet": "0xbddf61af533ff524d27154e589d2d7a81510c684",
  "source": "onchain",
  "count": 873,
  "open_count": 334,
  "closed_count": 288,
  "total_realized_pnl": 17183579.48,
  "positions_with_pnl": 309,
  "positions": [
    {
      "token_id": "34158857981196154020624191931838064546543114916900689904269497497174344049047",
      "size": 0,
      "avg_price": 0.316481,
      "realized_pnl": 447182.949852,
      "total_bought": 654236.312162
    }
  ]
}

Documentation Index

Fetch the complete documentation index at: https://polynode.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Returns every position a wallet has ever held, including closed positions that no longer appear in the standard positions endpoint. Each position includes precomputed realized_pnl, avg_price, total_bought, plus per-position activity, redemption, and resolution timestamps that let you build leaderboards, trim to a 30-day or 90-day window, or filter to a parent event. The standard /v1/wallets/{addr}/positions endpoint only returns open positions. Once a position is fully exited or a market resolves, it disappears. This endpoint fills that gap.

Request

GET /v2/wallets/{address}/positions/onchain
ParameterTypeDescription
addresspathWallet address (0x-prefixed, 40 hex chars)
sincequery (optional)Unix seconds. Drop positions whose last_trade_at is before this timestamp.
untilquery (optional)Unix seconds. Drop positions whose last_trade_at is after this timestamp.
tag_slugquery (optional)Keep only positions whose market carries this Polymarket event tag (e.g. nba, politics, crypto, 2026-fifa-world-cup-winner-595). Matches against the per-market tag_slugs array. See Filtering by tag.
since, until, and tag_slug are all opt-in. When none are supplied, the response is identical to a request with no parameters at all — same shape, same aggregates over the full position set. When any is supplied, positions are filtered and aggregates are recomputed (see Filtering).

Response

{
  "wallet": "0xbddf61af533ff524d27154e589d2d7a81510c684",
  "source": "onchain",
  "count": 879,
  "open_count": 340,
  "closed_count": 288,
  "total_realized_pnl": 17183579.48,
  "total_unrealized_pnl": -14346997.32,
  "total_pnl": 2836582.16,
  "positions_with_pnl": 309,
  "positions": [
    {
      "token_id": "100424505911492726143069668369640898890012859227903329249368753635028896002613",
      "size": 0,
      "avg_price": 0.63,
      "realized_pnl": 147588.53019,
      "unrealized_pnl": 0,
      "current_price": null,
      "market_status": "closed",
      "total_bought": 398887.919433,
      "market": "Will Fulham FC win on 2026-03-21?",
      "slug": "epl-ful-bur-2026-03-21-ful",
      "event_slug": "epl-ful-bur-2026-03-21",
      "outcome": "Yes",
      "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/premier+league.jpg",
      "condition_id": "0xae68634cb01137acbe26e65790f7268a523ee1519601f2576bd7b067c7c9f9da",
      "last_trade_at": 1742568134,
      "closed_at": 1742573012,
      "resolved_at": 1742572840
    }
  ]
}
FieldTypeDescription
walletstringQueried wallet address (lowercased)
sourcestringAlways "onchain"
countnumberTotal positions returned
open_countnumberPositions with size > 0
closed_countnumberPositions with size = 0 and nonzero realized_pnl
total_realized_pnlnumberSum of realized_pnl across all returned positions
total_unrealized_pnlnumberSum of unrealized_pnl across all returned positions. Reflects open-size paper P&L at current (or terminal) market prices.
total_pnlnumbertotal_realized_pnl + total_unrealized_pnl. Matches the net portfolio PnL shown on a Polymarket profile page.
positions_with_pnlnumberPositions where realized_pnl != 0
filteredbooleanPresent only when since or until was supplied. Always true in that case. Absent in default responses.
applied_filtersobjectPresent only when filtering. Echoes {since, until} (each is the supplied unix seconds or null). Absent in default responses.
positions[].token_idstringCTF token ID (called asset on the closed-positions endpoint)
positions[].sizenumberCurrent token balance (0 = fully exited)
positions[].avg_pricenumberVolume-weighted average entry price
positions[].realized_pnlnumberRealized profit/loss in USDC
positions[].unrealized_pnlnumberPaper P&L on remaining open shares. For resolved markets, uses the terminal settlement price (1.0 winner / 0.0 loser). For live markets, uses the current market price. 0 when size = 0.
positions[].current_pricenumber | nullPrice used for unrealized_pnl. 1.0 or 0.0 on resolved markets, live market price on active markets, null when size = 0 or no price is available.
positions[].market_statusstring"live" / "resolved-win" / "resolved-loss" / "resolved-unknown" / "closed". See positions feed for full enum.
positions[].total_boughtnumberTotal tokens acquired for this position
positions[].marketstringMarket question
positions[].slugstringMarket slug — identifies the specific market within an event (e.g. nba-bos-cle-2026-03-08-spread).
positions[].event_slugstring | nullEvent slug — identifies the parent event a market belongs to (e.g. nba-bos-cle-2026-03-08). For single-market events, equals slug. For multi-market events (NBA games with several lines, election markets with several candidates, FIFA World Cup with one market per team, etc.) this is the parent the markets share. null when the event metadata is not yet known.
positions[].outcomestringOutcome label (e.g. “Yes”, “No”, “Up”)
positions[].imagestringMarket image URL
positions[].condition_idstringMarket condition ID
positions[].last_trade_atnumber | nullUnix seconds. Latest fill (maker or taker) on this token across V1 + V2 exchanges. null when polynode has no fill history for the token (e.g. positions acquired purely via splits/merges or NegRisk conversion with no later trade).
positions[].closed_atnumber | nullUnix seconds. Latest moment this wallet redeemed any outcome of this market’s condition_id for collateral. null when the wallet has not redeemed (positions sold to zero before resolution, or open positions, or wins held but not yet redeemed).
positions[].resolved_atnumber | nullUnix seconds. Moment the market was resolved on-chain (when payouts became redeemable). null for markets that resolved before polynode began tracking, or for markets that have not yet resolved. Recent markets are fully covered.
positions[].tag_slugsstring[] | nullPolymarket event-level tags applied to this market. Examples: ["nba","basketball","sports","all"] for an NBA game market, ["crypto","up-or-down","recurring","all"] for a 5-min crypto market. Order is most-specific → most-general. Tags live on the parent event (markets sharing an event_slug share the same tag_slugs). null only on the rare event that doesn’t carry any tag or hasn’t been ingested yet.

Distinguishing the three timestamp fields

These three timestamp fields look similar but answer different questions. Choosing the right one matters when filtering:
FieldWhose actionWhen
last_trade_atThe wallet’s most recent fill on this exact tokenEach fill event (buy or sell) updates this
closed_atThe wallet’s most recent collateral redemption for the marketSet when the wallet calls redeemPositions on the CTF contract
resolved_atThe market itself (not the wallet) becoming redeemableSet once per market, when the oracle reports payouts on-chain
A position can have resolved_at populated and closed_at null — the market resolved but the wallet has not yet redeemed. A position can have closed_at set and resolved_at null — the wallet redeemed during a window polynode does not have full historical resolution coverage for, but the redemption itself was observed. For windowing a leaderboard to “the last 30 days of trading activity,” last_trade_at is the right field — it captures every position the wallet was active in. For “positions resolved in the last 90 days,” resolved_at is the right one. For “positions the wallet has actually closed out,” closed_at is the right one.

Filtering by activity window

since and until are optional unix-seconds query parameters that filter positions by last_trade_at. Designed to support 30d / 90d / 365d leaderboard windows without fetching multi-year history just to trim it client-side.

Behavior

  • Both bounds are inclusive.
  • Positions with last_trade_at = null are dropped under any active filter — there’s no timestamp to compare against.
  • All seven aggregates (count, open_count, closed_count, total_realized_pnl, total_unrealized_pnl, total_pnl, positions_with_pnl) recompute over the filtered set.
  • The response gains two top-level keys to make the shift self-documenting: filtered: true and applied_filters: { since, until }. These keys are absent from default responses.
  • Bad inputs return 400 with a descriptive error.

30-day window

SINCE=$(( $(date -u +%s) - 86400 * 30 ))
curl "https://api.polynode.dev/v2/wallets/0xbddf61af533ff524d27154e589d2d7a81510c684/positions/onchain?since=$SINCE" \
  -H "x-api-key: YOUR_KEY"

90-day window

SINCE=$(( $(date -u +%s) - 86400 * 90 ))
curl "https://api.polynode.dev/v2/wallets/0xbddf61af533ff524d27154e589d2d7a81510c684/positions/onchain?since=$SINCE" \
  -H "x-api-key: YOUR_KEY"

Bounded range (e.g. Q1 2026)

curl "https://api.polynode.dev/v2/wallets/0x.../positions/onchain?since=1735689600&until=1743465600" \
  -H "x-api-key: YOUR_KEY"
The default response is unaffected by these new params. When you call this endpoint with no since/until/tag_slug, the response shape and every aggregate is identical to what you got before. The filtered and applied_filters keys are only added when you opt in.

Filtering by tag

tag_slug is an optional query parameter that keeps only positions whose market carries the matching Polymarket event tag. Tags come from Polymarket’s event taxonomy — typical values include nba, basketball, sports, politics, crypto, fed, world, 2025-predictions, and event-specific slugs like 2026-fifa-world-cup-winner-595. Each market inherits the tag set of its parent event, so all 32 markets under “Will X win the World Cup?” share the same tag list. The full set is visible in the per-row tag_slugs array on every response.

Behavior

  • Match is case-sensitive and exact (?tag_slug=nba matches markets whose tag_slugs array contains "nba").
  • Positions with tag_slugs = null are dropped under an active filter — same logic as last_trade_at for since/until (cannot match what we don’t know).
  • Aggregates recompute over the filtered set.
  • applied_filters.tag_slug echoes the value back so the response is self-documenting.
  • Combine with since / until to get e.g. “all NBA positions in the last 30 days” — all three filters AND together.

Examples

All NBA positions for a wallet

curl "https://api.polynode.dev/v2/wallets/0x.../positions/onchain?tag_slug=nba" \
  -H "x-api-key: YOUR_KEY"

All positions tagged to a specific event

curl "https://api.polynode.dev/v2/wallets/0x.../positions/onchain?tag_slug=2026-fifa-world-cup-winner-595" \
  -H "x-api-key: YOUR_KEY"
This collapses across every children market under the FIFA World Cup winner event.

Crypto positions in the last 90 days

SINCE=$(( $(date -u +%s) - 86400 * 90 ))
curl "https://api.polynode.dev/v2/wallets/0x.../positions/onchain?tag_slug=crypto&since=$SINCE" \
  -H "x-api-key: YOUR_KEY"

Discovering available tags

Use the dedicated /v2/onchain/tags endpoint for the full tag namespace (5,779 tags currently). Returns a flat slug array by default, or ?details=true for per-tag market counts and timestamps:
curl "https://api.polynode.dev/v2/onchain/tags?min_markets=100&details=true" \
  -H "x-api-key: YOUR_KEY"
The values returned by /v2/onchain/tags are the exact strings to pass to ?tag_slug= here. For a wallet-specific view, you can also fetch the wallet’s positions without a filter and read the union of tag_slugs across the response — that’s the tag namespace the wallet has actually traded in.

How realized_pnl Works

Each position’s realized_pnl reflects the profit or loss after the position is closed (sold or redeemed). For a winning binary position bought at 0.40thatresolvesat0.40 that resolves at 1.00:
  • avg_price = 0.40
  • total_bought = 10,000 (tokens acquired)
  • Cost basis = 10,000 × 0.40=0.40 = 4,000 USDC
  • Payout at 1.00=10,000×1.00 = 10,000 × 1.00 = $10,000 USDC
  • realized_pnl = 10,00010,000 - 4,000 = $6,000
The formula: realized_pnl = total_bought × (1 - avg_price) for positions that resolve at $1. Per-position values (realized_pnl, avg_price, total_bought) come from the same onchain settlement data that Polymarket uses. Individual position P&L matches what Polymarket shows on its portfolio page.

Which total to use

The response returns three portfolio-level totals. Each answers a different question:
TotalAnswersMatches
total_realized_pnlHow much profit has this wallet actually taken off the table from closed + resolved positions?Not shown on Polymarket profile
total_unrealized_pnlWhat is the paper P&L on the wallet’s currently-held shares at current (or terminal) prices?Not shown directly
total_pnlWhat is the wallet’s net portfolio P&L right now?The “Profit” number on a Polymarket profile page
total_pnl is what most dashboards want — it’s the single number users recognize from Polymarket. total_realized_pnl is useful for attribution (which markets made money vs which positions are still out). total_unrealized_pnl is useful for risk views (how much is still at stake).
total_pnl = total_realized_pnl + total_unrealized_pnl. Reach for total_pnl when you want a number that matches a Polymarket profile page. Reach for total_realized_pnl when you want only closed-out gains.
When you supply since or until, all three totals recompute over the filtered set — the same logic, just narrower input.

Performance

Responses are cached for 5 minutes per wallet. First request for a new wallet takes 200ms-3s depending on position count (up to 20,000 positions supported). Cached responses return in under 50ms. Filter parameters are applied to the cached set; passing since/until does not invalidate the underlying cache.
PositionsFirst requestCached
< 500~1s< 50ms
500-1,000~1.5s< 50ms
1,000-5,000~3s< 50ms

Examples

Default response (no filter)

curl "https://api.polynode.dev/v2/wallets/0xbddf61af533ff524d27154e589d2d7a81510c684/positions/onchain" \
  -H "x-api-key: YOUR_KEY"
Returns every position the wallet has held, all-time, with the full set of fields documented above. Default response shape — no filtered or applied_filters keys.

Filter to the last 30 days

SINCE=$(( $(date -u +%s) - 86400 * 30 ))
curl "https://api.polynode.dev/v2/wallets/0xbddf61af533ff524d27154e589d2d7a81510c684/positions/onchain?since=$SINCE" \
  -H "x-api-key: YOUR_KEY"

Filter to a parent event

event_slug is included on every row, so once you have a wallet’s positions you can group them by parent event without an extra round-trip:
curl "https://api.polynode.dev/v2/wallets/0x.../positions/onchain" \
  -H "x-api-key: YOUR_KEY" \
| jq '.positions | group_by(.event_slug) | map({event_slug: .[0].event_slug, market_count: length, pnl: (map(.realized_pnl + .unrealized_pnl) | add)})'
This collapses a wallet’s positions across the children of an event (e.g. all 32 World Cup winner markets, or the multiple lines of a single NBA game) into one row per event.

Authorizations

x-api-key
string
header
required

Path Parameters

address
string
required

Wallet address (0x-prefixed)

Response

Onchain positions with P&L