edgekit

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).

The causality contract — read this first

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.

causality.py
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.ndarray
  • h, 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
ParamTypeDefaultMeaning
h, l, carrayHigh / low / close.
nint20Lookback window.
wilderboolFalseFalse = 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.ndarray
  • h, 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.ndarray
  • h, 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.ndarray
  • x — 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 it

donchian#

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_prev

Oscillators#

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.ndarray
  • c — 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.ndarray
  • x — 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.ndarray
  • x — the excitation series (e.g. normalised bar range).
  • kappa — decay rate; larger kappa = 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
ParamTypeDefaultMeaning
h, l, carrayHigh / low / close.
atr_lbint84Log-ATR lookback used to normalise range.
kappafloat0.1Hawkes decay rate.
median_winint42Rolling window for the expansion threshold (median).

Returns: a boolean numpy array — True where volatility is expanding.

!A strategy may inline a subtly different version
A strategy can inline its own vol-expansion computation (e.g. to match a specific seeding); 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.ndarray
  • y — 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.ndarray
  • ret — 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-score

half_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) -> float
  • spread — the residual spread series (e.g. from rolling_ols_hedge).

Returns: a float half-life in bars (inf if non-reverting).

hl = ind.half_life(spread)              # < ~60 bars = a live pair

cross_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.DataFrame
  • matrix — a DataFrame of 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 date

See also#