Your first backtest
Load raw Nasdaq bars, slice them to the regular cash session, run the causal ORB engine, and read the two objects every edgekit workflow produces: the trade frame and the trade-stats dict. Ten minutes, one strategy, real numbers.
This guide walks the first four lines of the pipeline. It stops short of proving anything — that is the next guide. Here the goal is just to get a validated round-trip: bars in, priced-in-R trades out, and an honest summary you can trust. We use the opening-range breakout (ORB) as the running example — a famous, intuitive strategy, and, as the summary already hints, a useful one precisely because it turns out not to be an edge.
Step 1 — load the bars#
data.load_bars reads a HistoryExporter CSV (time_utc,open,high,low,close,tick_volume) or a processed parquet split. It validates the schema, sorts the index, and drops duplicate timestamps so the frame is monotone and unique — the OHLC contract the rest of the library assumes.
import edgekit as ek
bars = ek.data.load_bars("US100_M1.csv") # Nasdaq index, 1-minute bars
print(bars.shape, bars.index[0], "->", bars.index[-1])
print(bars.columns.tolist()) # ['open', 'high', 'low', 'close', 'tick_volume']Step 2 — keep the regular-hours session#
The ORB is intraday: each session's opening range defines the day's setup, and the position is flattened at the close. data.rth_maskselects the regular cash session (09:30–16:00 New York), left-inclusive / right-exclusive so the 16:00 close bar is dropped. Pre-slicing to the session you trade is what lets the strategy read each day's calendar cleanly.
rth = bars[ek.data.rth_mask(bars.index, start="09:30", end="16:00", tz="America/New_York")]
print(f"{len(rth):,} RTH bars {rth.index[0].date()} -> {rth.index[-1].date()}")
# 390 M1 bars per session (09:30-16:00 = 6.5h)ek.data.integrity_report(bars, bar_minutes=1). It reports span, weekend vs intraweek gaps, zero-range bars, and spikes — the difference between a clean series and one with holes that silently inflate a result.Step 3 — run the causal backtest#
Every strategy in the zoo exposes a one-liner .backtest(bars). Here we run strategy.ORB — or_bars=30 uses the first 30 minutes as the opening range, and target_r=2.0 flattens winners at twice the range width (the stop sits at the opposite edge, so the range is 1R). One trade per day, flattened at the close. The call returns the canonical trade frame: one row per closed round-trip, priced in R and net of costs.
orb = ek.strategy.ORB(or_bars=30, target_r=2.0)
trades = orb.backtest(rth, warmup=5, bars_per_day=390) # 390 M1 bars per RTH session
print(len(trades), "trades") # 2666The default cost model is the fraction-of-price convention — 12 bps round-trip spread plus 2 bps/day swap — and bars_per_day=390converts each trade's hold-in-bars into days for that swap charge (an intraday trade holds a fraction of a day). Both are arguments to .backtest if you need to change them.
Reading the trade frame#
Every row is one closed trade. The columns:
| Column | Meaning |
|---|---|
entry_time | bar timestamp the position opened |
exit_time | bar timestamp it closed |
direction | +1 long, -1 short |
entry | gap-aware fill price |
exit | stop / target / session-flatten fill price |
stop_dist | the risk denominator in price units (1R = opening-range width) |
r | the trade result in R-multiples, net of costs |
bars_held | how many bars the trade was open |
tag | the strategy name |
exit_reason | why it closed (when the engine records one) |
date | exit date — the key for daily/annual metrics |
dir | direction alias downstream tooling expects |
trades[["entry_time", "direction", "entry", "exit", "r", "bars_held"]].head()
# the whole result in one number:
print(f"total {trades.r.sum():+.1f}R best {trades.r.max():+.2f}R worst {trades.r.min():+.2f}R")
# total -570.6R best +2.00R worst -1.00Rr is its profit measured in units of its own initial risk. Dollars are applied once, later, from a single sizing scalar — never baked into a strategy. This is what lets the same trade stream be sized to any account or drawdown budget. See R-multiples.Step 4 — summarise with trade_stats#
trade_stats is the universal per-trade summary in R-space. Pass the R column; pass dates= (the exit dates) to unlock annualised metrics, and hold= (bars-held) to unlock hold statistics.
stats = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
print(f"{stats['n']} trades | win {stats['win_rate']:.0%} | PF {stats['pf']:.2f} "
f"| EV {stats['ev_r']:+.3f}R | ann {stats['ann_r']:+.1f}R | MAR {stats['mar']:.2f}")
# 2666 trades | win 39% | PF 0.71 | EV -0.214R | ann -54.3R | MAR -0.10The returned dict — walk through what each key means:
| Key | Value here | Meaning |
|---|---|---|
n | 2666 | number of closed trades (~254/yr) |
total_r | ≈ -570.6 | sum of all trade R |
ev_r | -0.214 | expectancy — mean R per trade (negative: it loses) |
win_rate | 0.39 | fraction of winners |
pf | 0.71 | profit factor = gross win / gross loss (< 1 = net loss) |
avg_win / avg_loss | — | mean R of winners / losers |
best / worst | — | largest single win / loss in R |
win_streak / loss_streak | — | longest run of each |
Passing dates= adds the annualised block: years, ann_r (R per year), max_dd_r (max drawdown in R), mar (ann_r / max_dd — the risk-adjusted headline), sharpe, worst_day_r, and trades_per_year. Passing hold=trades.bars_held adds avg_hold and median_hold.
What the engine did for you#
Two causal guarantees are baked into every one of those 2,666 trades:
- Gap-aware fills. A long whose breakout level is jumped fills at the actual price, not the stale trigger —
entry = max(level, open). A stop gapped through fills at the worse of stop and open. You never get the fantasy price. - Cost charged in R.Each trade's spread + swap is converted to R using its own stop distance and subtracted from the gross result, so
ris always net. Wider-range trades pay proportionally less R for the same spread. - Pessimistic stops. When a bar could have hit both stop and target, the loss is taken. No favourable-fill wishful thinking.
Next#
- Proving an edge — run this ORB through the full gauntlet.
- Sizing for a prop-firm challenge — turn an R-stream into dollars.
- Causality — why the fills and lags are the way they are.
- API · metrics — every key trade_stats returns.