Merkl Developer Portal logo
get

/v4/analytics/positions/{userAddress}/{chainId}

Get user position history

Fetch 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, 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.

Excluded: protocol fees (CLAMM state carries liquidity only) and Merkl incentives (not position states).

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
- Filter by protocol: GET /v4/analytics/positions/0xUserAddress/1?protocol=uniswap
- Filter by identifier: GET /v4/analytics/positions/0xUserAddress/1?identifier=0xPoolAddress

Path parameters

userAddressstring
required

User wallet address to query

chainIdnumber | string
required

Chain ID to filter by (optional)

Query parameters

includeHistoryboolean
required
default: false

Include per-position history snapshots

includeDisplayboolean
required
default: false

Decorate 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).

includePnlboolean
required
default: false

Add a running pnl block (cost basis, per-event cashflow, PnL, ROI) to every snapshot's display, plus a top-level summary. 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.

minTimestampnumber | string

History 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).

dailyboolean
required
default: false

Bucket history snapshots daily

firstnumber | stringdefault: 1000

Number of historical snapshots to return

protocolstring

Filter positions by protocol name (e.g., 'uniswap'). Case-insensitive partial match

identifierstring

Filter 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.

afterstring

Cursor for pagination

Responses

200
Response for status 200
Schema
object
dataobject
required
Wrapped positions keyed by chainId, then identifier, then indexingId
summaryobjectPnL aggregates. Present only when `includePnl=true`.
totalobject
required
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`.
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. 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.
basisenum
required
completetruncatedunpriced
positionsIncludednumber
required
Positions whose figures are in these totals.
positionsExcludednumber
required
Positions left out (no cashflow source for the stateType, or nothing priced).
byIdentifierobject
required
Keyed by chainId, then identifier — the same nesting as `data`.
paginationobject
required
positionCountnumber
required
Actual number of items returned
totalHistorySnapshotsnumberTotal number of history snapshots
pageInfoobject
endCursorstringCursor for the next page
hasNextPageboolean
required
Whether more results are available — matches the transactions route
hasMoreHistoryboolean
required
Deprecated alias of `hasNextPage`, kept for existing consumers. Prefer `hasNextPage`.
Example
{
  "data": {},
  "summary": {
    "total": {
      "costBasisUsd": 0,
      "pnlUsd": 0,
      "roi": 0,
      "basis": "complete",
      "positionsIncluded": 0,
      "positionsExcluded": 0
    },
    "byIdentifier": {}
  },
  "pagination": {
    "positionCount": 0,
    "totalHistorySnapshots": 0,
    "pageInfo": {
      "endCursor": "string",
      "hasNextPage": false,
      "hasMoreHistory": false
    }
  }
}