edgekit

edgekit.strategy

Small, illustrative strategy templates expressed against the causal Strategy interface. Each subclasses BaseStrategy: prepare builds lagged indicator arrays, entry/exit make per-bar decisions, and .backtest(bars) runs the R-multiple bar loop. Bring your own strategy by subclassing the base.

What's inside. The abstract base BaseStrategy, plus two concrete templates: ORB (an intraday opening-range breakout) and SmaCross (a fast/slow moving-average crossover). Both are plain textbook demos built on the same interface.

These are demonstrations, not signals
The bundled strategies exist to exercise the engine and the gauntlet — they are illustrative, not tuned edges (unfiltered, they are typically net-negative after costs). The workflow is always: implement a candidate, then put it through the gauntlet. Most candidates should be rejected (see the ORB example).

BaseStrategy (ABC)#

The abstract base implementing the engine's Strategy protocol. Subclasses set a name and implement three methods; they inherit the one-line backtest wiring.

prepare(self, bars) -> dict                        # precompute causal indicator arrays (the P dict)
entry(self, bars, P, i) -> EntryIntent | None      # entry decision on a flat bar i
exit(self, bars, P, pos, i) -> float | None        # exit price on an in-position bar i

The inherited convenience method runs the strategy through run_bar_loop and returns the canonical trade DataFrame priced in R:

backtest(self, bars, cost: CostModel | None = None, warmup: int = 210,
         bars_per_day: float = 6.0) -> pd.DataFrame
ParamTypeDefaultMeaning
barspd.DataFrameOHLC frame (tz-naive UTC DatetimeIndex, float open/high/low/close).
costCostModel | NoneNoneCost model; default = the library convention (12 bps round-trip + 2 bps/day).
warmupint210Bars to skip before trading (indicator warmup).
bars_per_dayfloat6.0Converts hold-in-bars to days for the swap cost (H4 = 6/day).

Returns the canonical trade frame: one row per closed trade with net R in r, exit date in date, plus dir, bars_held, entry, exit, stop_dist, tag, exit_reason.

ORB#

Opening-range breakout — a classic intraday breakout, as a plain template.

ORB(or_bars: int = 30, target_r: float = 2.0)
ParamDefaultMeaning
or_bars30Bars in the opening range (its width = 1R).
target_r2.0Flatten at target_r × risk (or at end of session).

Each session's first or_bars bars define an opening range; a break of its high goes long / its low goes short, with the stop at the opposite edge (range width = 1R). One trade per day, flattened at the session end.

import edgekit as ek
from edgekit.strategy import ORB
rth = bars[ek.data.rth_mask(bars.index, start="09:30", end="16:00", tz="America/New_York")]
trades = ORB(or_bars=30, target_r=2.0).backtest(rth, warmup=5, bars_per_day=390)
!Session-slice first
The session is derived from the bar index's calendar day — pre-slice / localise the frame to your trading session before backtesting. Being intraday, pass a small warmup and a bars_per_day matching your bar size. This is the raw, unfiltered breakout skeleton — a demonstration, not a tuned edge.

SmaCross#

A vanilla fast/slow moving-average crossover with an ATR stop — the textbook trend-following template.

SmaCross(fast: int = 20, slow: int = 100, atr_n: int = 20, stop_mult: float = 2.0)
ParamDefaultMeaning
fast20Fast SMA period.
slow100Slow SMA period.
atr_n20ATR period for the stop distance.
stop_mult2.0Hard stop distance in ATRs (the R denominator).

Go long when the fast SMA crosses above the slow SMA, short on the opposite cross; exit on a stop_mult×ATR stop or when the SMAs cross back. All indicators are lagged, so bar i only sees information through i-1.

from edgekit.strategy import SmaCross
trades = SmaCross(fast=20, slow=100).backtest(bars, warmup=210, bars_per_day=6)

Writing your own#

Subclass BaseStrategy, precompute (and lag) your indicators in prepare, return an EntryIntent(direction, level, stop_dist) from entry, and an exit price from exit. The engine handles gap-aware fills, the R accounting, and costs. See the custom-strategy guide and the tutorial chapter for a full walkthrough.

New templates#

Five more textbook edges. The first two are engine-driven like ORB and SmaCross — subclasses of BaseStrategy with the inherited .backtest(bars). The last three trade a cross-section or a pair, which the single-instrument bar loop cannot express, so each carries its own vectorised backtest with its own signature — read them below. All are demonstrations for the gauntlet, not tuned edges.

BollingerRevert#

Bollinger-band mean reversion — go long when the lagged close drops below the lower band, exit at the lagged middle band, with a hard ATR stop. Engine-driven: the usual backtest(bars) applies.

