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 tradesThree cases worth pausing on:
- 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.
- 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. - 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-researchfrom 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:
- A one-page wallet-wrapped tool: paste an address, get a downloadable HTML report. Spotify-Wrapped for your HL trading.
- 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.