Where Did BitMEX's Clients Go? Tracing an Exchange Wind-Down With the Arkham API

BitMEX shut down on September 23. Every client it had withdrew somewhere, and those withdrawals are public. Arkham names a lot of the wallets on the other end, so you can see exactly which venues picked up BitMEX's book, and which named firms left early versus waited until they were forced out.
A quick timeline, because it matters for the analysis. BitMEX announced the closure on July 23 and stopped new sign ups the same day. Trading ran normally until August 26, when the exchange switched to reduce-only and started force-closing positions. Everything still open at 04:00 UTC on September 23 was closed. That gives you three natural windows: the voluntary leavers, the people who waited for reduce-only, and the stragglers still withdrawing after the lights went off.
First question you might have: BitMEX is a centralized exchange, so how is any of this on-chain? A CEX still holds customer funds in its own wallets, and for BitMEX that was mostly Bitcoin in a well-known set of addresses. Every withdrawal is a real transaction from those wallets to whatever address the client pasted in. Arkham has those wallets labeled as BitMEX, so every payout shows up as an outflow from the entity.
https://arkm.com/explorer/entity/bitmex

Start with every wallet BitMEX paid out to since the announcement.
import requests
resp = requests.get(
"https://api.arkm.com/counterparties/entity/bitmex",
params={"flow": "out", "limit": 100, "timeGte": "2026-07-23"},
headers={"API-Key": "YOUR_API_KEY"},
)
outflows = resp.json()
I'm running everything here on a sample of 100 rows per call. Counterparties come back sorted by USD, so that's the top 100 destinations, which is where the story is anyway. The reason for the cap: API credits cost the same at any limit, but every labeled row you get back also counts against a separate Intel label lookup quota.
Each row lands in one of five buckets, and the bucket tells you what kind of signal it is.

A row with a depositServiceID is the best signal you'll get. Every user on an exchange gets their own deposit address, so a BitMEX withdrawal landing in a Binance deposit address means a client moved straight to Binance. You see the move and the size, though not who the client is unless Arkham has named that deposit address too. A row where arkhamEntity.type is cex is a different thing. Users don't withdraw to another exchange's hot wallet, so money going straight there is usually BitMEX itself moving treasury or selling, not a client. Keep it separate. A named fund, otc, or custodian is a firm pulling money back home, which tells you who left but not yet where they went. Everything unlabeled is self-custody, mostly retail. And rows going back to BitMEX's own wallets are internal shuffling.
def destination(row):
addr = row["address"]
entity = addr.get("arkhamEntity") or {}
dep = addr.get("depositServiceID")
if entity.get("id") == "bitmex" or dep == "bitmex":
return None, "internal" # BitMEX moving its own funds
if dep:
return dep, "venue" # client's deposit address elsewhere
if entity.get("type") == "cex":
return entity["id"], "exchange_direct" # likely BitMEX treasury
if entity:
return entity["id"], entity.get("type")
return addr.get("address"), "unlabeled"
Drop the internal rows first. Exchanges shuffle funds between hot and cold wallets constantly, and during a wind-down that shuffling gets bigger, since everything gets consolidated for payouts.

Now split the pull by phase. Run the same call three times with a timeLte on the first two windows, and tally USD per venue for each one.
PHASES = {
"voluntary": ("2026-07-23", "2026-08-26"),
"reduce_only": ("2026-08-26", "2026-09-23"),
"after_close": ("2026-09-23", None),
}
The voluntary column is the interesting one. Those are traders who still had a fully working exchange and chose to leave in the first five weeks. Whoever captured that money won on product or relationships, not on being the default. The reduce-only column is closer to inertia. People who waited until they couldn't open new positions mostly go wherever they already had an account.

Next, follow the named firms one hop further. A fund withdrawing to its own wallet hasn't told you its next venue yet, but its own outflows will.
resp = requests.get(
f"https://api.arkm.com/counterparties/entity/{firm_id}",
params={"flow": "out", "limit": 100, "timeGte": "2026-07-23"},
headers={"API-Key": "YOUR_API_KEY"},
)
next_hop = resp.json()
Run that through the same destination function and keep only the venue rows. This hop is where your quota goes, since every firm is another full call, so the script only traces the top couple of firms and the single biggest unlabeled wallet. The /counterparties endpoints are also limited to one request per second, so sleep between calls, and cache every response to disk so a re-run costs nothing.

