Writing

Reconstructing round-trip trades from raw Hyperliquid fills

  • hyperliquid
  • python
  • quant

Most "what was my PnL?" tools you can run on a Hyperliquid wallet look at fills one at a time and sum the closedPnl field. That's fine if you only need a total. It falls apart the moment you want to ask anything more interesting.

For example:

  • What was my average hold time?
  • Which asset do I actually make money on after fees?
  • When did I scale into a position vs flip direction?
  • What's my round-trip win rate, not my fill-by-fill hit rate?

None of those questions are answerable from a flat list of fills. You need trades— opened position, held it, closed it, here's the entry, here's the exit, here's the duration. HL's API doesn't give you that. You have to reconstruct it.

This post walks through how, and points at the open-source library that does it for you.

What HL actually gives you

userFillsByTime returns a flat list of execution events. Each fill is:

{
  "coin": "BTC",
  "px": 68000.0,
  "sz": 0.5,
  "side": "B",
  "time": 1712006400000,
  "startPosition": 0.0,
  "dir": "Open Long",
  "closedPnl": 0.0,
  "fee": 8.5,
  "liquidation": false
}

The interesting field is startPosition — your position size in this coin before the fill landed. Combined with side and sz, that lets you compute the position after the fill. Walk the fills in time order and you have your position over time for that asset.

A round-trip trade is a period where your signed position starts at zero, goes nonzero, and returns to zero. Three fills can do it (open, partial close, close); thirty can do it (scaling in, scaling out, flipping). The algorithm has to handle both.

The reconstruction algorithm

In plain Python:

@dataclass
class OpenPosition:
    asset: str
    entry_time: int
    entry_price: float
    signed_size: float       # positive for long, negative for short
    cumulative_pnl: float
    fee: float

def reconstruct_trades(fills: list[Fill]) -> list[RoundTripTrade]:
    active: dict[str, OpenPosition] = {}
    trades: list[RoundTripTrade] = []

    for fill in sorted(fills, key=lambda f: f.time):
        delta = fill.size if fill.side == "B" else -fill.size
        position = active.get(fill.asset)

        # Fresh position
        if position is None or position.signed_size == 0:
            if delta != 0:
                active[fill.asset] = open_position(fill, delta)
            continue

        # Same direction → scale in (weighted average entry)
        if same_sign(position.signed_size, delta):
            new_size = position.signed_size + delta
            new_entry = (
                position.entry_price * position.signed_size
                + fill.price * delta
            ) / new_size
            position.signed_size = new_size
            position.entry_price = new_entry
            position.fee += fill.fee
            continue

        # Opposite direction → reduce or flip
        if abs(delta) < abs(position.signed_size):
            # Partial close: realize PnL on the closed portion, keep the rest
            position.signed_size += delta
            position.cumulative_pnl += fill.closed_pnl
            position.fee += fill.fee
        elif abs(delta) == abs(position.signed_size):
            # Full close: emit a round-trip trade
            position.cumulative_pnl += fill.closed_pnl
            position.fee += fill.fee
            trades.append(close_trade(position, fill))
            del active[fill.asset]
        else:
            # Flip: close + open the remainder on the other side
            position.cumulative_pnl += fill.closed_pnl
            position.fee += fill.fee
            trades.append(close_trade(position, fill))
            remaining = delta + position.signed_size
            active[fill.asset] = open_position(fill, remaining)

    return trades

Three cases worth pausing on:

  1. Weighted average entry on scale-ins.If you bought 0.5 BTC at 68,000 and another 0.5 at 69,000, your effective entry is 68,500. Naively averaging the fill prices doesn't work once the sizes differ. Weight by signed size.
  2. Partial closes don't end the trade.If you're long 1 BTC and sell 0.3, you're still long 0.7. The fill's closedPnlis correctly accounting for the 0.3, but the round-trip isn't complete yet. Accumulate into the open position until size returns to zero.
  3. Flips need two records. Going from long 0.5 BTC to short 0.3 BTC in one fill means you closed a 0.5 long and opened a 0.3 short. Emit one round-trip for the long, start a new open position for the short. The naive approach (treat it as one fill) loses the closed trade entirely.

What this unlocks

Once you have round-trip trades, you can answer questions HL's UI never will:

trades = reconstruct_trades(fills)

# Average hold time per asset
by_asset = group_by(trades, key=lambda t: t.asset)
for asset, asset_trades in by_asset.items():
    median_hold_ms = statistics.median(t.exit_time - t.entry_time for t in asset_trades)
    print(f"{asset}: {median_hold_ms / 60_000:.1f} min median hold")

# Round-trip win rate after fees
profitable = sum(1 for t in trades if t.realized_pnl - t.fees > 0)
print(f"win rate: {profitable / len(trades) * 100:.1f}%")

# Hour-of-day signature
from collections import Counter
hours = Counter(
    datetime.fromtimestamp(t.exit_time / 1000, tz=UTC).hour
    for t in trades
)

Layer behavioral classification on top and you have something interesting. A trader whose fills cluster in the hour after a closing loss is probably revenge trading. A trader whose closed-PnL skew is highly negative but whose funding-earned dwarfs their fees is a funding farmer. These patterns are easy to detect once trades are reconstructed.

Skip the boilerplate

I wrote a library that does all of this. It's open source, MIT licensed, and on PyPI:

pip install hl-research
from hl_research.cache.store import Cache
from hl_research.analytics.wrapped import build_wrapped, reconstruct_round_trip_trades

cache = Cache()
fills = cache.read_fills("0xabc...").collect()
trades = reconstruct_round_trip_trades(fills)

# Or skip ahead to the full behavioral report
funding = cache.read_funding("BTC")
candles = {"BTC": cache.read_candles("BTC", "1h")}
report = build_wrapped(fills.lazy(), funding, candles, "0xabc...")

hl-research ships with:

  • A typed cache layer (Parquet on disk, DuckDB metadata) over the HL info API
  • The round-trip reconstruction code shown above
  • A rules-based behavior classifier (revenge trader, funding farmer, scalper, etc.)
  • Backtest engine + grid + random + walk-forward optimization
  • HTML report generation: paste-wallet-in, shareable-HTML-out
  • A Textual TUI for browsing it all interactively

Source: github.com/ramenxbt/hl-research
Docs: ramenxbt.github.io/hl-research

Where this is going

The library is the foundation. Two products in flight on top:

  1. A one-page wallet-wrapped tool: paste an address, get a downloadable HTML report. Spotify-Wrapped for your HL trading.
  2. A hosted backtest service: write a Python strategy in the browser, run it against cached HL data, share the results.

If you're doing serious quant work on Hyperliquid and the library is missing something obvious, open an issue. Pull requests welcome.