Quickstart
Take a famous, intuitive strategy — the opening-range breakout — from raw 1-minute bars through the gauntlet, and watch it get rejected. Every call below is the real public API, and the numbers are the ones the canonical example prints on Nasdaq. Examples exist to show the library, not to hand out alpha.
The shape of the code mirrors the pipeline it implements: load → backtest → prove → size → ship. We build it one step at a time, then paste the whole thing together at the end. Make sure edgekit is installed first; the lean core is enough for the whole run.
1. Load and slice to the session#
data.load_bars reads a HistoryExporter CSV (or a .parquet split) into the OHLC contract: a tz-naive UTC DatetimeIndex with float open/high/low/close columns. The ORB is an intraday strategy, so instead of resampling we keep the raw 1-minute bars and mask to the regular cash session with data.rth_mask — DST-correct, 09:30–16:00 New York, so the opening range is anchored to the real Nasdaq open.
import edgekit as ek
# load Nasdaq M1, keep the regular cash session (09:30-16:00 New York)
bars = ek.data.load_bars("US100_M1.csv")
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()}")2. Run the causal backtest#
strategy.ORBis the raw opening-range breakout: each session’s first or_bars minutes define a range, a break of its high goes long / its low goes short, the stop sits at the opposite edge (so the range width is 1R), and the position is flattened at a target_r multiple or at the close — one trade per day. .backtest() runs the bar loop with gap-aware fills, pessimistic stops, and cost charged in R, returning the canonical trade frame (one row per closed round-trip, net R in the r column). It is intraday, so warmup is small and bars_per_day=390 (M1 bars in an RTH session) converts hold-in-bars to days for the swap cost.
# 30-minute opening range, 2R target, stop at the opposite edge, one trade/day
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
stats = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
print(f"{len(trades)} trades | win {stats['win_rate']:.0%} | PF {stats['pf']:.2f} "
f"| EV {stats['ev_r']:+.3f}R")
# 2666 trades | win 39% | PF 0.71 | EV -0.214Rtrade_stats is the universal per-trade summary in R-space. Passing dates (the exit dates) unlocks the annualised metrics. On US100 M1 from 2015–2026 the ORB prints 2,666 trades, PF 0.71, EV −0.214R — a profit factor below 1 and a negative expectancy. The number is already bad, but we run the rest of the gauntlet anyway to understand why, because a below-1 headline is not always the whole story.
3. Prove it with a permutation test#
The decisive structural question: is the strategy’s timing distinguishable from random, or is it just noise? The Monte-Carlo permutation test answers it. validation.permute_ohlc decomposes each bar into gap / high / low / close increments and applies one shared shuffle — destroying trends and autocorrelation while preserving the return marginal and bar shapes. We re-run the same strategy on each permuted series to build the null distribution, then validation.mcpt counts how often the null matches or beats the real total R.
import numpy as np, pandas as pd
o, h, l, c = (rth[x].to_numpy(float) for x in ("open", "high", "low", "close"))
idx = rth.index
def null_stat(rng):
po, ph, pl, pc = ek.validation.permute_ohlc(o, h, l, c, rng)
shuffled = pd.DataFrame({"open": po, "high": ph, "low": pl, "close": pc}, index=idx)
t = orb.backtest(shuffled, warmup=5, bars_per_day=390)
return float(t.r.sum()) if len(t) else 0.0
p = ek.validation.mcpt(float(trades.r.sum()), null_stat, n=1000)
print(f"permutation test: p = {p:.4f}") # low p: the timing beats random...Here is the subtle part, and the whole reason the ORB makes a good teaching case: the permutation p on total R comes out low. The real breakout’s entry timing genuinely beats random-entry shuffles — it is not pure noise. And yet the strategy still loses money (PF 0.71). The permutation test asks "is the timing structure real?", not "does it make money net of what it costs to trade?". Beating a coin-flip is not the same as beating the spread.
p < 0.01as "profitable"; read it as "not obviously noise, keep testing."4. Cost-stress — where it actually dies#
Re-running at 1×, 2× and 3× assumed cost is the graceful-degradation check: a real edge bends, a fake one breaks. CostModel().scaled(mult) escalates the spread and swap. The ORB never clears a profit factor of 1 — not even at nominal cost — and collapses as cost climbs. This is the realistic-cost gate (gauntlet step 2), and it is where the candidate is rejected: you never bank a positive expectancy to size in the first place.
for mult in (1.0, 2.0, 3.0):
t = orb.backtest(rth, cost=ek.CostModel().scaled(mult), warmup=5, bars_per_day=390)
s = ek.trade_stats(t.r.to_numpy())
print(f" {mult:.0f}x -> PF {s['pf']:.2f}")
# 1x -> PF 0.71 2x -> PF 0.45 3x -> PF 0.29 (below 1 at every level -> dead)5. Record the verdict in a report#
A rejected strategy still earns a report — the point of the library is an honest record of what you tested and why it died. report.Report assembles a self-contained HTML page (CSS and every chart inlined as base64, zero external requests). It needs the [viz] extra.
verdict = "REJECTED - net-negative after costs" if stats["pf"] <= 1 else "survives"
(ek.report.Report("ORB - edgekit gauntlet", meta="US100 M1 - 30-min opening range")
.kpi_row([("PF", f"{stats['pf']:.2f}", f"win {stats['win_rate']:.0%}"),
("EV", f"{stats['ev_r']:+.3f}R", f"{len(trades)} trades"),
("Perm p", f"{p:.3f}", "timing beats random"),
("Cost x3", "PF 0.29", verdict)])
.caveat("Timing is non-random, but PF < 1 at every cost level. Not tradeable.")
.write("orb_report.html"))The whole thing#
Here is the full script, adapted from examples/orb_gauntlet.py — the canonical end-to-end recipe that doubles as an integration smoke test. It exercises strategy + engine + metrics + validation + costs + report together on real data, and it ends in a rejection.
import numpy as np
import pandas as pd
import edgekit as ek
# 1) load Nasdaq M1 and keep the regular cash session (09:30-16:00 New York)
bars = ek.data.load_bars("US100_M1.csv")
rth = bars[ek.data.rth_mask(bars.index, start="09:30", end="16:00", tz="America/New_York")]
# 2) causal backtest: 30-min opening range, 2R target, stop at the opposite edge
orb = ek.strategy.ORB(or_bars=30, target_r=2.0)
trades = orb.backtest(rth, warmup=5, bars_per_day=390)
stats = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
print(f"{len(trades)} trades | win {stats['win_rate']:.0%} | PF {stats['pf']:.2f} "
f"| EV {stats['ev_r']:+.3f}R") # 2666 | PF 0.71 | EV -0.214R
# 3) PROVE it -- Monte-Carlo permutation test (structure, not profitability)
o, h, l, c = (rth[x].to_numpy(float) for x in ("open", "high", "low", "close"))
idx = rth.index
def null_stat(rng):
po, ph, pl, pc = ek.validation.permute_ohlc(o, h, l, c, rng)
shuffled = pd.DataFrame({"open": po, "high": ph, "low": pl, "close": pc}, index=idx)
t = orb.backtest(shuffled, warmup=5, bars_per_day=390)
return float(t.r.sum()) if len(t) else 0.0
p = ek.validation.mcpt(float(trades.r.sum()), null_stat, n=1000)
print(f"permutation: p = {p:.4f} -> timing beats random, but see the cost gate")
# 4) cost-stress x1/x2/x3 -- the gate it fails
for mult in (1.0, 2.0, 3.0):
s = ek.trade_stats(orb.backtest(rth, cost=ek.CostModel().scaled(mult),
warmup=5, bars_per_day=390).r.to_numpy())
print(f" {mult:.0f}x -> PF {s['pf']:.2f}") # 0.71 / 0.45 / 0.29 -> never above 1
# 5) record the verdict -- REJECTED, not sized, not shipped
(ek.report.Report("ORB - edgekit gauntlet", meta="US100 M1 - 30-min opening range")
.kpi_row([("PF", f"{stats['pf']:.2f}", f"win {stats['win_rate']:.0%}"),
("EV", f"{stats['ev_r']:+.3f}R", f"{len(trades)} trades"),
("Perm p", f"{p:.3f}", "timing beats random"),
("Cost x3", "PF 0.29", "REJECTED")])
.caveat("Timing is non-random, but PF < 1 at every cost level. Not tradeable.")
.write("orb_report.html"))Where to go next#
- The ORB gauntlet — this example in full, and the honest nuance of a strategy whose timing beats random yet still loses to costs.
- The pipeline — each stage above in depth, and why the order is load → backtest → prove → size → ship.
- The validation gauntlet — walk-forward, regime splits, PBO, deflated Sharpe and is-it-beta beyond the permutation test.
- Guide: your first backtest — a fuller walkthrough on your own data.
- API reference — every public function, module by module.