Two in three 'liquidity pulled' alerts are the same wallet putting it back
A treasury bot pings: LP removed, −$21,330,275, UNI/WBTC. Somebody pulls up a chart, somebody asks in the group chat whether the market maker left, and for the next hour the answer is a guess.
The answer was sitting in the same API, one query away. 132 seconds later the same wallet put 99.8% of that money — the same 1,962,475.5391248302 UNI, to the last decimal — into UNI/WETH, one pool over. The alert was true. The panic was not.
So I stopped looking at one removal and followed 335 of them. Two in three "liquidity pulled" alerts were the same wallet putting it back within six hours. This post is about that number, the one API parameter that produces it, and the two kinds of row that almost made it wrong.
- Live: forwarding.edycu.dev (the short path is /judge)
- Repo: github.com/edycutjong/forwarding — MIT, stdlib-only Python, no API key
The alert everyone fires, and the row nobody reads
CoinMarketCap's DEX API has /v1/dex/liquidity-change/list: every add and remove on a token's pools, with a USD value (tu), a side (tp), a timestamp, and — the part that matters — the wallet that did it, m. Filter it with minVolume=100000, sort by tu, and you have an "LP removed" alert bot. That is the obvious product, and it stops at the first row.
The endpoint also accepts maker= as a server-side filter. One keyless call returns everything one wallet did across every pool of the token. That is the whole trick — the second row was never hidden, it was just never joined:
def follow_maker(
client, platform, address, maker, *, t0_ms, back_h=W_BACK_H, fwd_h=W_FWD_H, pages=FOLLOW_PAGES
):
"""THE JOIN. Every liquidity event by `maker` on `address`, inside t0 -+ the window."""
lo = t0_ms - int(back_h * 3600_000)
rows, meta = walk(
client,
{"platform": platform, "address": address, "maker": maker},
pages,
until=lambda page: min(ts_ms(r) for r in page) < lo,
)
return window_rows(rows, meta, t0_ms=t0_ms, back_h=back_h, fwd_h=fwd_h)
The walk() with a cursor is there because startTime is plan-gated on the keyless tier (HTTP 403, error 1013) while endTime is not, so a ±6 h window has to be paged back to with lastId and cut client-side on ts.
Here are the two rows from the live run, verbatim fields:
remove ts=1788321551000 tu=-21330274.564875204 m=0x4f0aa5900b8292273b2f9a178d5468f8048bb9a9 en=Ring Exchange (Ethereum) a0=-1962475.5391248302
add ts=1788321683000 tu=21287254.934237212 m=0x4f0aa5900b8292273b2f9a178d5468f8048bb9a9 en=Ring Exchange (Ethereum) a0=1962475.5391248302
21,287,254.93 ÷ 21,330,274.56 = 0.9980
Same maker, same token amount, a different pair. That run made 14 calls, all 200, 0 credits, in 50.6 s, and every response body is embedded in the receipt under the sha256 the trace prints.
Turning two rows into a verdict
Once you have the wallet's rows inside the window, the classification is arithmetic on tu — no model produces a number. The adds are split into same pool and other pools, and the shares decide:
if same_share >= FULL and same_share >= other_share:
kind = "REBALANCE"
elif other_share >= FULL:
kind = "MIGRATION"
if dest and dest["pubAt_ms"] and dest["pubAt_ms"] > ts_ms(removal):
kind = "CONSOLIDATION"
elif total_share >= PARTIAL_MIN: