AGENT SKILL

realized-lp

Check what liquidity providers ACTUALLY earned in a Uniswap v3 or Aerodrome pool — fee income minus impermanent loss — against the fees-only APR that DEX UIs advertise. Use when asked whether an LP position, pool, or yield number is actually profitable, whether an advertised APR is trustworthy, or to audit many pools at once for misleading yield. Backed by live Graph subgraph data.

License 许可
MIT
最近更新
2026-11-14
Stars
★ 81
Forks
17

01 前言 Front Matter

name
realized-lp
description
Check what liquidity providers ACTUALLY earned in a Uniswap v3 or Aerodrome pool — fee income minus impermanent loss — against the fees-only APR that DEX UIs advertise. Use when asked whether an LP position, pool, or yield number is actually profitable, whether an advertised APR is trustworthy, or to audit many pools at once for misleading yield. Backed by live Graph subgraph data.

02 资源文件 98

03 SKILL.md 正文

Realized LP

Advertised LP APR is fee income, annualized. It has no price term, so it cannot go negative. A pool cannot advertise a loss no matter what happened to the people in it. This skill computes the number that can: realized = fees + impermanent loss.

When to use this

  • "Is this pool actually profitable?" / "Is this APR real?"
  • "I'm LPing WETH/USDC, am I making money?"
  • "Find me pools where the advertised yield is lying"
  • Any yield-farming or LP decision where the advertised number is the only evidence on offer

Quick start (no install, no API key)

curl -s https://realized.drainfun.xyz/api/pools

202 live pools across 4 validated venues. The response embeds its own schema and the exact impermanent-loss formula in howToComputeRealizedReturn, so you can compute any range yourself with no further docs. Fields per pool:

field meaning
pair token0/token1 symbols
dex / chain uniswap-v3 | aerodrome / mainnet | arbitrum | polygon | base
r price ratio over the window (exit ÷ entry)
fees fee income over window, % of entry TVL
adv advertised APR % — what the DEX UI shows
days window length

Compute realized return for any range

function il(r, w) {                      // w = range half-width: 1.25 tight, 2 typical, 1e8 full
  const sa = Math.sqrt(1 / w), sb = Math.sqrt(w);
  let pos;
  if (r <= 1 / w) pos = (1 / sa - 1 / sb) * r;
  else if (r >= w) pos = sb - sa;
  else pos = 2 * Math.sqrt(r) - sa - r / sb;
  return ((pos / ((1 - sa) + (1 - 1 / sb) * r)) - 1) * 100;
}
const realizedPct = pool.fees + il(pool.r, 2);
const realizedApr = (realizedPct / pool.days) * 365;
const misleading  = pool.adv > 0 && realizedApr < 0;   // advertised a profit you didn't get

r <= 1/w or r >= w means price left the band — the LP is fully converted into the losing asset and the loss is realized, not impermanent. Always say so; it changes the advice.

Live queries (MCP server)

For pools not in the cached set, or a custom window, run the MCP server. Needs a free Subgraph Studio API key.

git clone https://github.com/jiggy-cadence/realized && cd realized && npm install

Paste into Claude Desktop (claude_desktop_config.json), Cursor (.cursor/mcp.json), or any MCP client, then restart it:

{
  "mcpServers": {
    "realized": {
      "command": "node",
      "args": ["/absolute/path/to/realized/bin/mcp-server.js"],
      "env": { "GRAPH_API_KEY": "your-subgraph-studio-key" }
    }
  }
}

No key? Skip the MCP server entirely. Every tool is also a plain HTTP GET against our server, which proxies its own Graph key — no auth, CORS open:

curl "https://realized.drainfun.xyz/api/find?q=WETH/USDC"
tool use
find_pool(query) start here — resolve "WETH/USDC" or "PEPE" to ranked poolIds. You never need to know an address.
realized_return(poolId, days?, rangeWidthX?) fees vs IL vs advertised for one pool over a recent window
position_realized(poolId, entryDate, rangeWidthX?) what YOU actually made on a position entered on a specific date, however long ago — not "the last 30 days," but "since I actually put money in." See below.
explain_gap(poolId) plain-language verdict + the evidence chain that produced it
audit_pools(limit?) corpus-wide sweep: how many pools advertise a profit LPs didn't get

