edgekit

Backtesting fundamentals

A backtest is a simulation of a decision rule against the historical tape. Get the mechanics wrong — fill at a price you could not have got, count a bar you had not yet seen — and every number downstream is fiction. This chapter builds the mental model of the bar loop, how edgekit fills a trade, what the trade frame stores, and why cost is charged in R.

Strip away the jargon and a backtest is one honest question asked over and over: standing at this bar, with only what I could have known by now, what would I have done — and what would it have cost me?Answer it wrong by a single bar and you are no longer testing a strategy, you are reading tomorrow's newspaper today. Everything in this chapter exists to make that question impossible to cheat on.

Event-driven vs vectorized#

There are two ways to compute what a strategy would have done. A vectorized backtest expresses the whole rule as array algebra: build a boolean signal for every bar, shift it forward one bar, multiply by the next return, and sum. It is fast and fine for a first sniff — but it quietly assumes you can act on a bar at the same instant it closes, and it struggles to express path-dependent logic (a stop that may fire intrabar, a one-position-at-a-time constraint, an end-of-session flatten).

An event-drivenbacktest walks bars in order and asks, at each step, "given only what I knew at the close of bar , what do I do on bar ?" It is slower but honest: it makes the arrow of time explicit, so look-ahead has to be introduced deliberately rather than by accident. edgekit's research engine, run_bar_loop, is event-driven for exactly this reason.

VectorizedEvent-driven (run_bar_loop)
SpeedFast (array ops)Slower (Python bar loop)
Path dependenceAwkward — intrabar stops, one-position rulesNatural — state carried across bars
Look-ahead riskHigh — a stray unshifted array peeksLow — time's arrow is enforced by construction
Use forA first-pass sanity checkThe number you actually trust and validate

The bar loop#

The core contract is the same one the whole library rests on: a decision made on bar may read information only through bar , and the resulting order fills at the open of bar (or gap-aware at bar ). A Strategy exposes three methods: prepare precomputes lagged indicator arrays once (the P dict), entry returns an EntryIntent or None on a flat bar, and exit returns an exit price or None while in a position.

bar_loop.py
import edgekit as ek
from edgekit.strategy import ORB

# rth = a session-sliced OHLC frame; ORB reads the opening range, breaks it, stops at the far edge
trades = ek.engine.run_bar_loop(rth, ORB(or_bars=30, target_r=2.0),
                                warmup=5, bars_per_day=390)
print(trades["r"].sum(), len(trades))   # total R (typically net-negative), trade count

The loop keeps one position at a time. On each bar it either (a) polls exit if it holds a position, or (b) polls entryif it is flat, then fills whatever intent comes back on the next bar's open. The state — are we long, at what level, with what stop — lives between iterations. That is the whole point of event-driven: the strategy cannot answer bar using bar , because bar does not exist yet in the loop.

At the desk — one bar of a US100 opening-range breakout
Picture the loop stepping onto the 10:00 bar of US100. It is flat, so it polls entry: the 30-minute opening range formed 9:30–10:00 was 21,480–21,520, and the just-closed 10:00 bar poked above 21,520, so the strategy returns an intent to go long with the stop at the low edge (stop_dist = 40 points). The loop does not fill here — it fills at the open of the 10:05 bar, which prints 21,528. You are long from 21,528, risking 40 points. The next iteration the loop holds a position, so it stops asking about entries and starts polling exit every bar until the stop or the 2R target (21,608) resolves. That one step — decide on the 10:00 close, fill on the 10:05 open — is the whole causal contract in miniature.
!Lag inside prepare, not entry/exit
Because entry and exit execute on bar , every indicator they read must already have been lagged (via ek.lag(x, 1)) inside prepare. If the P dict holds an unlagged array, bar reads its own just-closed value — the classic one-bar look-ahead that inflates a backtest and evaporates live.

How a fill is priced#

Next-open, gap-aware fills#

The decision is committed at the close of bar ; the fill happens at the open of bar . But the open is not always kind. If price gaps throughyour entry trigger overnight, you do not get the stale trigger price — you get the open. edgekit's fill_entry encodes this: a long whose level sits below the open fills at the open, never the level.

Stops resolve the same way, but against you. fill_stop takes the worse of the stop price and the open — a gap through a long stop fills at the lower open, deepening the loss. And when a single bar touches both the stop and the target, the engine counts it as the stop (pessimistic): you cannot know intrabar which came first, so assume the bad one.

