What LPSignal does
LPSignal watches concentrated-liquidity pools on Ethereum, BNB Chain, Base, Arbitrum, Optimism and Polygon (Uniswap v3 and v4, PancakeSwap v3, Aerodrome and Velodrome Slipstream). Every hour it measures what a liquidity position would have earned in each pool, after impermanent loss, for a set of price ranges. When a range has been paying well for a week and is still paying today, you get a signal with the exact position to open.
Both tokens in a pool must be on our blue-chip list, so you won't see signals for new or illiquid tokens. Uniswap v4 pools are covered only when they have no hooks and a fixed fee.
How net APR is measured
Net APR = fee APR − impermanent loss, annualised from a backtest over the pool's own hourly history. (The API reports the loss as a negative ilApr, so there netApr = feeApr + ilApr.)
- Fees come from the pool contract's fee-growth counters, read at the end of every hour. This is the exact amount a unit of in-range liquidity earned, including dynamic fees. We never estimate fees from trading volume for a signal.
- Impermanent loss is the position's value compared with simply holding the two tokens you deposited, marked at each hour's closing price.
- In range is the share of hours the price spent inside the range. Out-of-range hours earn nothing and still count in the average.
Each pool is tested on range widths that suit its type. A range of ±5% means the position covers prices from 5% below to 5% above the price at entry, rounded outwards to the pool's tick spacing.
| Pool type | Examples | Ranges tested |
|---|---|---|
| Stable | USDT/USDC | ±0.05% · ±0.1% · ±0.5% |
| Correlated | wstETH/WETH, cbBTC/WETH | ±0.5% · ±1% · ±3% |
| Volatile | WETH/USDC, WBNB/USDT | ±5% · ±10% · ±20% · full range |
Windows are the last 24 hours, 7 days and 30 days. Aerodrome and Velodrome figures are for unstaked liquidity. Where staking in the gauge pays emissions, we show that rate next to the pool for reference, but it never triggers a signal and is not backtested.
When a signal fires
A pool fires an opportunity signal when one of its ranges meets all of these at the top of an hour:
| Condition | Stable | Correlated | Volatile |
|---|---|---|---|
| Net APR over the last 7 days | ≥ 8% | ≥ 10% | ≥ 30% |
| Net APR over the last 24 hours | ≥ 8% | ≥ 10% | ≥ 30% |
| Time in range over 7 days | ≥ 80% | ||
- Only exact fee data counts. If fee-growth readings are missing for a window, the pool can't fire.
- If more than one range qualifies, you get the one with the highest 7-day net APR.
- After a signal, the same pool stays quiet for 72 hours unless its 7-day net APR climbs to 1.5 times what it was at the last signal.
- A chain that is more than 2 hours behind fires nothing until it catches up, so you never get a signal about stale data.
Short-term opportunities
Some ranges pay very well for an hour or a few — a volatile session, a new incentive, a burst of flow — long before a 7-day average notices. A short-term signal (kind: "burst") fires when a range meets all of these at the top of an hour:
| Condition | Stable | Correlated | Volatile |
|---|---|---|---|
| Net APR over the last hour | ≥ 30% | ≥ 45% | ≥ 110% |
| Time in range over that hour | ≥ 67% | ||
| Swaps in that hour (one outsized trade is not a burst) | ≥ 3 | ||
- Only exact fee data counts, like every opportunity.
- A pool that fired a 7-day signal in the same hour does not also get a short-term one. After a short-term signal the pool stays quiet for 12 hours unless its one-hour net APR grows by half again.
- Each short-term signal is scored on the 24 hours after it fires (7-day signals on the 7 days after), in its own track record:
GET /v1/signals/stats?kind=burst. - Signals arrive 30–90 minutes after the hour they describe (the archive only carries finalized blocks). A one-hour burst can be over by the time you act. The shortest window is one hour because the data is hourly.
High yield, high risk
DEX sites rank pools by their pool-level APR: the last 24 hours of swap fees divided by the pool's liquidity, annualised. A high-yield signal (kind: "hot_pool") fires when a big pool's figure jumps, once all of these hold at the top of an hour:
| Condition | Threshold |
|---|---|
| Pool-level fee APR over the last 24 hours | ≥ 50% |
| Against the pool's own 7-day average | ≥ 2× |
| Pool TVL (refreshed within 3 hours) | ≥ $500k |
- Fees are estimated as volume × the pool's fee rate, valued at the latest prices. Uniswap v4 pools are left out: their TVL is the depth near the price, which would overstate the figure.
- It is a pool-level figure, not a recommended position (
tickLowerandtickUpperare 0), and it is not scored. Next to it,bestNet24his what our best range actually netted over the same day after impermanent loss (bestRangeBpsays which), or null without a 24-hour backtest. - One per pool per 24 hours. Paid plans get it live, the free plan 24 hours later, like the other opportunities.
Track record
Seven days after an opportunity signal fires, we backtest the exact ticks it recommended over those seven days and publish the result on the signal. This happens for every signal, including the losing ones.
- Result: the realised net APR, with its fee and IL parts.
- Pending: fewer than 7 days have passed.
- Unscored: exact fee data for that week never arrived. These are shown but left out of the averages, so the track record is never padded with estimates.
Risk alerts
Risk alerts are free and live on every plan.
| Alert | Fires when |
|---|---|
| Liquidity outflow | The pool's liquidity fell by 30% or more within 3 hours, valued at current prices so a falling token price doesn't trigger it, on two consecutive readings of the live chain a minute or more apart (a liquidity pulled and re-added a block later is not an outflow). Only for pools above the minimum size. |
| Depeg | On a stable or correlated pair, the price is at least 0.5% away from the 7-day median, in the same direction, on two consecutive readings of the live chain a minute or more apart (or on the last two hourly closes). Marked severe at 2%. |
Smart LP scoring
We follow position NFTs on Uniswap v3, PancakeSwap v3 and Slipstream from deposit to final withdrawal. When a position closes, its score is what the owner collected minus what holding the deposited tokens would be worth, valued at the pool's price at the moment of that withdrawal.
- A wallet needs at least 3 scored closes and $10,000 of capital to be ranked.
- Positions funded by more than one wallet, moved to a new owner mid-life, staked in a gauge, or first seen after they opened are not scored.
- Bots and vaults are not ranked: contract addresses, wallets that close more than 10 positions a day, and wallets whose positions last under an hour at the median. A signal reaches you minutes to an hour after the opening, too late to follow them. Wallets you follow are still reported.
- Pro members get an alert when a top-20 wallet, or one they follow, opens a position of $10,000 or more.
Plans and delays
| Free | Basic | Pro | |
|---|---|---|---|
| Opportunity signals | 24h after firing | Live | Live |
| Short-term opportunities | 24h after firing | Live (subscribe) | Live (subscribe) |
| High yield, high risk | 24h after firing | Live (subscribe) | Live (subscribe) |
| Risk alerts | Live | Live | Live |
| Smart LP alerts and full leaderboard | – | – | Yes |
| Telegram | Yes (delayed) | Yes | Yes |
| Webhook, WebSocket, API key | – | Yes | Yes |
| Custom rules | – | 3 | 20 |
Plan limits are checked again when each message is sent, so a signal queued before a plan lapses isn't delivered after it.
Custom rules
On Basic (3 rules) and Pro (20 rules) you can set your own thresholds. Every rule is checked each hour on the same exact data as the default signals. A match is sent only to you, on every channel you connected; it carries a rule field with the rule's id and name, and it is not scored or shown in the public track record.
| kind | Thresholds | Default signal, for comparison |
|---|---|---|
| net_apr | minNet7d, minNet24h (defaults to minNet7d), minInRange7d (default 0.8) | 8% / 10% / 30% by pool type, 80% in range |
| depeg | minDeviation (0.001–0.5), stable and correlated pairs only | 0.5% |
| tvl_outflow | minDrop (0.05–0.95), windowHours (1–24, default 3) | 30% within 3 hours |
Narrow any rule with chains, pairClasses, pools ("chain:address", up to 50) and minTvlUsd (at least 100,000; default 1,000,000). cooldownHours (24–720, default 24) keeps a rule quiet on the same pool after it fires. An account gets at most 50 matches per 24 hours.
Rule matches arrive whatever your subscriptions are (turn off the kinds you only want through your rules). A match that repeats a signal you already receive for the same pool and hour is skipped. After a downgrade your rules are kept, but only as many as the plan allows run (active).
What is pushed to you
Telegram, webhooks and the WebSocket stream carry the kinds of signal you subscribe to. The core events are on by default: net_apr, tvl_outflow, depeg and smart_lp. Short-term burst and high-yield hot_pool signals are pushed only after you add them. Your plan still decides what you may receive, and matches of your custom rules always arrive.
Change it on your account page or with PUT /v1/me/subscriptions and {"kinds": ["net_apr", "burst", "depeg"]}. A WebSocket connection can pick its own kinds with ?kinds=net_apr,burst, and REST takes the same kinds parameter; source=subscribed returns exactly what your push channels deliver.
SDKs
Official SDKs for Node.js (20+) and Python (3.10+) wrap the whole API, the signal stream and webhook verification. They are open source under the MIT license: LPSignals/lpsignal-node and LPSignals/lpsignal-python.
npm install lpsignal
pip install lpsignal
- REST client with typed responses, API error codes as exceptions, and automatic retry after a rate-limit response.
- A stream that never misses a signal. Before every connection the SDK fetches anything newer than your last signal over REST, then resumes the stream after it. Outages of any length are filled, and while your process runs each signal reaches you once. After a crash the last one may come again, so handle signals idempotently by id.
- Resume after restarts. Save the last signal id with the built-in file store; Node and Python use the same file format.
- Webhook verification in one call, returning the parsed delivery.
Authentication
The REST API lives at https://api.lpsignal.app/v1. Public endpoints work without a key. Send your key as a bearer token to get your plan's view of signals and to use account endpoints. Keys start with lps_. Create or replace one from Account.
import { LPSignal } from 'lpsignal'; const lps = new LPSignal({ apiKey: process.env.LPSIGNAL_API_KEY }); const { signals, next } = await lps.signals({ kind: 'net_apr', limit: 20 }); // next page: lps.signals({ kind: 'net_apr', before: next })
import os from lpsignal import LPSignal lps = LPSignal(api_key=os.environ["LPSIGNAL_API_KEY"]) page = lps.signals(kind="net_apr", limit=20) # next page: lps.signals(kind="net_apr", before=page["next"])
curl "https://api.lpsignal.app/v1/signals?kind=net_apr&limit=20" \ -H "Authorization: Bearer lps_live_..."
Anonymous and free callers see opportunity signals once they are 24 hours old. Risk alerts are always included.
Rate limits and errors
| Limit | Requests / minute |
|---|---|
| Per API key | 300 |
| Without a key, per IP | 120 |
| All traffic, per IP | 600 |
Range backtests (/backtest) | 30 |
Over a limit you get 429 with a Retry-After header. Errors are JSON with a stable error code:
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | A parameter failed validation. issues lists each field. |
| 401 | invalid_api_key | The key is wrong or was replaced. |
| 403 | pro_required | The endpoint needs the Pro plan. |
| 404 | pool_not_found | Not a tracked pool on that chain. |
| 429 | rate_limited | Wait retryAfterSec seconds. |
| 500 | internal_error | Our fault. Include requestId if you contact support. |
Endpoints
| Path | Key | Returns | |
|---|---|---|---|
| GET | /v1/chains | – | Scan progress and pool count per chain |
| GET | /v1/pools? | – | Pools with their best range. window = 1, 24, 168 or 720 hours. sort = netApr (default), feeApr, ilApr, inRange, emissionApr, tvl, fee, volume24h, fees24h or poolApr; order = desc (default) or asc. Each pool carries volume24hUsd and fees24hUsd (an estimate of the swap fees paid in the last 24h: volume × the current fee rate, before protocol cuts; approximate for dynamic-fee pools) and best.net24h (the same range over 24h), and poolApr24h: the pool-level 24h APR DEX sites show (estimated 24h fees / TVL × 365; ignores impermanent loss; null for Uniswap v4); minPoolApr keeps pools with at least that (0.3 = 30%). The page carries total; page with offset. Pools under $10k TVL are left out unless minTvlUsd = 0 (a near-empty pool's per-liquidity APR is meaningless) |
| GET | /v1/pools/:chain/:address | – | One pool with every range × window metric. v4 pools use their 32-byte pool id |
| GET | /v1/pools/:chain/:address/hours? | – | Hourly fees, volume and price, up to 720 hours |
| GET | /v1/pools/:chain/:address/backtest? | – | Backtest any range (0 = full range) over up to 30 days |
| GET | /v1/signals? | optional | kinds = several kinds, e.g. net_apr,burst. Newest first, paged with before = the next value from the last response, or with offset (the page then carries total); or sort = return (the APR at firing) or outcome (the realised 7-day result), order = desc (default) or asc, paged with offset (the page carries total; signals without that figure come last). source = default (global signals), rules (your rule matches) or subscribed (exactly what your push channels deliver) |
| GET | /v1/signals/stats? | – | The track record of kind net_apr (default) or burst |
| GET | /v1/signals/:id | optional | One signal |
| GET | /v1/smart-lps? | optional | Leaderboard. sort = rank (default, the pnl rank), pnl, return, capital, closes, wins, apr or winRate; rank stays the pnl rank whatever the sort. Each wallet carries aprVsHold (annualised return vs holding over the capital × time it deployed), winRate and avgHoldH. The page carries total. Without Pro: top 10, addresses hidden |
| GET | /v1/smart-lps/:owner/positions? | Pro | A wallet's open and closed positions, each list paged and sorted on its own: openSort = lastEvent, openedAt or entryUsd; closedSort = closedAt, openedAt, capitalUsd or pnlUsd; totals in openTotal / closedTotal; an open position carries poolTick (its pool's tick now) |
| GET | /v1/me | yes | Your plan and connected channels |
| PUT | /v1/me/webhook | yes | Set the webhook URL. Returns the signing secret once |
| DELETE | /v1/me/webhook | yes | Remove the webhook |
| POST | /v1/me/telegram-link | yes | One-time code for the Telegram bot |
| GET | /v1/me/follows | Pro | Wallets you follow |
| PUT | /v1/me/follows/:owner | Pro | Follow a wallet (up to 50) |
| DELETE | /v1/me/follows/:owner | Pro | Unfollow |
| GET | /v1/me/rules | yes | Your rules, the plan limit and matches in the last 24 hours |
| POST | /v1/me/rules | paid | Create a rule (Basic: 3, Pro: 20) |
| PUT | /v1/me/rules/:id | yes | Replace a rule |
| DELETE | /v1/me/rules/:id | yes | Delete a rule |
| PUT | /v1/me/subscriptions | yes | Which kinds are pushed to you |
| WS | /v1/stream? | paid | Live signals, see below |
All APR and ratio fields are fractions: 0.345 means 34.5%. fee is in hundredths of a basis point, so 500 is a 0.05% fee tier. Timestamps are ISO 8601 in UTC. Ids are strings.
Signal object
REST, webhooks and the stream all send signals in this shape.
{ "id": "4821", "kind": "net_apr", "firedAt": "2026-09-30T09:00:00.000Z", "chain": "base", "dex": "uniswap_v3", "pool": "0xd0b53d9277642d899df5c87a3966a349a798f224", "pair": "WETH/USDC", "fee": 500, "pairClass": "volatile", "tvlUsd": 38200000, "rangeBp": 500, "entryTick": -200720, "tickLower": -201210, "tickUpper": -200230, "net24h": 0.41, "net7d": 0.345, "net30d": 0.298, "fee7d": 0.518, "il7d": -0.173, "inRange7d": 0.94, "rule": null, "outcome": null }
| Field | Meaning |
|---|---|
| kind | net_apr opportunity, burst short-term opportunity, hot_pool high yield (high risk), tvl_outflow or depeg risk alert, smart_lp wallet entry |
| rule | Set when one of your custom rules produced the signal: { id, name }. null for everything else. |
| tickLower, tickUpper | The recommended position. Use these ticks directly; they are already aligned to the pool's tick spacing. |
| rangeBp | The range tier tested, in basis points of price (500 = ±5%, 0 = full range) |
| fee7d, il7d | The two parts of net APR over 7 days. il7d is the loss against holding the two tokens, so it is zero or negative: net7d = fee7d + il7d. The same holds for feeApr, ilApr and netApr everywhere in the API. |
| net24h, net7d, net30d | Net APR of that position over each window. net30d is null until the pool has 30 days of history. |
| stakedEmissionApr | Slipstream only: gauge emissions at the current rate if staked instead. For reference only. |
| outcome | null until scored, then { status, netApr, feeApr, ilApr, evaluatedAt }. status is done or inexact. |
Risk alerts carry drop, tvlBeforeUsd, tvlNowUsd and windowHours (outflow) or deviation and severe (depeg). Smart LP alerts carry owner, rank, top, entryUsd and wallet30d. High-yield signals carry poolApr24h, poolApr7d, volume24hUsd, fees24hUsd, bestNet24h, bestRangeBp and risk.
Webhooks
Each signal is POSTed to your URL as {"type": "signal", "deliveryId": "…", "signal": {…}} with these headers:
| x-lpsignal-delivery | Unique per delivery. The same signal may arrive more than once, so skip ids you've already handled. |
| x-lpsignal-timestamp | Unix seconds when we signed the request |
| x-lpsignal-signature | sha256= HMAC-SHA256 of "<timestamp>.<raw body>" with your signing secret |
import express from 'express'; import { verifyWebhook, WebhookVerificationError } from 'lpsignal'; app.post('/lpsignal', express.raw({ type: 'application/json' }), (req, res) => { try { const event = verifyWebhook(req.body, req.headers, process.env.LPSIGNAL_WEBHOOK_SECRET); // at-least-once: skip event.deliveryId if you have already handled it res.sendStatus(200); } catch (e) { if (e instanceof WebhookVerificationError) return res.status(400).send(e.reason); throw e; } });
from fastapi import FastAPI, Request, Response from lpsignal import WebhookVerificationError, verify_webhook app = FastAPI() @app.post("/lpsignal") async def lpsignal(request: Request): try: event = verify_webhook(await request.body(), request.headers, SECRET) except WebhookVerificationError as e: return Response(e.reason, status_code=400) # at-least-once: skip event["deliveryId"] if you have already handled it return Response(status_code=200)
import crypto from 'node:crypto'; // without the SDK: use the raw request body, re-serialised JSON will not match function verify(rawBody, headers, secret) { const ts = headers['x-lpsignal-timestamp']; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window const want = 'sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex'); const got = headers['x-lpsignal-signature'] ?? ''; return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want)); }
- Respond with any 2xx within 15 seconds. Redirects are not followed.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes and 2 hours, then dropped.
- The URL must be HTTPS and resolve to a public address. It is checked again on every delivery.
WebSocket stream
The SDKs handle everything below for you, including catching up after outages longer than 24 hours. Connect to wss://api.lpsignal.app/v1/stream with your key in the Authorization header, or as ?key= from a browser. Signals arrive in ascending id order and never twice on one connection. Save the last id you processed and reconnect with ?since=<id> to catch up on anything from the last 24 hours. Add ?kinds=net_apr,burst to choose the kinds for one connection; without it you get the kinds you subscribe to.
import { LPSignal, SignalStream, FileLastIdStore } from 'lpsignal'; const stream = new SignalStream({ client: new LPSignal({ apiKey: process.env.LPSIGNAL_API_KEY }), store: new FileLastIdStore('./lpsignal-state.json'), // resume point survives restarts onSignal: async (signal, { source }) => { // once per signal, in id order; source = 'rest' | 'replay' | 'live' }, onEvent: (e) => { if (e.type === 'fatal') console.error(e.error.message); }, }); await stream.start();
import asyncio, os from lpsignal import AsyncLPSignal, FileLastIdStore, SignalStream async def on_signal(signal, source): # "rest" | "replay" | "live" ... async def main(): lps = AsyncLPSignal(api_key=os.environ["LPSIGNAL_API_KEY"]) await SignalStream(lps, on_signal, store=FileLastIdStore("lpsignal-state.json")).run() asyncio.run(main())
| Message or close code | Meaning |
|---|---|
| {"type":"signal"} | A signal. replay: true marks catch-up messages after ?since=. |
| {"type":"ready"} | Catch-up finished. Everything after this is live. |
| {"type":"replay_truncated"} | More than 1,000 signals to catch up. Reconnect with since set to the lastId given. |
| close 4409 | Catch-up couldn't finish. Reconnect with the last id you received. |
| close 4402 | Your plan no longer includes the stream. |
| close 1001 | Server restart. Reconnect with since. |
The server pings every 30 seconds; standard clients answer automatically.
Telegram
- In Account, choose Connect Telegram to get a one-time code.
- Open @LPsignals_bot and send
/start <code>. - Signals your plan includes now arrive in that chat. Free plans get opportunity signals 24 hours after they fire.