---
title: "Export Hyperliquid Trade History: Fills, PnL, Funding"
description: "Hyperliquid's API caps fills and drops TWAP fills. Export every fill, PnL, fee and funding payment to a trading journal and tax CSV with Python."
canonical_url: "https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history"
markdown_url: "https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history.md"
route: "/resources/market-data-guides/hyperliquid-trade-history"
robots: "index, follow, max-image-preview:large"
generated_from: "prerendered_html"
---

# How to Export Your Full Hyperliquid Trade History

What Hyperliquid's API really returns for an account's fills, why TWAP fills go missing, and how to build a complete journal and tax CSV.

By

[0xArchive Team](https://0xarchive.io/authors/0xarchive-team)

November 14, 2026

12 min read

- [Hyperliquid](https://0xarchive.io/resources?topic=hyperliquid)

## Key findings

1. [Step 1: see what the native API returns](https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history#article-section-step-1-see-what-the-native-api-returns) 2,000 Fills returned by Hyperliquid's userFills: 1 hour 20 minutes on a busy account.
2. [Step 2: find what the native list leaves out](https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history#article-section-step-2-find-what-the-native-list-leaves-out) 12,309 An account's own TWAP fills in three days that Hyperliquid's fill list never returned.
3. [Step 3: build a trading journal](https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history#article-section-step-3-build-a-trading-journal) 15,380 Broken position chains from same-millisecond fills before ordering them correctly.
4. [Side by side: native API and 0xArchive](https://0xarchive.io/resources/market-data-guides/hyperliquid-trade-history#article-section-side-by-side-native-api-and-0xarchive) 25 May 2025 Start of the complete per-account fill record on 0xArchive.

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

- **`userFills` returns the latest 2,000 fills.** On a busy account that was 1 hour 20 minutes of trading.
- **`userFillsByTime` pages 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](https://0xarchive.io/pricing) .
- The account address you want, in `WALLET` . For the native test, a busy account in `ACTIVE_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: 0
```

`userFills` 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:

- `historicalOrders` returns the latest 2,000 orders. On the busiest account that was 23 seconds.
- `userFunding` is 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](https://0xarchive.io/resources/market-data-guides/hyperliquid-whale-tracker).

## 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.85
```

The `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 61723
```

This 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](https://support.koinly.io/en/articles/9489976-how-to-create-a-custom-csv-file-with-your-data) 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](https://0xarchive.io/resources/comparisons/native-hyperliquid-api-vs-0xarchive) for every dataset.

## What to know before you build

- **Dedupe on the whole fill.** Paging `userFillsByTime` from 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 `aggregateByTime` option turned a 61-fill sweep into one row, which no longer matches trade IDs.
- **Old orders vanish natively.** `orderStatus` for a cancelled order from two hours earlier returned `unknownOid` .
- **Lighter uses account indexes.** The Lighter route is `/v1/lighter/accounts/{account_index}/positions/changes` , from 17 January 2025, with fields such as `realized_pnl` and `fee_usdc` . Rows past `finalized_through` are 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](https://0xarchive.io/resources/market-data-guides/hyperliquid-whale-tracker) .
- Every dataset and its start date: [Hyperliquid historical data](https://0xarchive.io/resources/market-data-guides/hyperliquid-historical-data) .
- Track TWAP orders: [Hyperliquid TWAP tracker](https://0xarchive.io/resources/market-data-guides/hyperliquid-twap-tracker) .
- The routes: [wallet and position reference](https://docs.0xarchive.io/rest-api/positions) .
- Start with a [free API key](https://0xarchive.io/pricing) .
