Backtest Modeling
How the Strategy Studio backtest fills orders — fill timing, slippage, fees, funding, lot/tick/min-notional rules, and what is and isn't simulated.
The backtest is a deterministic bar-replay over historical Hyperliquid candles. Understanding exactly how it fills orders is the difference between a credible curve and a fantasy. Everything here is the actual BacktestAdapter behavior.
Fill timing (no same-bar lookahead)
A strategy decides on bar N's close. The bar that produced the signal is already history, so the backtest never fills inside it — that would be lookahead.
- Market orders fill at the next bar's open (bar N+1), with slippage applied.
- Limit orders rest and are eligible to fill from bar N+1 onward. A resting buy limit fills when a later bar's low crosses down to the price (
low ≤ price); a sell limit when a later bar's high crosses up (high ≥ price). The fill price is the limit price. - Trigger orders (stop / take-profit / trailing) also rest and arm from bar N+1. A stop/TP fires when a later bar's range crosses
triggerPx; a trailing stop ratchets its trigger toward the market each bar before testing the cross. - Managed limits (a resting order the engine keeps re-pricing) that are cancelled or replaced at a bar close still get that bar's fill check: on the exchange the order rested for the whole bar, so if the bar traded through it, it filled before the cancel.
- Gap fills are honoured. When a bar *gaps through* a stop's trigger (the open is already past
triggerPx), the market never traded at the trigger price — the fill bases on the worse of trigger and open, not on a fantasy fill at the trigger. A stop through a weekend gap fills at the gapped open.
post-only rejection
A postOnly limit rejects if the next bar's open is already through the price (it would have crossed as a taker). Watch the Console for postonly_reject.
Slippage
Market fills (and triggered market fills) move against you by:
slipBps = slippage.baseBps + slippage.impactBps × 0.5
fillPx = referencePx × (1 ± slipBps / 10_000)
Buys fill higher, sells lower. The defaults are baseBps = 1, impactBps = 5 (so ~3.5 bps effective), set in the graph's backtest settings (in the exported .borsa.json; the Inspector does not show them). Resting limit/stop-limit fills take their limit price with no extra slippage.
Fees
Per fill, in basis points of notional (configurable; defaults are the Hyperliquid exchange schedule):
- Taker
takerBps = 4.5— market fills, triggered market fills, and limits that cross. - Maker
makerBps = -0.5— apostOnlylimit that rests and fills as maker earns the rebate (a negative fee).
> Fee-parity note: these are exchange fees only. A live order also pays a builder fee (per-wallet), which the backtest does not model. To estimate your true all-in cost, bump takerBps/makerBps by your configured builder fee.
Funding accrual
Hyperliquid funds hourly. The engine charges/credits funding on every open position at each hourly boundary it crosses:
payment = signedNotional × hourlyRate × intervalsCrossed
A long pays when the rate is positive; a short receives. The payment hits cash and accumulates into data.position.cumFundingSinceOpen.
Important: in backtest there is no historical funding series, so ctx.fundings is empty and the rate is effectively 0, so funding accrues to zero in a pure backtest. The accounting path is identical to paper/live, so the moment a funding rate is available (paper/live) it debits/credits correctly. data.fundingRate returns NaN in backtest. If a funding-sensitive strategy (e.g. the Funding Fade template, marked Live only) must be evaluated, run it in paper mode where the live rate flows. The same holds for the trade-tape meters (data.takerPressure, data.liqVolume), which are NaN in backtest. data.prices reports mark and oracle equal to the candle close.
Lot, tick, and minimum notional
When market metadata is available, exec nodes validate every intent before it's emitted (and adapters re-check before submission):
- Size is floored to the market lot (
10^-szDecimals). - Limit price is rounded to the market tick.
- Notional (
size × price) must clear Hyperliquid's $10 minimum, else the order is rejected withorder:rejectedin the Console.
Studio backtests (and the publish dialog's) load market metadata with the candles, so all three are enforced in backtest, paper and live. A common gotcha: a sizeFallback like 0.0001 BTC is well under $10 notional and silently rejected.
Reduce-only and bracket OCO
Two behaviours that used to be live-only are now enforced in the simulator, so backtest/paper can't flatter a graph that live would punish:
- Reduce-only is enforced at the fill chokepoint (backtest *and* paper). An oversized reduce-only fill is clamped to the current position size; a reduce-only fill with no position, or on the same side as the position, is rejected (
reduce_only_no_position) — no phantom position flips. Reduce-only orders also skip the $10 min-notional so dust positions stay closable. exec.bracketstop + target are OCO in backtest/paper: whichever leg fires first cancels the sibling on the same bar (oco_cancelledin the order log), so the survivor can't re-fire later as a phantom exit. If both would cross within one bar, the stop wins (conservative). Live is different: the legs go to the exchange as independent trigger orders (no native OCO grouping yet) — the surviving leg is *not* auto-cancelled.
Order-node features in backtest
exec.market, exec.twap and exec.limit have options that react to fills and to the account. The backtest models them with its own fills and a simulated account:
- Fill outputs (
filled,filledUsd,filledSize,fillSide,filledTotalUsd,filledTotalSizeonexec.marketandexec.twap) report simulated fills. A market order sent at bar N's close fills at bar N+1's open, and its fill shows on the node's outputs at the next evaluated tick. Live, they report the exchange's fills when they arrive. - Retry until filled (
exec.market). Backtest market orders fill in full, sounfilledonly comes from orders that fail: an order under the $10 minimum or the lot, or no next bar to fill on (end of data). Retries are spaced by Retry every, but a backtest only ticks at bar closes, so at most one retry goes out per bar. Live, partial IOC fills and exchange rejections are retried as well. - Use available balance (
exec.market,exec.limit). Live, each order is sized to what Hyperliquid says the account can place right now. Backtest and paper estimate it as free margin (equity minus the margin open positions hold) × the position's leverage, else 1×. Under $10, the node waits instead of sending, and fires once simulated profit or a close frees margin. - Total cap / Total cap ($) (
exec.market). Counted from simulated fills plus orders in flight; each order is clipped to what is left, andcapReachedturns true once nothing is left. - TWAP (
exec.twap). The schedule runs onctx.now, which in backtest only advances bar by bar: all slices that fall due within a bar go out together at its close and fill at the next open. A TWAP shorter than one bar therefore executes in one or two bars. Whilepauseis true, due slices wait and the schedule resumes without a catch-up burst. Slices are sized from what really filled: what one slice didn't fill is added to the next, and up to 3 top-up slices finish a shortfall after the schedule.progressanddonefollow the simulated fills.
Position accounting
Fills update a single signed position per coin. Adding to a position averages the entry price; reducing realizes PnL (fillPx − entryPx) × sign × closedSize into cash; crossing through zero flips the side and resets the entry to the fill price for the remainder. Equity is cash plus mark-to-market on the open position.
What is and isn't simulated
Simulated: next-bar/next-open fill timing, order-node fill outputs, retries, available-balance sizing and total caps (from simulated fills and margin), gap-aware stop fills (worse of trigger/open), size-proportional slippage, taker/maker fees, lot/tick/min-notional rounding, trigger/stop/trailing crossings against bar ranges, reduce-only enforcement (clamp/reject), bracket stop/target OCO, hourly funding accounting (rate = 0 in backtest), seeded determinism.
Not simulated: intra-bar price path (only OHLC of each bar is known: a stop and a target hit within the same bar resolve by the adapter's crossing rules, not by tick sequence), order-book depth / partial fills (market orders and crossed limits fill in full), the live builder fee, real funding/open-interest/liquidation history, latency/queue position, and liquidations of your own position.
Honest live gaps (the other direction): two things the *simulator* does that live currently cannot: a live trailing stop is submitted as a static trigger at the initial offset (Hyperliquid has no native trailing order — the ratchet only exists in backtest/paper), and a live bracket has no OCO (see above). Everything else — cross timing on closed bars, sampling, reduce-only, warmup behaviour — now matches across backtest, paper, and live: decisions commit on the same closed-bar ticks in all three modes.
Determinism and reproducibility
A graph + seed + data range = one event log. Re-running the same backtest yields byte-identical equity, orders, and plots. Use this to bisect a change: tweak one param, re-run, diff the order log.
Engine version gate
Each graph records the engineVersion it was built against. On load the runner compares it to the running build:
- Major mismatch — node semantics may have changed; results may be incorrect (surfaced as an error/warning before running).
- Minor/patch mismatch — usually safe, surfaced as a note.
Bump a published strategy's version after edits; engine version is checked independently so an old graph on a newer engine is flagged rather than silently mis-run.