Pessimism is a feature
Every ambiguity is resolved against the strategy. This is deliberate. An optimistic fill — the target when a bar touched both, the trigger when price gapped past it — is exactly how a backtest manufactures edge that is not there. If a strategy survives pessimistic fills, its edge is more likely to be real.

The R denominator#

edgekit prices every trade in R-multiples, not dollars. R is the trade's P&L divided by the risk you put on — the stop distance at entry. An EntryIntent carries a stop_dist in price units; that is the denominator that turns a price move into R:

A trade that hits its stop is R by construction; a 2R target is a R win. Pricing in R makes strategies comparable across instruments and volatilities before any capital decision, and it is the unit the entire gauntlet operates in.

Scenario. Carry on the US100 long above. You filled at entry = 21,528 with stop_dist = 40. Three bars later price runs into your 2R target and the engine fills the exit at 21,606. The raw move is points; in R that is R (a hair under the clean +2R because the target gap-filled two points early). Now suppose instead the trade had reversed and the stop filled at 21,488: R exactly. The beauty of the R denominator is that a 40-point US100 stop and, say, a $1,200 BTCUSDT stop both land on the same −1 → +2 axis, so you can pool them, rank them, and stress them without ever converting to dollars.

The trade frame#

run_bar_loop returns the canonical trade DataFrame — one row per closed trade. These are the columns every downstream tool ( metrics, validation, sizing) expects:

ColumnMeaning
rNet R-multiple of the trade — already net of cost. The workhorse column.
dateExit date/time — enables annualised metrics and per-year regime splits.
dir+1 long / −1 short.
entryFill price at entry.
exitFill price at exit.
stop_distThe R denominator — stop distance in price units at entry.
bars_heldHolding period in bars (feeds hold stats and swap cost).
tagThe strategy name carried onto each trade.
exit_reasonWhy the trade closed (stop, target, channel, session flatten…).

The frame is the interface. Once you have it, you never touch the loop again — you hand trades.r and trades.date to ek.trade_stats and to every gauntlet function:

trade_frame.py
import edgekit as ek

st = ek.trade_stats(trades.r, dates=trades.date)
print(st["n"], st["ev_r"], st["pf"])       # count, expectancy in R, profit factor
print(st["ann_r"], st["mar"])              # annualised R, MAR (needs dates)

Charging cost in R#

edgekit never bakes dollars into a strategy. Cost is a haircut on each trade's R, applied once per round trip inside the loop, via a CostModel. The crypto-convention model expresses cost as a fraction of price — a round-trip spread plus a per-day financing charge — and converts it to R with the same stop-distance denominator:

cost_in_r.py
import edgekit as ek

cost = ek.costs.CostModel(spread_rt=0.0012, swap_day=0.0002)   # 12 bps round-trip + 2 bps/day
trades = ek.engine.run_bar_loop(bars, strategy, cost=cost)     # trades.r is ALREADY net of cost
stats  = ek.trade_stats(trades.r, dates=trades.date)

# one round trip's haircut, in R:
r = cost.r_cost(entry_price=30_000.0, risk_per_unit=1_200.0, days=8)   # -> 0.07 R

Scenario. Read that r_cost call as a real BTCUSDT swing trade. You enter at $30,000 risking $1,200 per unit (your stop distance), and you hold 8 days. The spread costs dollars round-trip and financing adds dollars, so dollars of friction against a -dollar risk unit — a haircut of R on every trade. That sounds tiny until you notice a trend system whose average winner is +0.9R: 0.07R off the top is roughly 8% of the edge gone before you count a single loser. This is exactly why cost lives inside the loop — so a strategy that looks like PF 1.15 gross and is really PF 0.98 net gets caught here, not in production.

Because every stored ris net, a strategy's profit factor is a net-of-cost number from the start. That matters: a gross edge that dies once you pay the spread is not an edge, and the honest place to find that out is inside the loop, not in a footnote. Sizing to dollars happens later, from a single scalar, so the strategy stays a pure statement about the market.

Two engines
run_bar_loop is the research engine — one position, gap-aware, cost in R — and it is what the gauntlet consumes. edgekit also ships run_backtest, a fixed reward:risk prop-firm simulator with EOD flatten and loss caps, priced in pips and dollars. Reach for it only when you need the account-rules simulation; everything in Part IV runs on the R-stream from run_bar_loop.

Next: Why backtests lie — the five ways a clean-looking backtest is already wrong before you ever run a statistical test on it.