
/v4/analytics/positions/{userAddress}/{chainId}
Get user position historyFetch historical position data for a user across opportunities.
Auth: a valid x-api-key developer key, or an authenticated JWT identity (cookie or Bearer) — both grant the same read access. Neither grants the decrypted confidential amounts (Zama ERC-7984 plaintext balances): those sets are withheld from the response, including for the address's own JWT. The public sibling series (confidential_transfer, delegation) are returned as usual.
Response:
- data[chainId][identifier][indexingId]: Wrapped positions grouped by chain ID → identifier → indexingId. Each positionGroup carries shared metadata (protocol, name, action, stateType, globalStates, tokens) and a positions map keyed by positionId.
- latest is reported as of the indexing checkpoint: its (blockNumber, blockTimestamp) are advanced to how far indexing has completed (state unchanged through there), never beyond it — so the state is never presented as more current than what's indexed.
- globalStates (populated per snapshot when includeDisplay=true): at the positionGroup level it holds series descriptors only (type, indexingId). The value applied to a snapshot lives on that snapshot's own globalStates[key] = { blockTimestamp, value } — the series row at-or-before the snapshot's timestamp (carry-forward) — so each snapshot (latest + each history entry) is self-describing.
- debt: present and true when the group's amounts are owed, not held (a borrow) — sum values across groups by subtracting these. Set once per group: it follows from the processor.
- positions[positionId].params: position-invariant fields hoisted out of the snapshots, emitted once per position. Shape follows stateType; CLAMM carries tickLower/tickUpper (and salt when the persisted state has one), which is why they are no longer inside each snapshot's state. poolId and tokenId are not emitted at all: poolId is the positionGroup's identifier, and tokenId is the last segment of the positionId key.
- pagination.positionCount: Number of positions returned
- pagination.totalHistorySnapshots: Total history snapshots (when > 0)
- pagination.pageInfo: Cursor-based pagination info (when applicable)
PnL (includePnl=true): adds a running pnl block to every snapshot's display — cost basis, per-event cashflow, PnL, ROI, all USD at each event's own daily-close price — A position's own PnL is its latest.display.pnl; there is no duplicate block on the position.
Every history row is one capital event, so a row's state delta is the cashflow and everything else is PnL. PnL therefore needs the complete series: the flag implies includeDisplay and includeHistory, and disregards first/after, since first pages rows newest-first portfolio-wide and drops the oldest writes — exactly the cost basis. The walk is row-capped; if it stops early, or a minTimestamp anchor opens the series, the block reports basis: "truncated". Scope large portfolios with identifier or indexingIds.
summary carries the request's aggregates: total plus byIdentifier nested chainId → identifier, mirroring data. ROI is recomputed from the aggregate, never averaged, and divides by mean capital at risk (the basis time-weighted over the time it was funded) — so it survives a position closing, where the net basis goes negative, and it is not inflated by capital recycled through the position. The per-position means are summed, which is exact only where the positions' windows coincide. A borrow's cost basis is capital *received*, so costBasisUsd subtracts it and reports net capital committed. Always read positionsIncluded/positionsExcluded — PnL cannot be computed for every position (unsupported stateType, unpriced token), and a total that omits some says so there rather than looking complete.
pnlUsd excludes Merkl incentives and protocol fees (CLAMM state carries liquidity only). With includeRewards, a Merkl claim paid in the position's own token lands in the position: it stays in the cost basis, so pnlUsd does not change, but roi leaves it out of the capital and the row reports it as claimInflowUsd. Its campaign counts in the reward position, even when it belongs to another opportunity.
Merkl rewards (includeRewards=true): adds one merkl_reward group per (distribution chain, reward token) paid by a campaign of the returned positions, keyed merkl:<chainId>:<token> under data[chainId][token]. Its single position holds:
- latest.state: claimed, claimable and pending amounts, and campaigns[] with earnedBy (the protocol groups that earn each campaign). scope: "partial" when the user also earns the token outside the returned positions.
- With includeDisplay: valueUsd (claimable at today's close), pendingUsd, and the per-campaign breakdown.
- With includePnl or includeHistory: claims are read too. claimedUsd values every claim at its own claim-day close. Each claim is one history row (claimable estimated at 0).
- With includePnl: a reward pnl block (paid out plus still held, no cost basis or ROI), and in summary: rewardsUsd, pnlWithRewardsUsd (pnlUsd plus claimed and claimable, pending left out), roiWithRewards (same capital as roi) and rewardsBasis. Under byIdentifier, a campaign counts under every identifier that earns it.
Rewards follow the reward routes' privacy rules: private, hideLeaderboard and hidden-token rewards are left out unless the caller may read them.
Examples:
- All positions on chain 1: GET /v4/analytics/positions/0xUserAddress/1
- With history: GET /v4/analytics/positions/0xUserAddress/1?includeHistory=true
- With USD display values: GET /v4/analytics/positions/0xUserAddress/1?includeDisplay=true
- With PnL: GET /v4/analytics/positions/0xUserAddress/1?includePnl=true&identifier=0xPoolAddress
- With PnL and Merkl rewards: GET /v4/analytics/positions/0xUserAddress/1?includePnl=true&includeRewards=true
- Filter by protocol: GET /v4/analytics/positions/0xUserAddress/1?protocol=uniswap
- Filter by identifier: GET /v4/analytics/positions/0xUserAddress/1?identifier=0xPoolAddress
Path parameters
userAddressstringUser wallet address to query
chainIdstring (integer) | integerChain ID to filter by; 0 or omitted reads every chain
Query parameters
includeHistorybooleanInclude per-position history snapshots
includeDisplaybooleanDecorate each snapshot with a self-describing display block: one entry per token backing its amount(s) (id/symbol/decimals/raw amount) plus valueUsd per entry and a snapshot total — so callers stop re-deriving, per stateType, which token each amount belongs to. Always includes valueUsd (resolves prices on its own). Runs global-state enrichment first (CLAMM/rebased amounts only exist after it).
includePnlbooleanAdd a running pnl block (cost basis, per-event cashflow, PnL, ROI) to every snapshot's display, plus a top-level summary, and resolve each group's opportunities. A position's own PnL is latest.display.pnl. Implies includeDisplay and includeHistory, and overrides pagination: first/after are disregarded and the complete series is returned (row-capped), since a partial series understates the cost basis. Scope large portfolios with identifier or indexingIds. Each block's basis says whether the series reached inception.
includeRewardsbooleanAdd the user's Merkl rewards on the returned positions as merkl_reward groups: one per (distribution chain, reward token), keyed merkl:<chainId>:<token> and filed under data[chainId][token]. latest.state holds the claimed, claimable and pending amounts per campaign; state.campaigns[].earnedBy links each campaign to the groups that earn it. With includeDisplay, amounts are valued (claimable at today's close). With includePnl or includeHistory, claims are read too: one history row per claim (claimable estimated at 0), and claimedUsd at claim-day closes. Also resolves each group's opportunities, which decide the scope.
minTimestampnumber | stringHistory lower bound (unix seconds). Only applies when includeHistory=true: returns all snapshots strictly after this timestamp PLUS the closest snapshot at-or-before it per position — the anchor that represents the state as of minTimestamp. The anchor row is reported as-of minTimestamp: blockTimestamp=minTimestamp and blockNumber=the on-chain block live at that time (resolved via BlockService); the state value is the last write's value. Positions with no anchor and no post-minTimestamp activity don't appear. Disregarded when includeHistory=false (the latest snapshot is always returned).
dailybooleanBucket history snapshots daily
firstnumber | stringdefault: 1000Number of historical snapshots to return
protocolstringFilter positions by protocol name (e.g., 'uniswap'). Case-insensitive partial match
identifierstringFilter to a single opportunity identifier (pool address, market id, etc.). Case-insensitive exact match. Resolved to its indexingIds and filtered at the DB level in states-service, so pagination and pricing apply to the matched opportunity only, not the whole portfolio. Combines with indexingIds as an intersection.
indexingIdsstring[]Keep only these indexingIds (comma-separated or repeated). Filtered at the DB level in states-service (on indexing_id), so callers scoped to a few groups (e.g. the indexingIds backing one dashboard's opportunities) fetch and price only those, not the whole portfolio.
afterstringCursor for pagination
Responses
200Response for status 200
object
dataobjectsummaryobjectPnL aggregates. Present only when `includePnl=true`.
totalobjectrequired
costBasisUsdnumberNet cashflow through the position, in its own direction: capital invested for a supply, principal outstanding for a borrow. Goes negative once outflows exceed inflows — on a closed position that surplus is the profit, so `pnlUsd` is exactly its negation. A borrow's basis therefore stays positive, and the negation the summary applies to it is what makes its total read as net capital committed.pnlUsdnumberCurrent value minus `costBasisUsd`. Interest, price moves and impermanent loss all land here, because none of them is a deposit or a withdrawal. Protocol fees and Merkl rewards are not counted.roinumber`pnlUsd` over **mean capital at risk**: the cost basis integrated over the time it was positive, divided by that time. Not gross inflows, which count recycled capital once per round trip; not `costBasisUsd`, which inverts the ratio once a position closes. Debt counts as deployed capital. Idle stretches are excluded, so $100 held for a day then flat for a year is a return on $100. Percentage (15.5 = +15.5%). Absent when no capital was ever at risk. A mean, so it ignores when in the window the flows landed — not an IRR — and it does not net leverage off. Covers only the stretch since the basis last restarted: an unreadable row discards the basis it built, so on a restarted series — including one still reporting `complete` — this is the return over the post-restart tail. A full exit is not a restart.basisenumcompletetruncatedunsupportedunpricedpositionsIncludednumberpositionsExcludednumberrewardsUsdobjectWith `includeRewards`: Merkl rewards of the campaigns on these positions, each campaign counted once. Claims at their claim-day close, claimable and pending at today's close. Under `byIdentifier`, a campaign counts under every identifier that earns it, so the entries can sum to more than `total`. `rewardsBasis` qualifies these sums apart from `basis`: `truncated` when a reward figure is unpriced (left out) or a reward read failed.
claimednumberclaimablenumberpendingnumberpnlWithRewardsUsdnumberWith `includeRewards`: `pnlUsd` plus claimed and claimable rewards — what the user made, incentives included. Pending rewards are left out. `pnlUsd` alone excludes incentives.roiWithRewardsnumberWith `includeRewards`: `pnlWithRewardsUsd` over the same capital as `roi`.rewardsBasisenumHow far the figures can be trusted. `complete`: the series reaches the position's inception. `truncated`: it starts mid-life (a `minTimestamp` anchor, the row cap, or a re-anchor), so the cost basis misses earlier flows. `unsupported`: PnL has no cashflow rule for the position's shape (a two-legged Fluid position, for example), even when every value prices. `unpriced`: a value or a cashflow could not be priced, so there are no figures. A total takes the worst basis among its included positions. With none included, it is `unsupported` when every left-out position is, and `unpriced` otherwise.completetruncatedunsupportedunpricedbyIdentifierobjectpaginationobjectrequired
positionCountnumbertotalHistorySnapshotsnumberTotal number of history snapshotspageInfoobject
endCursorstringCursor for the next pagehasNextPagebooleanhasMoreHistoryboolean{
"data": {},
"summary": {
"total": {
"costBasisUsd": 0,
"pnlUsd": 0,
"roi": 0,
"basis": "complete",
"positionsIncluded": 0,
"positionsExcluded": 0,
"rewardsUsd": {
"claimed": 0,
"claimable": 0,
"pending": 0
},
"pnlWithRewardsUsd": 0,
"roiWithRewards": 0,
"rewardsBasis": "complete"
},
"byIdentifier": {}
},
"pagination": {
"positionCount": 0,
"totalHistorySnapshots": 0,
"pageInfo": {
"endCursor": "string",
"hasNextPage": false,
"hasMoreHistory": false
}
}
}https://analytics.merkl.xyz/v4/analytics/positions//?includeHistory=&includeDisplay=&includePnl=&includeRewards=&daily=&first=curl -X GET "https://analytics.merkl.xyz/v4/analytics/positions/0xd93814273b33DD33B32cD345225CfB65DEB048Dd/1?includeHistory=false&includeDisplay=false&includePnl=false&includeRewards=false&daily=false&first=1000"