edgekit

The pipeline

edgekit implements one opinionated path from raw bars to a live strategy: load → backtest → prove → size → ship. The stages are ordered on purpose — each one is a gate, and skipping ahead is how fake edges reach production.

The whole library is designed to read like this pipeline. Below, each stage explains what happens, which modules do it, and why it sits where it does. The governing rule: you do not size or ship a strategy until it is proven. Sizing an unproven backtest just risks real money on noise; a polished report of a lucky result is a liability, not an asset.

Load#

The data module owns everything before a signal exists: loading bars, resampling to the strategy timeframe, marking DST-correct sessions, and splitting chronologically. data.load_bars reads a HistoryExporter CSV or parquet split into the OHLC contract — a tz-naive UTC DatetimeIndex with float open/high/low/close, sorted and de-duplicated. data.resample_ohlcv downsamples to a coarser timeframe using the causal label="left" / closed="left" convention: each bar carries its open timestamp, so a strategy acting at that timestamp only sees bars that have closed.

import edgekit as ek

bars = ek.data.load_bars("US100_M1.csv")               # OHLC contract, UTC index
rth  = bars[ek.data.rth_mask(bars.index)]              # -> keep the RTH cash session

# know what the data actually is before you trust it
rep = ek.data.integrity_report(bars, bar_minutes=1)
print(rep["intraweek_gaps"], rep["weekend_gaps"])

Why first.Everything downstream inherits the data’s flaws. A gap, a duplicated timestamp, or a look-ahead-inducing resample convention silently poisons the backtest — so the contract is enforced at the door with data.integrity_report and the sealed chronological holdout (data.chronological_split) available for later out-of-sample work.

Backtest#

A strategy emits trades priced in R-multiples— profit measured in units of a trade’s own initial risk. The engine runs the strategy bar-by-bar (run_bar_loop) with gap-aware fills and pessimistic stops, charging costs in R via CostModel. Indicators from indicators are returned unlagged; the strategy lags them explicitly with core.lag, keeping the causality decision visible and testable. The metrics module then summarises the trade R-stream with trade_stats.

strat  = ek.strategy.ORB(or_bars=30, target_r=2.0)
trades = strat.backtest(rth, warmup=5, bars_per_day=390)   # canonical trade frame, r = net R
stats  = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
print(stats["pf"], stats["ev_r"])                          # PF 0.71 | EV -0.214R
!Causality is a tested property, not a hope
R is charged net of cost, stops fill at the worse of stop/open, and indicators are lagged at the call site. Property tests perturb future bars and assert the past does not move. A good backtest number here is the start of skepticism — the whole reason the next stage exists.

Prove#

This is the crown jewel and the reason edgekit exists. The validation gauntlet tries to break your result before the market does. The decisive step is the Monte-Carlo permutation test: validation.permute_ohlc destroys serial structure (trends, autocorrelation) while preserving bar shapes and the return marginal, you re-run the same strategy on each permuted series, and validation.mcpt returns a p-value. p < 0.01 is a real edge; a no-edge strategy yields p ~ U(0,1).

import pandas as pd
o, h, l, c = (rth[x].to_numpy(float) for x in ("open", "high", "low", "close"))

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=rth.index)
    return float(strat.backtest(shuffled, warmup=5, bars_per_day=390).r.sum())

p = ek.validation.mcpt(float(trades.r.sum()), null_stat, n=1000)   # low p (timing != noise)

The permutation test is one gate of several. Run the rest of the gauntlet in order and stop believing at the first failure: walk-forward across blocks (validation.walk_forward), regime splits (validation.regime_by_year / regime_by_adx), cost-stress ×1/2/3 (costs.cost_stress — the ORB collapses to PF 0.29 at 3× cost, and never clears 1 even at nominal cost), an is-it-alpha-or-beta regression (validation.is_it_beta), and a parameter-plateau check (validation.param_sweep).

This is the gate
Prove sits between backtest and size for a hard reason: an unproven edge must never be sized or shipped. If the permutation p-value is not below 0.01, or any gauntlet stage fails, the strategy is dead — there is nothing downstream to do. Most apparent alpha is fake, and this stage is where you find out cheaply, on paper, instead of expensively, live.

Size#

Only now do dollars enter, exactly once. The sizing module turns the proven R-stream into a dollar-risked equity path. sizing.size_to_dd finds the dollars-per-R scalar that makes historical max drawdown equal a hard budget (e.g. 0.095 of the account for a 10% prop limit with buffer), honouring a daily-loss cap when it binds tighter. For multiple books, portfolio combines their daily-R streams (risk-parity by default), and the governors vol_target, cppi and dd_throttle ride on top.

daily_r = trades.set_index("date").r.groupby(lambda t: t.normalize()).sum()
sz = ek.sizing.size_to_dd(daily_r, dd_budget=0.095, account=100_000, daily_cap=0.045)
print(sz["binding"], sz["dollar_per_r"])   # which limit binds, and $/R (only run this on a survivor)
!Size against the honest drawdown
The realised historical drawdown is optimistic. validation.dd95 gives the 95th-percentile drawdown from a block-bootstrap — the number to size against. And the sized return is a ceiling: haircut it ×0.85 for edge decay before you treat it as a forward estimate.

Ship#

The last stage packages the proven, sized strategy for a decision. challenge answers the prop-firm question honestly — a challenge is a path problem, not an average-return problem, so challenge.simulate block-bootstraps the sized daily-P&L through the firm’s rules (FTMO / CFT / BrightFunded) and returns a Monte-Carlo pass rate. report and viz then emit self-contained HTML — CSS and every chart inlined as base64, zero external requests — including the linked Challenge / Live / Realistic trio that never hides the haircut.

rate = ek.challenge.simulate(sz["sized"], ek.challenge.FTMO_1STEP)   # MC pass rate

# for a survivor only -- the ORB above never reaches this stage
(ek.report.Report("shipped strategy", meta="sized to a 10%-max / 5%-daily budget")
    .kpi_row([("Perm p", f"{p:.3f}", "cleared the gauntlet"),
              ("Pass",   f"{rate:.0%}", "FTMO 1-step"),
              ("$/R",    f"{sz['dollar_per_r']:,.0f}", f"binds {sz['binding']}")])
    .caveat("Backtest ceiling; haircut x0.85 for a forward estimate.")
    .write("report.html"))

Why last. Shipping is the only stage that touches the outside world — a live bot, a prop challenge, a report someone acts on. It is deliberately downstream of proof and sizing so that the only thing that ever gets shipped is an edge that survived the gauntlet and was sized to a real drawdown budget.

Where to go next#