Stop assuming the range — read the real one

Every number above needs a range width, and ±2× is a guess. /api/wallet/{address} reads what the LP actually set:

curl -s "https://realized.drainfun.xyz/api/wallet/0xYourAddress"

Read-only. No signing, no wallet connection, no permissions — it is a subgraph lookup on a public address, nothing more. Returns each open position with its real tickLower/tickUpper, the derived range.widthX, and a plain-language range.label. Feed range.widthX into position_realized (or /api/position/{poolId}?range=) to get realized return at the LP's actual band instead of an assumed one. That is the whole point of the endpoint.

It searches Uniswap v3 AND v4 — and they are not the same kind of answer

With no ?dex=, the endpoint queries both v3 and v4 on mainnet in parallel and returns { searched, foundIn, openPositions, venues: { "uniswap-v3": {…}, "uniswap-v4": {…} } }. The venues stay in separate blocks deliberately: merging them would produce one position list where some rows silently cannot support a realized-return number.

v3 (+ arbitrum/polygon/base), Aerodrome v4 (mainnet)
source position state read event reconstruction
real range yes yes
realized return yes no
exit simulation yes no

Why the asymmetry: the v4 Position entity carries only id/tokenId/owner/origin/timestamps — no tick range at all. Ranges are reconstructed by summing signed ModifyLiquidity.amount per (pool, tickLower, tickUpper). That is a strictly weaker evidence class than reading a struct the chain already maintains, so we report the range and inRange and refuse to price a v4 exit. /api/venues exposes this per venue as realizedReturn / exitSimulation booleans, so an agent can check capability instead of assuming it.

If you are an agent consuming v4 results, respect these four things:

  1. Check audit.allPass before using any number. Every v4 response ships audit.checks[] with 5 invariants (completeness, negatives classified, positions strictly positive, ticks ordered, all keys accounted).
  2. openPositions: null + error means the history was truncated (>5000 ModifyLiquidity events). A truncated sum is self-consistent and wrong, so no positions are reported at all rather than a partial total that looks authoritative.
  3. incompleteHistory[] lists ranges where liquidity was removed with no matching add in this wallet's events — almost always a position transferred in. Their size is deliberately not reported; do not infer one.
  4. Lookups key on origin (the EOA). sender is the position manager contract and matches nothing — if you query v4 events yourself, do not use sender.

Uniswap v2 is not supported, on purpose. v2 LP shares are fungible and always full-range, so "read the range you actually set" has no meaning there — including it would dilute the claim rather than extend it.

It is position discovery, not per-position P&L — deliberately. Two fields look like they'd give you fees and entry value. Both lie:

  • collectedFees* is a withdrawal record, not an earnings record — it only populates when the LP calls collect(). Measured live: of 150 positions with collectedFeesToken0 == 0, 71 had non-zero feeGrowthInside0LastX128 — they earned fees and never collected. So uncollected fees return measurable: false with a reason, never $0. Reporting zero there would reproduce the exact defect this project exists to expose, pointed at the user's own money.
  • deposited*/withdrawn* are lifetime cumulative, not entry state (75% of live positions read as one-sided because of it). They are namespaced under cumulative and must not be read as position value.

Every response carries a limits block stating both, and token decimals ship beside every raw amount so you never guess the divisor. Never render a position's fees as a number unless fees.measurable is true.

position_realized — the one question every real LP actually has

realized_return answers "what does the average LP get in this pool right now." position_realized answers "I put money into this exact pool on this exact date at this exact range — what have I actually made since." Anchors to a real calendar date, not a fixed lookback:

{ "poolId": "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", "entryDate": "2026-07-28", "rangeWidthX": 2 }

Returns fees earned, impermanent loss, realized return, whether price ever left your band (if so the loss is locked in, not impermanent), and a day-by-day series so you can see exactly when the position turned. Refuses to answer (measurable: false) rather than guess if fewer than 2 days have passed since entry — there is nothing to measure yet, and 0% is not the honest answer to "not enough data."

