edgekit.indicators
Vectorised technical indicators — the single home for logic that was copy-pasted across ~90 research scripts (the Hawkes filter alone appeared 52 times). Every function returns a numpy array aligned to the input bars.
What's inside. Volatility (true_range, atr, atr_log), trend strength (adx), moving averages (sma, ema), the Donchian channel (donchian) used by breakout systems, oscillators (rsi, zscore), the Hawkes volatility-clustering pair (hawkes, hawkes_vol_expansion), and the cross-sectional / stat-arb primitives (rolling_beta, residualize, rolling_ols_hedge, half_life, cross_sectional_rank).
Every function here returns values that are NOT lagged. Index position i holds the indicator computed through bar i, inclusive — a value you only actually know at that bar's close. A trading rule that reads it on bar i is peeking at the current, completed bar: look-ahead bias.
The caller must lag every indicator with edgekit.core.lag (or a .shift(1)) before a strategy acts on it. Keeping the lag at the call site rather than baking it into each indicator is deliberate: it makes the causality decision visible and testable. This one habit is the difference between a real backtest and fiction.
from edgekit import indicators as ind
from edgekit.core import lag
# WRONG — reads the current bar's completed ATR (look-ahead)
N_bad = ind.atr(bars.high, bars.low, bars.close, 20)
# RIGHT — lag by one bar; N[i] reflects info known at the close of bar i-1
N = lag(ind.atr(bars.high, bars.low, bars.close, 20), 1)Volatility#
true_range#
Wilder true range — the greater of high−low, |high−prior close|, |low−prior close|. The first bar falls back to high−low (no prior close). Always ≥ 0.
true_range(h, l, c) -> np.ndarrayh, l, c— high / low / close arrays (or Series).
Returns: a numpy array of per-bar true range, aligned to the inputs.
atr#
Average true range — the R-denominator building block. wilder=False is a simple rolling mean (the default); wilder=True uses Wilder smoothing (ewm(alpha=1/n)).
atr(h, l, c, n: int = 20, wilder: bool = False) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
h, l, c | array | — | High / low / close. |
n | int | 20 | Lookback window. |
wilder | bool | False | False = simple rolling mean; True = Wilder ewm smoothing. |
Returns: a numpy array of ATR (leading n−1 positions are NaN for the rolling-mean form).
N = lag(ind.atr(bars.high, bars.low, bars.close, 20), 1) # risk unit (stop distance)atr_log#
Log-space ATR (Wilder ewm on log true range) — the volatility normaliser used by the Hawkes volatility filter, so range is comparable across price regimes.
atr_log(h, l, c, n: int = 84) -> np.ndarrayh, l, c— high / low / close.n— Wilder ewm lookback (default 84).
Returns: a numpy array of log-space ATR.
Trend & averages#
adx#
Wilder ADX (trend-strength), ported verbatim from the repo's wadx/adxf. A low ADX marks chop; a trend filter typically gates entries on ADX >= adx_min to skip it.
adx(h, l, c, n: int = 14) -> np.ndarrayh, l, c— high / low / close.n— Wilder smoothing length (default 14).
Returns: a numpy array of ADX values (roughly 0–100).
sma, ema#
Rolling simple mean; exponential mean (ewm(span=n, adjust=False)). A common macro_ma macro-trend filter is an sma of close.
sma(x, n: int) -> np.ndarray
ema(x, n: int) -> np.ndarrayx— the series to smooth (e.g. close).n— window / span.
Returns: a numpy array of the smoothed series.
ma200 = lag(ind.sma(bars.close, 200), 1) # macro filter: longs only above itdonchian#
The rolling channel behind breakout systems: upper = highest high over n bars, lower = lowest low over n bars. Not lagged — a breakout rule uses lag(upper, 1) so the breakout is measured against the channel as of the prior bar.
donchian(h, l, n: int = 20) -> tuple[np.ndarray, np.ndarray]h, l— high / low arrays.n— channel lookback (default 20, the classic breakout entry length).
Returns: a tuple (upper, lower) of numpy arrays.
up, lo = ind.donchian(bars.high, bars.low, 20)
up_prev = lag(up, 1) # breakout when high clears up_prevOscillators#
rsi#
Wilder RSI, bounded [0,100]. n=2 is the mean-reversion (RSI2) variant used on indices — a fast oscillator that flags short-term exhaustion.
rsi(c, n: int = 14) -> np.ndarrayc— close array.n— Wilder length (default 14; use 2 for the RSI2 mean-reversion setup).
Returns: a numpy array of RSI values in [0,100].
zscore#
Rolling z-score of a series over win bars — how many standard deviations the current value sits from its trailing mean. The workhorse of the stat-arb spread signal.
zscore(x, win: int) -> np.ndarrayx— the series (e.g. a hedge-ratio spread).win— rolling window.
Returns: a numpy array of rolling z-scores.
Hawkes volatility clustering#
hawkes#
Hawkes self-exciting intensity: o[i] = o[i-1]*exp(-kappa) + x[i], scaled by kappa. A decaying-memory accumulator — a spike in x excites the intensity, which then decays geometrically. Used as a volatility-clustering filter (NeuroTrader).
hawkes(x, kappa: float) -> np.ndarrayx— the excitation series (e.g. normalised bar range).kappa— decay rate; largerkappa= faster forgetting (default used elsewhere is 0.1).
Returns: a numpy array of Hawkes intensity (leading value is NaN until seeded).
hawkes_vol_expansion#
Boolean expansion filter: True when the Hawkes intensity of normalised range sits above its rolling median — i.e. volatility is expanding. A typical HK gate: only take a breakout while vol is opening up. Not lagged.
hawkes_vol_expansion(h, l, c, atr_lb: int = 84, kappa: float = 0.1,
median_win: int = 42) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
h, l, c | array | — | High / low / close. |
atr_lb | int | 84 | Log-ATR lookback used to normalise range. |
kappa | float | 0.1 | Hawkes decay rate. |
median_win | int | 42 | Rolling window for the expansion threshold (median). |
Returns: a boolean numpy array — True where volatility is expanding.
hawkes_vol_expansion here is the standalone, general-purpose form. They are close but not necessarily bit-identical — use this one for research, and expect a strategy to reproduce its own inlined gate.Cross-sectional & stat-arb#
rolling_beta#
Causal rolling beta of y on mkt — cov/var over the trailing window. The market-exposure estimate behind the residual-momentum construction.
rolling_beta(y, mkt, win: int) -> np.ndarrayy— the asset return series.mkt— the market/factor return series.win— trailing window.
Returns: a numpy array of rolling beta.
residualize#
Strip the market factor: ret - beta*mkt using a causal rolling beta — the residual-momentum (market-neutral) construction. What is left is the idiosyncratic return the strategy actually trades.
residualize(ret, mkt, win: int) -> np.ndarrayret— the asset return series.mkt— the market return series to neutralise against.win— the rolling-beta window.
Returns: a numpy array of market-neutral residual returns.
rolling_ols_hedge#
Causal rolling OLS of y on x — the pairs / stat-arb hedge ratio (a lightweight Kalman substitute). Returns the intercept, slope, and the residual spread you actually trade.
rolling_ols_hedge(y, x, win: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]y, x— the two legs of the pair.win— the trailing OLS window.
Returns: a tuple (alpha, beta, residual_spread) of numpy arrays — feed the spread to zscore / half_life.
alpha, beta, spread = ind.rolling_ols_hedge(gold, silver, win=90)
z = lag(ind.zscore(spread, 60), 1) # trade the spread's z-scorehalf_life#
Ornstein-Uhlenbeck half-life of mean reversion via an AR(1) fit on the spread. A small half-life means fast reversion (a tradeable pair); it returns inf when the spread is non-reverting.
half_life(spread) -> floatspread— the residual spread series (e.g. fromrolling_ols_hedge).
Returns: a float half-life in bars (inf if non-reverting).
hl = ind.half_life(spread) # < ~60 bars = a live paircross_sectional_rank#
Row-wise rank in [0,1] across assets (columns) — the cross-sectional momentum / reversal signal. Each row is one date, each column one instrument; the output ranks every asset against its peers on that date.
cross_sectional_rank(matrix: pd.DataFrame) -> pd.DataFramematrix— aDataFrameof a per-asset signal (rows = dates, columns = instruments).
Returns: a DataFrame of the same shape with values in [0,1] (percentile rank across columns per row).
ranks = ind.cross_sectional_rank(momentum_12m) # 1.0 = strongest, 0.0 = weakest that dateSee also#
- edgekit.core — the
lagprimitive you must apply to every output here. - edgekit.strategy — how a strategy wires these into
prepare. - edgekit.engine — consumes the lagged indicator arrays bar by bar.