Indicators & features
Indicators are how a strategy compresses a stream of prices into a handful of numbers a rule can act on. There are only a few underlying ideas — level, direction, speed, spread, and dispersion — and almost every named indicator is one of them in disguise. This chapter walks the families, gives the one-line formula and the exact edgekit call for each, and nails down the causality rule that decides whether any of it means anything.
The families#
Trend — where is the level, and which way is it drifting?#
Moving averages smooth price into a slow level so you can read direction. The simple moving average is the mean of the last closes; the exponential moving average weights recent bars more heavily and reacts faster:
A rising average means the market is trending up on that horizon; the gap or crossover between a fast and a slow average is the classic trend signal. Longer = smoother but laggier.
ma_fast = ek.indicators.ema(bars.close, 20)
ma_slow = ek.indicators.sma(bars.close, 100)
ma200 = ek.indicators.sma(bars.close, 200) # a common macro-trend gate: longs only above itScenario (a cross fires). On BTCUSDT H4, the fast EMA(20) has been sitting under the slow SMA(100) all through a two-week grind — no long allowed. On the 16:00 bar the EMA(20) prints 64,900 and the SMA(100) prints 64,700: the fast line has crossed above the slow one. At the desk that is the trend flag turning on. If price is also above the SMA(200) macro gate (say 61,000), a trend-follower now has permission to look for a long; if the fast line were still below the slow, the same bar would be ignored entirely.
Momentum — how fast, and is it overextended?#
Momentum oscillators measure the speed and one-sidedness of recent moves. The Relative Strength Index maps the ratio of average up-moves to average down-moves onto a bounded 0–100 scale:
High RSI flags an overbought push, low RSI an oversold washout. A trend-follower reads a high RSI as strength; a mean-reverter reads the same value as a fade candidate — the indicator is neutral, the interpretation is the strategy. The fast variant is the short-horizon exhaustion flag; longer is the smoother momentum read.
rsi_fast = ek.indicators.rsi(bars.close, 2) # short-term exhaustion
rsi = ek.indicators.rsi(bars.close, 14) # smoother momentumScenario (same number, two strategies).ETHUSDT has run hard and the RSI(14) reads 78. A trend-follower sees 78 and thinks “strength — the move has thrust, stay long.” A mean-reverter sees the identical 78 and thinks “overbought — fade it back toward the mean.” The oscillator has not taken a side; the strategy has. That is why the RSI belongs to the feature layer, not the signal — the same column feeds opposite trades depending on which archetype reads it.
Volatility — how big is a normal move right now?#
Volatility indicators size the market's current wiggle so risk can be set relative to it. Average True Range is the mean of the true range — the largest of today's high−low and the gaps to the prior close:
ATR is the workhorse behind the R-multiple: a stop set at keeps the risk-per-trade constant in volatility units across calm and wild regimes, which is what makes R comparable through time. The log-space variant normalises range so it is comparable across price levels, and the Average Directional Index turns range into a trend-strength score (low ADX = chop, a natural gate to sit out).
atr = ek.indicators.atr(bars.high, bars.low, bars.close, 20) # stop distance / R unit
atr_lg = ek.indicators.atr_log(bars.high, bars.low, bars.close, 84) # regime-comparable vol
adx = ek.indicators.adx(bars.high, bars.low, bars.close, 14) # trend strength (gate chop)Scenario (ATR sets the risk, not the price).In a calm July stretch BTCUSDT's H4 ATR(20) is 700 points; after a macro shock it jumps to 2,100. A fixed 1,000-point stop would be three noise-bars wide in calm and get tapped on the first wiggle in stress. A stop instead sits 1,400 points away in calm and 4,200 in stress — so “one R” always means the same amount of normal movement. That is the whole trick behind volatility-normalised risk: the dollar stop moves so the R stays constant.
Channels — has price left its recent envelope?#
A channel wraps price in a band; a break of the band is the event. The Donchian channel is the simplest — the highest high and lowest low of the last bars — and is the engine behind breakout systems. Keltner-style envelopes instead centre on a moving average and widen by a multiple of ATR:
A close above the upper band is a “price is escaping its range” signal; the opposite band or the centre line is a natural exit. edgekit ships the Donchian primitive; the ATR-envelope you build from ema + atr.
up, lo = ek.indicators.donchian(bars.high, bars.low, 20) # breakout channel
mid = ek.indicators.ema(bars.close, 20)
band = mid + 2.0 * ek.indicators.atr(bars.high, bars.low, bars.close, 20)Scenario (the band is the event). The 20-bar Donchian upper on BTCUSDT H4 has been flat at 66,900 for days while price coils beneath it. The 20:00 bar closes at 67,300 — price has escaped the top of its three-day envelope. That single event is the breakout signal; the lower band at 63,100 is the natural opposite-edge stop, so the channel hands you both the trigger and the risk in one primitive.
Mean-reversion — how far from fair is price?#
Mean-reversion features measure distance from a referencein standard-deviation units, so “far” is defined statistically rather than by eyeballing. The rolling z-score is the canonical one:
where and are the trailing mean and standard deviation over bars. A large positive says price is stretched high relative to its recent range — a fade candidate if the series actually reverts. The same z-score is the core of a stat-arb spread signal, applied to the residual of one asset regressed on another rather than to raw price.
z = ek.indicators.zscore(bars.close, 60) # standardised distance from the trailing meanScenario (how far is “far”?).A stock's 60-bar mean is $148 with a standard deviation of $2. Today it prints $153 — a big-looking $5 pop, but that is , genuinely stretched. Now a different, jumpier name has a 60-bar mean of $148 and a standard deviation of $6; the same $153 print is only — barely a ripple. The z-score converts “how many dollars” into “how many typical bars,” which is the only version of “far” a fade should act on — and only if the series actually reverts, which the next chapter tests.
The causality rule: lag every feature#
Here is the rule that everything hinges on, and the one most retail backtests get wrong. Every indicator in edgekit is returned un-lagged. Index position holds the value computed through bar inclusive — a number you only truly know once bar has closed. A rule that reads it while deciding what to do on bar is peeking at the current, completed bar. The fix is a one-bar shift with edgekit.core.lag:
import edgekit as ek
from edgekit.core import lag
# WRONG — reads bar i's own completed ATR while acting on bar i (look-ahead)
N_bad = ek.indicators.atr(bars.high, bars.low, bars.close, 20)
# RIGHT — lag by one bar so N[i] reflects only what was known at the close of i-1
N = lag(ek.indicators.atr(bars.high, bars.low, bars.close, 20), 1)
up, _ = ek.indicators.donchian(bars.high, bars.low, 20)
up_prev = lag(up, 1) # a breakout on bar i is measured against the PRIOR channeledgekit keeps the lag at the call site on purpose. Baking a shift into each indicator would hide the single most consequential modelling decision you make; leaving it explicit means every strategy's prepare method visibly declares which bar of information it acts on. The habit costs one function call and buys you a backtest that is not lying to you.
Scenario (a one-line leak worth 20% a year).A researcher backtests “buy when the RSI(2) crosses up through 10” on ETHUSDT and gets a dazzling curve. The bug: the un-lagged RSI at bar is computed through bar 's close, so the rule is really buying bars that already closed with a low RSI — it is peeking at the close it claims to act before. Add lag(..., 1) and the entry now keys off the RSI known at the prior close; the curve loses most of its gain. Nothing about the idea changed — only whether the backtest was allowed to see the future.
Features track regimes — which is the point and the trap#
A good feature is informative precisely because it moves as the market's regime moves: ATR expands in stress, ADX rises in trends, z-scores blow out in dislocations. That responsiveness is what a rule exploits — and also why a single fixed threshold rarely holds across regimes. Volatility-normalised features (anything divided by ATR) travel across regimes better than raw-price ones, which is why R-multiples and ATR stops recur everywhere in edgekit.

From feature to strategy#
Everything here plugs into the prepare method from the previous chapter: compute the features you need, lag them, stash them in the P dict, and let entry/exit read them by index. What differs between a trend-follower, a breakout, and a mean-reverter is not the indicator library — it is which features they read and which direction they trade the reading. That is the map the next chapter draws.
Next: A taxonomy of strategies — the archetypes these features feed, what each one exploits, and when it fails.
