
Hyperliquid Trade Post-Mortem: Find Your Trading Mistakes in Python
Line up every closed Hyperliquid trade against 1s and 1m candles, then test stops, targets and no-adding rules on your own fills. Notebook and CLI in Python.
Hyperliquid puts every fill you have ever made on-chain, with the position you held before it, the realized PnL and the fee. Pair that with second-by-second prices and you can replay any trade you closed: where it went for you, where it went against you, what happened after you left, and what a stop or a target would have done.
In this guide we build a trade post-mortem in Python. It rebuilds a wallet's closed perp trades from indexed fills and lines each one up against 1s and 1m candles. It then asks six questions of every trade: should you have skipped it, stopped out, taken profit, held, closed and reopened, or not added? Finally it replays simple rules across all trades. We ran it on an active wallet picked at random from BTC fills. In a 30-day lookback, with trades from 18 October to 3 November 2026, that wallet lost $8,899 across 64 trades. The notebook pins $11,900 of losses on 32 trades that never moved more than 0.3% in its favour.
The code is in dwellir-examples/hyperliquid-trade-postmortem: a notebook, a command-line report and four small modules.
What You Will Learn
- Page a wallet's fills from
userFillsByTimeand rebuild round-trip trades fromstartPosition - Load 1s candles for the entry and exit minutes and 1m candles for the rest of each trade
- Measure maximum favorable and adverse excursion (MFE and MAE), capture and post-exit moves per trade
- Replay your own entries and exits under stop, target, trailing, time and no-adding rules
- Check whether a rule that wins on your earlier trades still helps on your later ones
Prerequisites
- Python 3.10 or newer. Run
python3 --versionto check. - A Dwellir API key. Create a free account and copy the key from the dashboard. The Hyperliquid Index needs no add-on.
- A Hyperliquid wallet address with closed perp trades since 27 July 2025, which is where the Index history starts.
- Comfort with pandas DataFrames.
Clone the example and install the four dependencies:
git clone https://github.com/dwellir-public/dwellir-examples
cd dwellir-examples/examples/hyperliquid-trade-postmortem
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export DWELLIR_API_KEY=your-api-keyBoth endpoints we use are billed per object returned: one API credit per fill and one per candle. Our 30-day run on a wallet with 1,118 fills used about 44,000 credits and took 54 seconds. Candles are cached on disk, so a second run of the same window costs only the fills.
Step 1: Fetch the wallet's fills
userFillsByTime returns up to 2,000 fills per request in time order. The /info route returns a bare array, so we build the next page's cursor from the last fill as time_txIndex. From hl_index.py:
def user_fills(user, start_ms, end_ms, coin=None):
"""All fills for `user` in [start_ms, end_ms), oldest first, as a DataFrame."""
body = {"type": "userFillsByTime", "user": user.lower(), "startTime": int(start_ms), "endTime": int(end_ms), "limit": FILL_PAGE}
if coin:
body["coin"] = coin
rows = []
while True:
page = _request(_url("/info"), body)
rows += page
if len(page) < FILL_PAGE:
break
body.pop("startTime", None)
body["cursor"] = f"{page[-1]['time']}_{page[-1]['txIndex']}"
df = pd.DataFrame(rows)
if df.empty:
return df
for col in ["px", "sz", "startPosition", "closedPnl", "fee"]:
df[col] = pd.to_numeric(df[col])
# tid is a random trade id. Fills in one block are ordered by txIndex.
return df.sort_values(["time", "txIndex"], kind="stable").reset_index(drop=True)_request() posts JSON to https://api-hyperliquid-index.n.dwellir.com/$DWELLIR_API_KEY/info and retries on 429, 500, 502, 503 and 504 responses and on network timeouts.
The sort on the last line matters. A market order that sweeps the book produces many fills with the same timestamp. Our first version sorted them by tid, which is a random trade id, and the running position fell out of step on 608 of 702 BTC fills. Sorting by txIndex, the fill's position inside the block, brought the mismatches to zero.
Try it in a Python shell:
import time, hl_index
end = int(time.time() * 1000)
fills = hl_index.user_fills("0xYourWallet", end - 30 * 86_400_000, end)
print(len(fills))
fills[["coin", "side", "px", "sz", "startPosition", "dir", "closedPnl", "fee", "txIndex"]].head(4)You should see output like this:
1118
coin side px sz startPosition dir closedPnl fee txIndex
0 ETH B 2402.5 0.0100 0.0000 Open Long 0.0 0.010811 18
1 ETH B 2402.5 0.0293 0.0100 Open Long 0.0 0.031676 20
2 ETH B 2402.5 1.0047 0.0393 Open Long 0.0 1.086206 22
3 ETH B 2402.5 0.9247 1.0440 Open Long 0.0 0.999716 24One ETH market buy produced those four fills in the same block, and each fill's startPosition is the previous fill's position plus its size.
Step 2: Rebuild round-trip trades
A trade starts when a position leaves zero and ends when it returns to zero. Fills that grow the position are entries and fills that shrink it are exits. A fill that crosses zero, which Hyperliquid labels Long > Short or Short > Long, closes one trade and opens the next at the same price. The core loop in trades.py, abridged (the full version also flags liquidation fills when the API marks them):
for f in rows.itertuples(index=False):
start = f.startPosition
if pos is None or abs(pos - start) > EPS * max(1.0, abs(start)):
# First fill for this coin, or our running position drifted.
# Resync on the exchange's number and drop the trade we cannot account for.
pos, trade = start, None
qty = f.sz if f.side == "B" else -f.sz
if abs(pos) < EPS: # flat: a new trade opens
trade = Trade(coin, 1 if qty > 0 else -1)
trade.entries.append((f.time, f.px, abs(qty), f.fee))
elif (pos > 0) == (qty > 0): # same direction: an entry
if trade:
trade.entries.append((f.time, f.px, abs(qty), f.fee))
else: # opposite direction: an exit
closing = min(abs(qty), abs(pos))
share = closing / abs(qty)
if trade:
trade.exits.append((f.time, f.px, closing, f.fee * share, f.closedPnl))
if abs(qty) > abs(pos) + EPS: # flip: the rest opens a new trade
if trade:
trades.append(trade)
trade = Trade(coin, 1 if qty > 0 else -1)
trade.entries.append((f.time, f.px, abs(qty) - closing, f.fee * (1 - share)))
elif abs(pos + qty) < EPS * max(1.0, abs(pos)): # back to flat: the trade is done
if trade:
trades.append(trade)
trade = None
pos += qtyBecause startPosition is the exchange's own number, any trade that was already open when the window started gets dropped, as does any trade we cannot follow end to end. Spot (@123) and HIP-4 outcome (#123) markets are skipped. HIP-3 markets such as xyz:SP500 are perps and stay in.
Each Trade exposes avg_entry, avg_exit, gross_pnl (the sum of closedPnl), fees, net_pnl and adds, which are entries placed more than a minute after the first. As a check, we recomputed every trade's PnL from its average entry and exit prices. It matched Hyperliquid's closedPnl to within $0.46 per trade.
import trades as tr
T = tr.build_trades(fills)
tr.trades_frame(T)[["coin", "side", "open", "hours", "notional", "entries", "adds", "exits", "net_pnl"]].head(3) coin side open hours notional entries adds exits net_pnl
0 ETH long 2026-10-18 00:45:03+00:00 13.51 123333.28 11 6 15 -1323.44
1 BTC short 2026-10-18 14:16:05+00:00 0.15 160856.52 16 0 11 -543.18
2 BTC long 2026-10-18 16:21:03+00:00 0.58 79571.91 2 0 9 -119.52Step 3: Load the price path around each trade
The candle range endpoint returns up to 5,000 candles per page for one market. It requires start and end aligned to the interval and rejects ranges that include the bucket still in progress, so candles() rounds the window outward, clips it to the last finished bucket and caches the result:
def candles(market, interval, start_ms, end_ms):
step = INTERVAL_MS[interval]
start = start_ms // step * step
last_done = (int(time.time() * 1000) // step - 1) * step
end = min(-(-end_ms // step) * step, last_done)
if end <= start:
return pd.DataFrame(columns=CANDLE_COLS)
path = CACHE / f"{market.replace(':', '_').replace('@', 'at')}-{interval}-{start}-{end}.csv.gz"
if path.exists():
return pd.read_csv(path)
df = _fetch_candles(market, interval, start, end)
CACHE.mkdir(parents=True, exist_ok=True)
df.to_csv(path, index=False)
return dfCandles are sparse: a second or minute with no trades has no candle, not a zero candle.
A trade that opened at 00:45:03 should not see the low printed at 00:45:01. So Prices.path() in analysis.py uses 1s candles for the entry minute and the exit minute, and 1m candles everywhere else, out to 24 hours after the exit:
def path(self, trade, until=None):
until = until or trade.close_time + self.after
m = self.minute[trade.coin]
entry_min = trade.open_time // MIN * MIN
exit_min = trade.close_time // MIN * MIN
open_sec = trade.open_time // 1000 * 1000
edge = [self.seconds(trade.coin, entry_min)]
if exit_min != entry_min:
edge.append(self.seconds(trade.coin, exit_min))
sec = pd.concat(edge)
sec = sec[(sec.index >= open_sec) & (sec.index < until)]
mins = m[(m.index > entry_min) & (m.index != exit_min) & (m.index < until)]
bars = pd.concat([sec.assign(t_end=sec.index + 999), mins.assign(t_end=mins.index + MIN - 1)])
return bars[["o", "h", "l", "c", "t_end"]].sort_index()Prices.__init__ merges overlapping trade windows per coin before fetching 1m candles, so 20 short BTC trades in one afternoon share one candle range instead of fetching 20 overlapping ones. For our 64 trades that came to 37,931 1m candles and 4,834 1s candles.
Check the path for the first trade:
import analysis as an
P = an.Prices(T)
len(P.path(T[0]))2290That is 13.5 hours of 1m bars plus 24 hours after the exit, with 1s bars in the entry and exit minutes. Minutes with no trades have no bar.
Step 4: Measure what each trade lived through
From the path, excursions() measures four things per trade, signed so that positive is good for the trade's direction:
- MFE, the best move in your favour while the trade was open, in % of the average entry
- MAE, the worst move against you while it was open, in % of the average entry
- capture, the realized move as a fraction of the MFE
- post-exit moves, where price was 1, 4 and 24 hours after the exit, in % of the average exit
The core of excursions(), abridged:
held = bars[bars.index <= trade.close_time]
after = bars[bars.index > trade.close_time]
s, entry, exit_ = trade.side, trade.avg_entry, trade.avg_exit
best = held["h"].max() if s > 0 else held["l"].min()
worst = held["l"].min() if s > 0 else held["h"].max()
mfe = s * (best - entry) / entry
mae = s * (worst - entry) / entry
realized = s * (exit_ - entry) / entryFor the 13.5-hour ETH long from step 2:
an.excursions(T[0], P){'mfe_pct': 0.635, 'mae_pct': -1.296, 'realized_pct': -0.984, 'capture': -1.55,
'mins_to_mfe': 182.9, 'after_1h_close_pct': -0.499, 'after_4h_close_pct': -1.156, ...}Its best price, three hours in, was 0.64% above the final average entry. Its worst was 1.30% below, and it closed 0.98% down. Four hours after the exit, ETH was another 1.16% lower, so holding would have lost more.
Plotting MFE against MAE for every trade shows the wallet's main problem straight away:
Winners had a median MFE of 0.78%. Losers had a median MFE of 0.13%, so most losing trades never went the wallet's way at all.
Step 5: Replay the trade under a rule
simulate() walks a trade bar by bar. Your real entries and exits happen as they did, and the rule only steps in when it fires. Real exits close the same fraction of the simulated position as they did of the real one, so a rule that never fires returns your real PnL. When a rule fires, it closes the rest of the position at the rule's price. A rule is a small dataclass:
@dataclass
class Rule:
name: str
stop: float = None # fraction against the current average entry, e.g. 0.02
take: float = None # fraction in favour of the current average entry
trail: float = None # fraction back from the best price since entry
max_hours: float = None # close everything this long after the first entry
no_adds: bool = False # ignore entries placed more than a minute after the first
reenter: float = None # after a stop, buy back once price bounces this far off the lowAbridged from simulate(), the part that decides whether a stop or target fires inside a bar:
stops = []
if rule.stop:
stops.append(avg * (1 - s * rule.stop))
if rule.trail:
stops.append(best * (1 - s * rule.trail))
stops = [px for px in stops if (s > 0 and lo <= px) or (s < 0 and hi >= px)]
stop_px = (max(stops) if s > 0 else min(stops)) if stops else None
tp = avg * (1 + s * rule.take) if rule.take else None
hit_tp = tp is not None and ((s > 0 and hi >= tp) or (s < 0 and lo <= tp))
if stop_px is not None and hit_tp:
if _stop_first(prices, trade.coin, t, bar, stop_px, tp, s):
hit_tp = False
else:
stop_px = None
if stop_px is not None:
# a bar that opens through the stop fills at the open
px = (min(stop_px, bar["o"]) if s > 0 else max(stop_px, bar["o"])) * (1 - s * slippage)
pnl += s * (px - avg) * pos - fee_rate * px * posFour details keep the replay honest:
- Stops and targets follow the running average entry, which moves every time you add.
- Synthetic fills pay the wallet's own average fee rate (3.92 bps here), and stops and time exits pay another 5 bps of slippage.
- If a stop and a target both fall inside one 1m bar,
_stop_first()loads that minute's 1s candles to see which traded first. - We checked three stopped-out trades by hand against the raw candles. The simulated PnL matched to the cent.
rate = an.taker_rate(T)
for rule in [an.Rule("stop 0.46%", stop=0.0046), an.Rule("take 0.78%", take=0.0078), an.Rule("no adds", no_adds=True)]:
print(rule.name, round(an.simulate(T[0], P, rule, rate), 2))stop 0.46% -652.65
take 0.78% 496.85
no adds -292.35The ETH long lost $1,323. A 0.46% stop would have halved the loss, and a 0.78% target would have turned it into a $497 win. The chart shows why:
Price touched the target just before 03:50, and that is when the wallet made its largest add, at the top. The average entry jumped, the target moved out of reach, and the position rode ETH down for the next ten hours.
Step 6: Ask the six questions of every trade
diagnose() prices each alternative in dollars against what really happened:
def diagnose(trade, prices, fee_rate, stop=0.02, take=0.03, hold_hours=4, bounce=0.01):
actual = trade.net_pnl
out = {"skip": -actual}
out["stop"] = simulate(trade, prices, Rule("s", stop=stop), fee_rate) - actual
out["take"] = simulate(trade, prices, Rule("t", take=take), fee_rate) - actual
out["reenter"] = simulate(trade, prices, Rule("r", stop=stop, reenter=bounce), fee_rate) - actual
out["no_adds"] = (simulate(trade, prices, Rule("n", no_adds=True), fee_rate) - actual) if trade.adds else 0.0
# hold: exit `hold_hours` after the real exit, at that bar's close
...
return outFixed levels such as a 2% stop rarely fire for a wallet whose trades move a fraction of a percent, so levels() sizes the stop and target from the wallet itself. The stop goes where 80% of winning trades never went further against you. The target is the median best move on winners. Both are clamped to 0.2% to 5%.
For this wallet, 80% of winners never went more than 0.46% against the entry, so the stop is 0.46%. The target came out at 0.78%.
label() then gives each trade the one lesson that would have saved the most. analyse() runs excursions(), levels(), diagnose() and label() over every trade, and lessons() sums the result:
df = an.analyse(T, P)
print(round(df.attrs["stop"], 4), round(df.attrs["take"], 4))
an.lessons(df)[["trades", "net_pnl", "saving"]].round(0)0.0046 0.0078
trades net_pnl saving
lesson
skip 32 -11900.0 11900.0
take 5 -1254.0 3934.0
stop 4 -3653.0 2232.0
fine 23 7908.0 NaNThe lessons:
| Lesson | When it applies |
|---|---|
| Better not to open it | The trade lost, and its MFE was smaller than its fees plus 0.2% |
| Should have stopped out | A stop at the wallet's stop size saves the most |
| Should have taken profit | A target at the wallet's target size saves the most |
| Should have kept holding | Price 4 hours after the exit was more than 0.5% further your way, and further than the trade itself made |
| Better to close and reopen | Stopping out, then buying back after a half-stop bounce off the low, saves the most |
| Better not to add | The same exits without the later entries save the most |
| Fine | No alternative is worth more than 0.1% of the trade's notional |
The "kept holding" test is strict on purpose. For a single trade, where price is four hours after the exit is mostly noise, so the label needs a large move. exit_timing() tests early exits across all trades instead (step 8).
32 of the 64 trades lost money without ever moving 0.3% the wallet's way. They lost $11,900 between them, $2,542 of it in fees. A 0.46% stop would have cut those losses by $2,150 in total, but it fires on only 7 of the 32 trades. The rest lose a little at a time, inside any reasonable stop. The fix for these trades is at the entry. The 23 trades labelled "fine" made $7,908.
Here is one of them, an ETH long that peaked 0.02% above its entry, went as far as 1.79% against it, and closed 1.57% down:
The opposite case, a ZEC long held 3.9 hours while it went almost 5% against the entry, is the clearest stop-loss lesson in the set. A 0.46% stop would have saved $966 of its $1,152 loss:
Step 7: Test rules on earlier and later trades
A rule that looks best on the trades you just reviewed is fitted to them. test_rules() replays every rule on every trade and scores the first and second half of the trades separately:
def test_rules(trades, prices, rules, split=0.5):
rate = taker_rate(trades)
cut = trades[int(len(trades) * split)].open_time if trades else 0
early = np.array([t.open_time < cut for t in trades])
rows = []
for rule in [ACTUAL] + list(rules):
pnl = np.array([simulate(t, prices, rule, rate) for t in trades])
rows.append({
"rule": rule.name,
"net_pnl": pnl.sum(),
"first_half": pnl[early].sum(),
"second_half": pnl[~early].sum(),
"win_rate": (pnl > 0).mean(),
"worst_trade": pnl.min(),
})
out = pd.DataFrame(rows).set_index("rule")
out["vs_actual"] = out["net_pnl"] - out.loc["actual", "net_pnl"]
return outrules_for(stop, take) builds the default set around the wallet's own levels: half, one and two stop sizes (0.23%, 0.46% and 0.92% here), one and two target sizes, stop-and-target pairs, a trailing stop, 1-hour and 4-hour time stops, no adds, no adds with a stop, and stop-then-re-enter.
Almost every rule helps on the earlier trades and hurts on the later ones. The 0.46% stop adds $2,192 on the earlier half and loses $1,615 on the later half. The one rule that helps on both is the wide 0.92% stop: +$592 early, +$1,232 late, +$1,825 overall. It rarely fires, and when it does it cuts the few large losers such as the ZEC trade. No adds stays close to flat on the later half (-$295). The stop-and-re-enter rule churns in and out of chop, paying fees each time, and loses $2,962.
pick_and_check() does the honest version for you: choose on the first half, read the second:
Best rule on the earlier half: max 1h ($3,444). On the later half it did not help (-$2,493).Picking on the earlier trades chose a 1-hour time stop, and it gave back $2,493 on the later ones. The wide stop that held up on both halves is worth trying, but it recovers $1,825 of an $8,899 loss. The bigger lesson for this wallet is the one from step 6: 32 trades were wrong at the entry, and no exit rule recovers most of that.
Step 8: Putting it all together
postmortem.py runs every step and prints the report:
python postmortem.py 0xYourWallet --days 30The output for our sample wallet, trimmed to the summary, the lessons and the exit timing:
64 closed trades from 1118 fills, 2026-10-18 to 2026-11-03
Net -$8,899 = gross -$4,037 - fees $4,862 (funding not included)
Won 19 of 64 (30%); average win $527, average loss -$420
Best move in your favour, median: winners 0.78%, losers 0.13%
Stop and target sized from your trades: stop 0.46%, take 0.78%
Where the money went (one lesson per trade, saving = what that fix would have added):
trades net_pnl saving meaning
lesson
skip 32 -$11,900 $11,900 Better not to open it
take 5 -$1,254 $3,934 Should have taken profit
stop 4 -$3,653 $2,232 Should have stopped out
fine 23 $7,908 No fix worth 0.1% of notional
Do you exit early? Price move after your exit, in your direction:
trades mean_pct median_pct share_in_your_favour t_stat
after
1h 64 -0.104 -0.006 0.469 -1.519
4h 64 -0.451 -0.112 0.438 -2.637
24h 64 -0.145 -0.038 0.484 -0.474The full report goes on to print the 14-row rules table from step 7 and the pick-and-check line.
The exit-timing table answers "should I have held longer?" across all trades at once. Four hours after an exit, price had moved 0.45% against this wallet's direction on average, with a t-stat of -2.6. This wallet does not exit too early. Its trades tend to point the wrong way.
Fees are the other line to read: $4,862, more than the gross loss of $4,037. At 3.9 bps a side, a round trip costs 0.078% of notional. That is a tenth of this wallet's median winning move (0.78%), and more than half of its median losing trade's best move (0.13%).
For charts, open the notebook. It calls the same run() and adds the scatter, the lessons bar, close-ups of the three worst trades and the rules chart:
WALLET=0xYourWallet jupyter notebook postmortem.ipynbPass --start and --end for a fixed window, --stop and --take to use your own levels, and --csv trades.csv to keep the per-trade table.
Going to production
Funding. closedPnl excludes funding, and so does every counterfactual, so comparisons are like for like. On positions held for days, add funding before you draw conclusions. The funding rates guide shows how to price it.
Bar resolution. Between the entry and exit minutes the replay uses 1m bars, so it assumes your real fills inside a minute happened before that minute's high or low. For scalps measured in seconds, switch the middle of the path to 1s candles. That costs up to 60 times the candle credits, so cache aggressively.
Liquidations. The fills we pulled from userFillsByTime carried no liquidation field, so a liquidation is scored as an ordinary exit. build_trades() flags a trade as liquidated when the field is present. To be sure, join liquidationFillsByTime on tid.
Sample size. Splitting 64 trades in half leaves 32 trades per side. Treat any rule's gain on the later half as a hint until it holds over a few months of trades. Use --start to review more history.
API key handling. The key travels in the URL path, so keep it out of notebooks you share. Read it from DWELLIR_API_KEY as the example does, and clear notebook outputs before committing.
Next steps
- Feed the per-trade CSV into your journal and track how the share of "better not to open it" trades changes month by month.
- Test entry filters, not just exits. The trades labelled "better not to open it" are the ones to study: the time of day, the coin, the funding rate or the order book state when you opened them.
- Replay the market rather than your own fills. The Hyperliquid historical data for backtesting post covers which datasets suit a full strategy backtest, and machine learning on Hyperliquid data shows how to score forecasts without lookahead.
Everything in this guide ran on Dwellir's Hyperliquid Index, with fills and 1s/1m candles back to 27 July 2025 on one API key. To run it on your own wallet, create a free account.
Frequently Asked Questions
How do I analyze my closed Hyperliquid trades?
Fetch your fills with userFillsByTime from Dwellir's Hyperliquid Index, group them into round trips using each fill's startPosition, then load 1s and 1m candles from the candle range endpoint for each trade's lifetime. From that price path you can measure the best and worst move while the trade was open (MFE and MAE), what price did after you exited, and how a stop, target or no-adding rule would have changed the result.
What API gives Hyperliquid fills and minute-by-minute prices?
Dwellir's Hyperliquid Index serves both on one host with one API key. userFillsByTime returns a wallet's fills (2,000 per page, back to 27 July 2025), and /v1/candles/range returns 1s, 1m or 5m OHLCV candles for any market, including HIP-3 markets. Both are billed per object returned.
How do I find the right stop loss for my trading?
Measure how far your winning trades went against you before they worked. If 80% of your winners never moved more than 0.46% against you, a stop near 0.46% cuts losers while touching only one winner in five. Then replay that stop on your earlier trades and check it on your later ones, because a level picked on the same trades it is scored on looks better than it is.
Does the analysis include funding payments?
No. Hyperliquid's closedPnl excludes funding, and so does every counterfactual in the notebook, so the comparisons are like for like. A trade held through many funding hours had a different real result.
How to Find the Wallet Behind a Hyperliquid Order
Turn a Hyperliquid transaction hash or order ID into the wallet that placed it. A tested Python script using txDetails, orderStatus, and indexed fills.
Polymarket API: Endpoints, API Keys and Trading Guide
The Polymarket API is three HTTP services: Gamma lists markets, CLOB serves order books and takes orders, Data returns positions. Reading needs no API key.