Same math as everywhere else in this project (lib/concentrated.js's own IL formula, not a separate implementation) — hand-verified in test/canary.test.js against an independent, from-scratch calculation, plus a live check against a real 45-day-old position.

Also available over plain HTTP (no MCP client, no API key — we proxy our own Graph key):

curl -s "https://realized.drainfun.xyz/api/position/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640?entry=2026-07-01&range=2"

entry accepts an ISO date or a unix timestamp; range is the band width (omit for ±2×, use a huge value for full range). Errors are explicit and never fabricated: a future entry, an unparseable date, an unknown pool, and a window too short to measure each return a stated reason rather than a number. The same call backs the date picker on the web UI — one implementation of the math, three transports (MCP, HTTP, browser).

simulate_exit — decision support for closing a position TODAY

Not a new claim: calls the identical position_realized math and reframes it around the question a holder actually has at the moment of deciding — dollars on a stated stake, not just percent since entry.

curl -s "https://realized.drainfun.xyz/api/simulate-exit/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640?entry=2026-07-28&range=2&stake=25000"

Gas is real. Live gas price from 1inch × estimated gas units for decreaseLiquidity + collect (~270k), priced in the chain's native token. The price is measured; the units are an estimate, so the response carries isEstimate: true and you should report the dollar figure as approximate. It degrades to { available: false, reason } rather than guessing.

Slippage is zero for a plain close, and that zero is measured, not missing. Closing a v3 position is not a swap — decreaseLiquidity + collect returns both tokens at the current tick, so there is no price impact. Slippage only exists if the holder then chooses to swap one side into the other:

curl -s "https://realized.drainfun.xyz/api/simulate-exit/0x88e6...5640?entry=2026-07-28&stake=25000&consolidate=true"

With consolidate=true the estimate is an explicit lower bound: impact is modelled against total pool TVL, but a concentrated pool's depth at the active tick is thinner than its TVL, so real impact is likely higher. Say "at least," never "exactly."

netAfterCostsUsd is populated only when both gas and slippage are known, and is null otherwise — a partial subtraction presented as a complete figure is the same defect this project exists to expose. If outOfRange is true, say plainly that the loss is locked in regardless of timing — waiting does not un-realize it.

Reporting rules (these matter more than the numbers)

  1. Always state the range. The same pool can be honest full-range and misleading at ±1.25×. Full-range is the most generous possible case for the pool — never quote it alone.
  2. measurable: false is not zero. It means the window couldn't be priced. Say "couldn't measure," never "no loss."
  3. Check the canary before quoting an aggregate. audit_pools returns canary.passed — stable/stable pairs must show ~0 IL. If it's false the instrument is unproven and every number in that response is untrusted.
  4. Fees are held constant across ranges, which is unfair to this tool: concentrating earns more fees too, so some IL is earned back. The direction is reliable; treat magnitudes as bounded by that caveat.
  5. This is backward-looking measurement, not a forecast. It says what happened over the window, not what will happen.

What holds this up

110 independent (non-overlapping) 30-day windows across 4 chains, plus the same instrument run unchanged on Aerodrome Slipstream — different team, codebase, incentive model — showing the same defect with the same shape. Venues whose stable pairs can't prove ~0 IL are excluded, not reported as clean. Full tables: https://realized.drainfun.xyz/report.html

Data: The Graph. Per-day feesUSD are indexer-derived aggregates. Not unreachable by RPC — feeGrowthGlobal0X128 is public pool state readable at any historical block, verified live 2026-09-11 — but reconstructing per-day USD from it needs an archive node, a block lookup per day boundary, and a historical price for both tokens at each one, repeated per pool. The Graph publishes that join already computed across 256 pools and 4 chains. The claim is practicality, not impossibility.

Machine-readable API spec

Full OpenAPI 3.1 description of every endpoint, parameter, response shape and error: https://realized.drainfun.xyz/openapi.json — so an agent never has to guess a request shape.

04 同仓库更多 Skills