Hyperliquid, an onchain crypto exchange, lets anyone read any account's fills through its API, but not all of them. The well-known limit is "the 10,000 most recent fills". We tested five accounts on 13 November 2026. The limits are different from that, and an account's own TWAP fills are missing from the list altogether. This guide shows what the native API returns, how to pull a complete history from 0xArchive, and how to turn it into a trading journal and a tax CSV. All code ran on 13 November 2026.
Short answer
userFillsreturns the latest 2,000 fills. On a busy account that was 1 hour 20 minutes of trading.userFillsByTimepages further, then stops. We reached between 7,156 and 34,263 fills per account. Below a per-account floor every request returns nothing. On the busiest account the floor was 3 days back. On a light account it was 22 months.- TWAP fills are missing. One account had 12,309 TWAP fills in three days that the native list never returned. PnL rebuilt from native fills alone is wrong for anyone who uses TWAP orders.
- 0xArchive keeps every fill for every account from 25 May 2025, including TWAP fills, with funding payments from 27 September 2025. The journal and CSV below are built from it.
Fills, trades and orders
Three words get mixed up, and the difference decides how you count:
- An order is an instruction: buy 2 BTC at market, or rest a bid at $68,000.
- A trade happens when two orders meet. One trade has a buyer and a seller.
- A fill is one side of a trade, from one account's point of view. Every trade produces two fills, one for the taker and one for the maker.
One market order can match many resting orders, so one order can produce many fills. In one minute of BTC trading on 13 November, every trade appeared as exactly two fill rows, and one taker order swept 61 resting orders: 61 trades and 61 fills for that taker. If you sum volume over fill rows, you count every trade twice. Count the taker side only. Step 5 shows how to check this yourself.
What you need
- Python 3 with
requests. The snippets are short prototypes without retry logic. - A 0xArchive API key in
OXARCHIVE_API_KEY. The Free plan includes the wallet routes with a rolling 30 days of history. A full year needs a paid plan; see pricing. - The account address you want, in
WALLET. For the native test, a busy account inACTIVE_WALLET.
Step 1: see what the native API returns
This pages userFillsByTime forward from the start of time until it stops, and then asks for the day just before the earliest fill it found:
import os, time, csv, collections, requests
from datetime import datetime, timezone
N = "https://api.hyperliquid.xyz/info"
ACTIVE = os.environ["ACTIVE_WALLET"] # a busy account
def native_fills(user):
"""Page userFillsByTime forward from the start of time until nothing new comes back."""
seen, start, calls = {}, 0, 0
while True:
page = requests.post(N, json={"type": "userFillsByTime", "user": user,
"startTime": start}, timeout=30).json()
calls += 1
new = [f for f in page if (f["tid"], f["oid"]) not in seen]
for f in new:
seen[(f["tid"], f["oid"])] = f
if not new or len(page) < 2000:
return sorted(seen.values(), key=lambda f: f["time"]), calls
start = page[-1]["time"]
time.sleep(0.3)
recent = requests.post(N, json={"type": "userFills", "user": ACTIVE}, timeout=30).json()
fills, calls = native_fills(ACTIVE)
first = fills[0]["time"]
before = requests.post(N, json={"type": "userFillsByTime", "user": ACTIVE,
"startTime": first - 86_400_000, "endTime": first - 1}, timeout=30).json()
ts = lambda m: datetime.fromtimestamp(m / 1000, timezone.utc).strftime("%Y-%m-%d %H:%M")
print("userFills rows:", len(recent))
print("userFillsByTime:", len(fills), "fills in", calls, "requests, earliest", ts(first))
print("fills in the day before that:", len(before))userFills rows: 2000
userFillsByTime: 22876 fills in 12 requests, earliest 2026-11-10 14:48
fills in the day before that: 0userFills stopped at 2,000. userFillsByTime returned 22,876 fills in 12 requests, back to 10 November, and the day before that was empty although the account traded then. The floor sits wherever Hyperliquid last trimmed this account's fills. Across our five accounts it ranged from under four hours to 22 months back. On one account it stayed put for 25 minutes of continuous trading, then moved forward 11 minutes. There is no documented schedule.
The other per-account calls have their own limits:
historicalOrdersreturns the latest 2,000 orders. On the busiest account that was 23 seconds.userFundingis hourly for about the last eight days. Older days come back as one row per coin per day. Totals still add up, but you cannot see individual hours.- Calls are rate-limited by weight per IP. Paging at one request every 0.3 seconds, 18 of 253 calls hit HTTP 429.
Step 2: find what the native list leaves out
The 0xArchive wallet route returns every fill leg for an account, following a cursor. Compare it with the native list for the same window:
H = {"X-API-Key": os.environ["OXARCHIVE_API_KEY"]}
B = "https://api.0xarchive.io/v1/hyperliquid"
def changes(wallet, start, end, family=""):
"""Every fill leg for one wallet, following the cursor. family="/hip3" for HIP-3 markets."""
rows, cursor = [], None
while True:
p = {"start": start, "end": end, "limit": 5000}
if cursor:
p["cursor"] = cursor
j = requests.get(f"{B}{family}/wallets/{wallet}/positions/changes",
headers=H, params=p, timeout=120).json()
rows += j["data"]
cursor = j["meta"].get("next_cursor")
if not j["meta"].get("has_more"):
return rows, j["meta"]
now = int(time.time() * 1000)
ours, meta = changes(ACTIVE, first, now)
native_ids = {f["tid"] for f in fills}
extra = [r for r in ours if int(r["trade_id"]) not in native_ids]
print("0xArchive legs since", ts(first), ":", len(ours), "| built through", meta["built_through"])
print("legs missing from the native list:", len(extra),
collections.Counter(r["coin"] for r in extra).most_common(4))0xArchive legs since 2026-11-10 14:48 : 34839 | built through 2026-11-13T20:00:00.000Z
legs missing from the native list: 12309 [('XRP', 3348), ('kPEPE', 3143), ('ZEC', 2970), ('BNB', 2848)]Over the same window, 0xArchive held 34,839 fill legs and 12,309 of them were absent from the native list. All were the account's own TWAP slices: the native API never returned one of them. Hyperliquid has a separate userTwapSliceFills call, and it returns only the latest 2,000 slices, which on this account covered 3.5 hours. Where both sources do have a fill, the trade IDs matched 100%.
The route is built through the top of the previous hour, shown in built_through. At 20:54 UTC it was complete through 20:00. It is a record of what happened, not a live feed. For live fills, use the wallet tracking guide.
Step 3: build a trading journal
A journal groups fills into round trips: a position opened from zero, adjusted, and closed back to zero. Each fill leg carries its position before and after, so the grouping is exact. Pull core and HIP-3 markets separately, because they are separate routes:
W = os.environ["WALLET"] # the account you want a journal for
start = int(datetime(2025, 5, 25, 15, tzinfo=timezone.utc).timestamp() * 1000) # first hour of the record
legs = []
for fam in ("", "/hip3"):
rows, _ = changes(W, start, now, fam)
legs += rows
def chain(rows):
"""Order legs so each one starts where the last one on that coin ended.
Legs that share a millisecond can come back in any order."""
out, last, groups = [], {}, collections.defaultdict(list)
for r in rows:
groups[(r["timestamp"], r["coin"])].append(r)
for (t, coin), g in sorted(groups.items()):
while g:
pos = last.get(coin, 0.0)
nxt = next((r for r in g if abs(float(r["start_position"]) - pos) < 1e-9), g[0])
g.remove(nxt)
out.append(nxt)
last[coin] = float(nxt["end_position"])
return out
trips, open_ = [], {}
for r in chain(legs):
c, sp, ep = r["coin"], float(r["start_position"]), float(r["end_position"])
t = open_.setdefault(c, {"coin": c, "side": "long" if (ep or sp) > 0 else "short",
"opened": r["timestamp"], "pnl": 0.0, "fees": 0.0, "complete": sp == 0})
share = abs(sp) / (abs(sp) + abs(ep)) if r["event_type"] == "flip" else 1.0
t["pnl"] += float(r["closed_pnl"])
t["fees"] += float(r["fee"]) * share # a flip's fee is split between the two trips
if ep == 0 or r["event_type"] == "flip":
t["closed"] = r["timestamp"]
trips.append(open_.pop(c))
if ep != 0: # a flip opens the next trip on the same fill
open_[c] = {"coin": c, "side": "long" if ep > 0 else "short", "opened": r["timestamp"],
"pnl": 0.0, "fees": float(r["fee"]) * (1 - share), "complete": True}
cut = datetime.fromtimestamp((now - 90 * 86_400_000) / 1000, timezone.utc).isoformat()
recent = [t for t in trips if t["complete"] and t["closed"] >= cut]
net = [t["pnl"] - t["fees"] for t in recent]
print(len(legs), "legs;", len(recent), "round trips closed in 90 days")
print("win rate %.1f%%" % (100 * sum(n > 0 for n in net) / len(net)),
"| closed PnL $%.2f | fees $%.2f" % (sum(t["pnl"] for t in recent), sum(t["fees"] for t in recent)))25562 legs; 422 round trips closed in 90 days
win rate 43.4% | closed PnL $14791.62 | fees $1993.85The chain step matters. Legs on the same coin in the same millisecond come back in any order. On this account, taking them as returned broke the position chain 15,380 times. Ordering each group so every leg starts where the previous one ended brought that to zero. A flip closes one trip and opens the next on the same fill.
This account closed 422 round trips in the last 90 days, won 43.4% of them, and made $14,791.62 of closed PnL against $1,993.85 of fees. Trips that were already open when the window started are skipped, because their opening cost is outside it.
To check your totals, compare them with the wallet profile route. Without a coin filter it counts whole UTC days and leaves out today, so set your window to match.
Step 4: add funding and export a CSV
Funding is the payment between longs and shorts every hour, and for a perp trader it is part of PnL. The funding route accepts up to 31 days per request, so loop over windows:
funding = []
for fam in ("", "/hip3"):
s = start
while s < now:
e = min(s + 31 * 86_400_000, now) # 31 days per request at most
cursor = None
while True:
p = {"start": s, "end": e, "limit": 5000}
if cursor:
p["cursor"] = cursor
j = requests.get(f"{B}{fam}/wallets/{W}/funding", headers=H, params=p, timeout=60).json()
funding += j["data"]
cursor = j["meta"].get("next_cursor")
if not j["meta"].get("has_more"):
break
s = e
with open("hyperliquid_history.csv", "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["Date", "Market", "Side", "Amount", "Price", "Fee", "Realized PnL", "Funding"])
for r in legs:
w.writerow([r["timestamp"], r["coin"], r["side"], r["size"], r["price"],
r["fee"], r["closed_pnl"], ""])
for f in funding:
w.writerow([f["timestamp"], f["coin"], "funding", "", "", "", "", f["funding_amount"]])
total = sum(float(f["funding_amount"]) for f in funding)
print(len(funding), "funding rows, net funding $%.2f" % total, "| csv rows", len(legs) + len(funding))36161 funding rows, net funding $-2516.26 | csv rows 61723This account paid $2,516.26 more funding than it received across core and HIP-3 markets. $865.48 of it came from HIP-3 markets alone, which a core-only export would have dropped. Positive funding_amount means received, negative means paid.
The CSV has one row per fill and one per funding payment. Tax tools disagree on how perps go in. Koinly's custom CSV guide takes futures as realized gains and losses, tagged with labels such as "realized gain", "futures fee" and "funding fee", rather than as opening and closing trades. Map the columns to your tool's format before importing. This is not tax advice.
Step 5: count trades, not fills
The market trades route returns both fills of every trade, with crossed marking the taker. Count rows, trades and how many resting orders each taker order hit:
r = requests.get(f"{B}/trades/BTC", headers=H, timeout=60, params={
"start": "2026-11-13T17:06:00Z", "end": "2026-11-13T17:07:00Z", "limit": 10000}).json()["data"]
per_trade = collections.Counter(x["trade_id"] for x in r)
takers = collections.defaultdict(set)
for x in r:
if x["crossed"]:
takers[(x["user_address"], x["order_id"])].add(x["trade_id"])
big = max(takers.values(), key=len)
print(len(r), "fill rows,", len(per_trade), "trades, rows per trade:", set(per_trade.values()))
print(len(takers), "taker orders; the largest filled against", len(big), "resting orders")Every trade had exactly two rows. The largest taker order filled against 61 resting orders, so a journal that counted it as one trade would undercount fills, and one that summed both rows would double its volume.
Side by side: native API and 0xArchive
We ran the same jobs against each source on 13 November 2026.
| Job | Hyperliquid API | 0xArchive |
|---|---|---|
| Recent fills | userFills: latest 2,000 | Every fill, cursor-paged |
| Older fills | userFillsByTime: 7,156 to 34,263 per account, then a floor | From 25 May 2025 for every account |
| TWAP fills | Missing from fills; 2,000 latest via a separate call | Included |
| Funding | Hourly for about 8 days, daily after | Hourly from 27 September 2025, core and HIP-3 |
| Order history | Latest 2,000 orders | Order lifecycle by market from March 2026 |
| Freshness | Live | Through the top of the last hour |
The native API is enough for a light account that trades without TWAP orders: one of our test accounts could still reach 8,384 fills from before May 2025 natively. It is also the right source for fills from the last hour. For busy accounts, TWAP users, hourly funding beyond a week, or a year of history, use the full record. See Hyperliquid's native API vs 0xArchive for every dataset.
What to know before you build
- Dedupe on the whole fill. Paging
userFillsByTimefrom the last row's time repeats that row. If 2,000 fills share one millisecond, time paging cannot get past them without risking a gap. The 0xArchive route pages by cursor and has no such limit. - Core and HIP-3 are separate. Fills and funding for HIP-3 markets such as xyz:NVDA live under
/hip3/wallets/. Spot and HIP-4 fills are not on these routes. - Do not merge partial fills before reconciling. Hyperliquid's
aggregateByTimeoption turned a 61-fill sweep into one row, which no longer matches trade IDs. - Old orders vanish natively.
orderStatusfor a cancelled order from two hours earlier returnedunknownOid. - Lighter uses account indexes. The Lighter route is
/v1/lighter/accounts/{account_index}/positions/changes, from 17 January 2025, with fields such asrealized_pnlandfee_usdc. Rows pastfinalized_throughare preliminary.
FAQ
How do I export my full Hyperliquid trade history?
Page the core and HIP-3 wallet routes from 25 May 2025 15:00 UTC to now, as in Step 3. Both include TWAP fills. Spot and HIP-4 fills are not on these routes. Hyperliquid's own export stops at a per-account floor.
Is the Hyperliquid API limited to 10,000 fills?
Not exactly. userFills returns 2,000. userFillsByTime reached up to 34,263 fills in our tests, then returned nothing older than a per-account floor that ranged from hours to months back.
Why does my rebuilt PnL not match Hyperliquid's?
The most common cause is missing TWAP fills, which the native fill list leaves out. The second is HIP-3 markets and funding, which sit on separate routes.
Can I get my Hyperliquid trades into a tax tool?
Yes. Export fills and funding to CSV as in Step 4, then map the columns to your tool's import format.
How far back does Hyperliquid fill history go?
0xArchive has every account's fills from 25 May 2025 15:00 UTC. Natively, it depends on the account's floor.
Next steps
- Follow an account live: build a Hyperliquid whale tracker.
- Every dataset and its start date: Hyperliquid historical data.
- Track TWAP orders: Hyperliquid TWAP tracker.
- The routes: wallet and position reference.
- Start with a free API key.