Unlabeled wallets work the same way with /counterparties/address/{address}. Most retail withdrawals sit in self-custody for a while before going anywhere, so don't expect every one to resolve. The large ones that do tend to be whales who never got labeled, which is a list worth having on its own.
Three things to keep honest before you put any of this in a deck.
First, arkhamEntity is a confirmed attribution and predictedEntity is a guess. A firm moving money in a hurry often uses fresh wallets, so a shutdown window has more predictions than usual. The script ignores predictions completely. Treat them as leads to check by hand.
Second, not everything leaves on-chain. OTC desks often settle off-chain, some institutions move balances between venues through off-exchange settlement networks without a transaction ever hitting the chain, and fiat withdrawals never show up at all. Institutions use these rails more than retail does, so the named-firm side of this picture is undercounted.
Third, money moved isn't the same as volume traded. A fund that withdrew to a rival venue might trade heavily there or barely touch it. What this shows is where the balances landed, which is the leading indicator. Volume follows later.
The reason this matters past BitMEX: the same script works on any venue that's shrinking. Swap in the entity id, set timeGte to the date things started going wrong, and you'll see who's leaving and where they're going while it's still happening.
Here's the full script, top to bottom.
import json
import time
from collections import defaultdict
from pathlib import Path
import requests
API_KEY = "YOUR_API_KEY"
HEADERS = {"API-Key": API_KEY}
BASE = "https://api.arkm.com"
EXCHANGE = "bitmex"
START = "2026-07-23" # shutdown announced
LIMIT = 100 # sample: top 100 rows per call
PHASES = {
"voluntary": ("2026-07-23", "2026-08-26"),
"reduce_only": ("2026-08-26", "2026-09-23"),
"after_close": ("2026-09-23", None),
}
NAMED_TYPES = ("fund", "otc", "custodian")
TOP_FIRMS = 2 # each second hop is another full call
TOP_WALLETS = 1
CACHE = Path("arkham_cache")
CACHE.mkdir(exist_ok=True)
def counterparties(kind, target, flow, gte, lte=None):
cache_file = CACHE / f"{kind}_{target}_{flow}_{gte}_{lte}_{LIMIT}.json"
if cache_file.exists():
return json.loads(cache_file.read_text())
params = {"flow": flow, "limit": LIMIT, "timeGte": gte}
if lte:
params["timeLte"] = lte
for attempt in range(5):
resp = requests.get(f"{BASE}/counterparties/{kind}/{target}",
params=params, headers=HEADERS)
if resp.status_code == 429:
time.sleep(2 ** attempt)
continue
resp.raise_for_status()
data = resp.json()
cache_file.write_text(json.dumps(data))
time.sleep(1.1) # heavy endpoint, 1 request per second
return data
raise RuntimeError(f"rate limited on {target}")
def destination(row):
addr = row["address"]
entity = addr.get("arkhamEntity") or {}
dep = addr.get("depositServiceID")
if entity.get("id") == EXCHANGE or dep == EXCHANGE:
return None, "internal"
if dep:
return dep, "venue"
if entity.get("type") == "cex":
return entity["id"], "exchange_direct"
if entity:
return entity["id"], entity.get("type")
return addr.get("address"), "unlabeled"
# Step 1: where BitMEX money went, phase by phase
venue_usd = defaultdict(lambda: defaultdict(float))
exchange_direct = defaultdict(float)
firms = defaultdict(float)
unlabeled = defaultdict(float)
types_seen = set()
for phase, (gte, lte) in PHASES.items():
data = counterparties("entity", EXCHANGE, "out", gte, lte)
for chain, rows in data.items():
for row in rows:
dest, kind = destination(row)
types_seen.add(kind)
usd = row.get("usd") or 0
if kind == "venue":
venue_usd[dest][phase] += usd
elif kind == "exchange_direct":
exchange_direct[dest] += usd
elif kind in NAMED_TYPES:
firms[dest] += usd
elif kind == "unlabeled":
unlabeled[dest] += usd
print(f"Destination types seen: {sorted(t for t in types_seen if t)}")
print(f"{len(venue_usd)} venues, {len(firms)} named firms, {len(unlabeled)} unlabeled wallets")
# Step 2: second hop for the top named firms and the biggest unlabeled wallet
def next_venues(kind, target):
data = counterparties(kind, target, "out", START)
found = defaultdict(float)
for chain, rows in data.items():
for row in rows:
dest, k = destination(row)
if k == "venue":
found[dest] += row.get("usd") or 0
return found
top_firms = sorted(firms.items(), key=lambda x: x[1], reverse=True)[:TOP_FIRMS]
top_wallets = sorted(unlabeled.items(), key=lambda x: x[1], reverse=True)[:TOP_WALLETS]
second_hop = {}
for firm_id, usd in top_firms:
try:
second_hop[firm_id] = (usd, next_venues("entity", firm_id))
except requests.HTTPError as e:
print(f" skipped {firm_id}: {e}")
for wallet, usd in top_wallets:
try:
second_hop[wallet] = (usd, next_venues("address", wallet))
except requests.HTTPError as e:
print(f" skipped {wallet[:10]}: {e}")
# Step 3: venues ranked by client money received, split by phase
print(f"
{'Venue':24} {'Voluntary':>14} {'Reduce-only':>14} {'After close':>14} {'Total':>15}")
print("-" * 85)
ranked = sorted(venue_usd.items(), key=lambda x: sum(x[1].values()), reverse=True)
for venue, phases in ranked[:20]:
v, r, a = phases["voluntary"], phases["reduce_only"], phases["after_close"]
print(f"{venue[:24]:24} {v:>14,.0f} {r:>14,.0f} {a:>14,.0f} {v + r + a:>15,.0f}")
# Step 4: direct exchange-to-exchange moves, likely BitMEX treasury, not clients
print(f"
{'Exchange (direct)':24} {'USD':>15}")
print("-" * 40)
for ex, usd in sorted(exchange_direct.items(), key=lambda x: x[1], reverse=True):
print(f"{ex[:24]:24} {usd:>15,.0f}")
# Step 5: where the named firms and whales went next
print(f"
{'Firm / wallet':30} {'Left BitMEX':>14} Next venues")
print("-" * 85)
for who, (usd, venues) in second_hop.items():
top = sorted(venues.items(), key=lambda x: x[1], reverse=True)[:3]
names = ", ".join(v for v, _ in top) or "not on a venue yet"
print(f"{who[:30]:30} {usd:>14,.0f} {names}")
Related post: Arkham API Competitor Analysis: Find Every Wallet That Trades With a Rival Protocol