edgekit

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.

first_backtest.py
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)
Check what the data actually is
Before trusting any backtest, run 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.ORBor_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")   # 2666

The 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:

ColumnMeaning
entry_timebar timestamp the position opened
exit_timebar timestamp it closed
direction+1 long, -1 short
entrygap-aware fill price
exitstop / target / session-flatten fill price
stop_distthe risk denominator in price units (1R = opening-range width)
rthe trade result in R-multiples, net of costs
bars_heldhow many bars the trade was open
tagthe strategy name
exit_reasonwhy it closed (when the engine records one)
dateexit date — the key for daily/annual metrics
dirdirection 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.00R
Why R, not dollars
A trade's r 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.10

The returned dict — walk through what each key means:

KeyValue hereMeaning
n2666number of closed trades (~254/yr)
total_r≈ -570.6sum of all trade R
ev_r-0.214expectancy — mean R per trade (negative: it loses)
win_rate0.39fraction of winners
pf0.71profit factor = gross win / gross loss (< 1 = net loss)
avg_win / avg_lossmean R of winners / losers
best / worstlargest single win / loss in R
win_streak / loss_streaklongest 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 r is 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.
!A plausible strategy is not a profitable one
PF 0.71 and a negative expectancy are already telling you this one does not survive its own costs — the breakout wins are too small and too rare to pay for the losers plus the spread. That is not a bug in the example; it is the example. The ORB looks tradeable and is famous for it. The next guide runs the full gauntlet and formalises exactly why it gets rejected.

Next#