BollingerRevert(n: int = 20, k: float = 2.0, atr_n: int = 20, stop_mult: float = 2.5)
ParamDefaultMeaning
n20Bollinger lookback (middle band = SMA(n)).
k2.0Band width in standard deviations.
atr_n20ATR period for the stop distance.
stop_mult2.5Hard stop in ATRs (the R denominator).
from edgekit.strategy import BollingerRevert
trades = BollingerRevert(n=20, k=2.0).backtest(bars)   # canonical trade frame in R

HawkesBreakout#

A Donchian breakout gated by a Hawkes self-exciting activity process — enter a channel break only when the lagged Hawkes intensity of |log returns| sits above its rolling q-quantile, i.e. when volatility is actively clustering. The gate is the point: raw Donchian breaks mostly fail; breaks during an activity burst are the ones with follow-through. ATR stop, engine-driven, all inputs lagged.

HawkesBreakout(kappa: float = 0.1, don_n: int = 20, q: float = 0.95,
               q_win: int = 100, atr_n: int = 20, stop_mult: float = 2.0)
ParamDefaultMeaning
kappa0.1Hawkes decay rate of the activity process.
don_n20Donchian channel lookback.
q0.95Rolling quantile the intensity must exceed to arm the breakout.
q_win100Window (bars) of the rolling quantile gate.
atr_n20ATR lookback for the stop.
stop_mult2.0Stop distance in ATR multiples.
from edgekit.strategy import HawkesBreakout
trades = HawkesBreakout(kappa=0.1, don_n=20, q=0.95).backtest(bars)

PairsCoint#

A cointegration pairs trade over two bar frames — hedge ratio from timeseries.kalman_hedge (rolling-OLS fallback), z-score of the spread over lookback, enter beyond entry_z, exit inside exit_z, stop out at stop_z. R is defined in z-space: the z-move captured divided by stop_z − entry_z (the risk budget of the trade).

PairsCoint(entry_z: float = 2.0, exit_z: float = 0.5, stop_z: float = 4.0, lookback: int = 100)

.backtest(bars_a: pd.DataFrame, bars_b: pd.DataFrame, cost=None) -> pd.DataFrame
ParamDefaultMeaning
entry_z2.0Enter when |z| of the spread exceeds this.
exit_z0.5Exit when |z| reverts inside this.
stop_z4.0Stop out when |z| blows past this (defines 1R).
lookback100Window for the spread z-score.

Returns: the canonical trade frame in R — same columns as the engine-driven templates, one row per closed spread trade.

from edgekit.strategy import PairsCoint
trades = PairsCoint(entry_z=2.0, exit_z=0.5).backtest(bars_pep, bars_ko)
trades.r.sum()   # R accounting works the same downstream (trade_stats, MCPT, sizing)

CsMomentum#

Cross-sectional momentum over a price panel — rank assets by the lookback-bar return skipping the most recent skip bars (sidestepping short-term reversal), go long the top top_frac and short the bottom, rebalanced on the rebalance calendar. Weights are shifted before returns are computed, so the backtest is causal.

CsMomentum(lookback: int = 126, skip: int = 21, top_frac: float = 0.2, rebalance: str = "ME")

.backtest(prices: pd.DataFrame) -> dict
ParamDefaultMeaning
lookback126Ranking window in bars (~6 months daily).
skip21Most-recent bars excluded from the ranking (~1 month).
top_frac0.2Fraction of names held long (and short).
rebalance"ME"pandas offset alias for the rebalance calendar (month-end).

Returns: a dict with keys "weights" (the T×N weight panel actually held) and "returns" (the causal portfolio return series).

from edgekit.strategy import CsMomentum
res = CsMomentum(lookback=126, skip=21).backtest(prices_panel)
res["returns"].cumsum()          # judge with ek.metrics; check ek.metrics.turnover(res["weights"])

Carry#

A cross-sectional carry sort — each rebalance, go long the top top_frac of assets by carry and short the bottom, holding until the next rebalance. You supply the carry panel (futures roll yield, FX rate differential, funding rates …) aligned to the price panel; weights are applied causally (shifted) like CsMomentum.

Carry(top_frac: float = 0.3, rebalance: str = "W")

.backtest(prices: pd.DataFrame, carry: pd.DataFrame) -> dict
ParamDefaultMeaning
top_frac0.3Fraction of names long (and short) by carry rank.
rebalance"W"pandas offset alias for the rebalance calendar (weekly).

Returns: a dict with keys "weights" and "returns", same contract as CsMomentum.

from edgekit.strategy import Carry
res = Carry(top_frac=0.3, rebalance="W").backtest(prices_panel, carry_panel)
res["returns"]   # long high carry, short low carry, causal

See also#