edgekit

Feature engineering

A model or a rule never sees a price chart — it sees a table of numbers. Feature engineering is the craft of building that table: turning raw OHLC into columns that summarise the market's state at each bar, using only information that was actually available at the time. Get the transforms right and a simple model can find an edge; get the timing wrong — leak one bar of the future — and you get a beautiful backtest that is pure fiction. This chapter covers both.

What a feature is#

A featureis a single derived number attached to a bar that describes something about the market's state at that moment: how fast it's moving, how volatile it is, how far it has stretched from its average, what day of the week it is. One feature is a column; the full set is a matrix with one row per bar and one column per feature — the raw material every rule and every model consumes.

The raw OHLC bar is a poor feature on its own. “Close = 41,382” means nothing without context — it's a level, and levels are non-stationary (the lesson of the previous chapter). Good features are almost always transforms that strip the level out and expose the state: a return, a ratio, a z-score, a count. edgekit packages the common ones into a single call, make_features, which turns an OHLC frame into a ready feature matrix:

import edgekit as ek

X = ek.timeseries.make_features(bars, windows=(5, 20, 60), lags=(1, 2, 3, 5), calendar=True)
X = X.dropna()   # trim the warm-up rows where the longest window hasn't filled

The columns it builds fall into a handful of families — the same families from the indicators chapter, now as model inputs:

  • Returns — the stationary base transform; the raw change the model actually predicts against.
  • Rolling volatility — how big a normal move is right now, over each of the windows.
  • Momentum — trailing return over each window; speed and direction of the recent drift.
  • RSI — bounded momentum/exhaustion oscillator (reuses indicators.rsi).
  • ATR% — average true range as a fraction of price, so volatility is comparable across price levels (reuses indicators.atr).
  • Distance-from-MA z-score — how many standard deviations price sits from its moving average (reuses indicators.zscore).
  • Range% — the bar's high-low range relative to price; an intrabar volatility read.
  • Calendar — day-of-week / month dummies, when calendar=True.
  • Lagged returns — returns at each lag in lags, giving the model short-horizon memory.

Because it reuses the indicator primitives, a feature named for an indicator means exactly what that indicator means — there is no second, subtly-different implementation to reconcile.

Scenario: one bar as the model sees it

A model never sees “BTCUSDT ripped on Tuesday.” It sees one row of numbers. Take the H4 bar that closed 2024-03-05 12:00 UTC at 66,800 after a strong push. After make_features (and after the built-in one-bar shift), the row handed to the model for the next bar looks roughly like:

ret        0.011     # last completed bar was +1.1%
vol_20     0.018     # 20-bar return stdev ~1.8%
mom_20     0.064     # trailing 20-bar momentum +6.4% (uptrend)
rsi_14     71.2      # momentum hot, near overbought
atr_pct    0.021     # ATR is 2.1% of price
z_ma_60    2.30      # price sits 2.3 sigma above its 60-bar mean (stretched)
range_pct  0.015     # this bar's high-low span was 1.5% of price
dow_1      1         # calendar dummy: it's a Tuesday

That is the whole market state compressed to eight numbers, every one stationary and every one knowable at the prior close. A rule reads two or three of them (“mom_20 > 0 and rsi_14 < 70”); a model reads all of them at once. Note the tension already visible in this row: momentum is up (trend says buy) but z_ma_60 is +2.3 (reversion says stretched) — the same row argues both ways, which is exactly the interaction a model is there to weigh.

Causality & leakage — the cardinal sin#

This is the one that matters more than every clever transform combined. A feature must be computable from past information only. If any column at row contains information that was not knowable until after bar had closed, you have look-ahead bias— the model is trained to “predict” the future using the future, and the edge evaporates the instant you go live.

The trap is subtle because most leakage is accidental. An indicator computed through the current bar, a rolling statistic fit over the whole sample, a label built from a forward return that quietly overlaps the features — each leaks the future into the past without any obvious error. The defence is a hard rule: every feature is lagged so that row uses only data through bar .

make_features shifts one bar for you — don't undo it

make_features shifts every column by one bar before returning it. Row t holds only information known at the close of bar t-1, so the matrix is safe to feed a rule or a model directly. This is the same causality contract the indicators carry, applied to the whole feature table at once.

The corollary is on you: do not bolt an un-lagged column onto the matrix, and do not un-shift what you were given. The moment one same-bar feature sits beside the causal ones, leakage is back — and it will not show up as an error, only as an out-of-sample collapse. When in doubt, re-read the causality contract.

leakage.py
# WRONG — a same-bar column glued next to the causal matrix leaks bar t into row t
X = ek.timeseries.make_features(bars)
X["close_now"] = bars.close          # <-- not lagged: look-ahead

# RIGHT — keep everything on the causal matrix; add lagged versions only
X = ek.timeseries.make_features(bars)
X = ek.timeseries.add_lags(X, ["ret"], lags=(1, 2, 5))   # stays shifted / causal

add_lags is the low-level primitive behind the lags argument — reach for it when you want lagged copies of a specific signal you built yourself, and trust that it keeps the shift.

Scenario: the accuracy that was too good

A model on US100 M15 features reports 71% direction accuracy in-sample and a Sharpe that looks like a typo. The culprit is one line: after calling make_features, the researcher appended X["close_now"] = bars.close“just as a reference column.” That column is the currentbar's close — the very quantity the label is derived from — sitting un-shifted beside the causal features. The model learned to read the answer off the reference column. Drop it (or lag it) and accuracy collapses to a realistic 52%. The tell is always the same: a suspiciously good number, and a feature that turns out to know something it could not have known at decision time.

Normalisation & scaling#

Even causal features vary wildly in scale: a return is , an RSI runs 0–100, a volume can be in the millions. Many models — and any distance- or gradient-based method — behave badly when inputs live on wildly different scales, because the large-magnitude feature dominates purely by units. The standard fix is the z-score, which recentres and rescales each feature to comparable units:

The critical detail is that and must be trailing(rolling) estimates, not full-sample constants — a full-sample mean uses future data to standardise the past, which is leakage wearing a statistician's coat. That is exactly why make_features builds its distance-from-MA feature as a rolling z-score.

Prefer stationary features over raw levels for the same reason a return beats a price: a level the model learned to key off in-sample may sit in a completely different range out-of-sample, and the rule silently breaks. Ratios, returns, and z-scores travel across regimes; raw prices and raw volumes do not.

Rolling feature statistics drifting over time
A rolling statistic drifts with the regime — which is what makes it informative and what makes fixed thresholds fragile. Standardising against a trailing window keeps a feature comparable across calm and stressed periods.

How many features? Fewer, with a reason#

It is tempting to throw every window and every lag at the model and let it sort them out. Resist it. Each extra feature is another dimension in which the model can memorise noise, and the number of spurious patterns available to fit grows faster than the signal. This is the curse of dimensionality in its practical form: more features, more ways to overfit, worse out-of-sample.

The discipline that beats it is economic rationale, not statistical fishing:

  • Prefer features with a reason to work. A momentum feature earns its place because trend-persistence is a documented effect; a feature that merely correlated with returns in your sample has no such backing and usually won't survive.
  • Drop redundant features. Two features with a correlation near 1 add dimensions without adding information. Check a correlation matrix and keep one of each cluster.
  • Distrust in-sample importance. A model will happily rank a leaked or noise feature as “important”. Feature importance describes the fit you have, not the edge you'll keep — confirm it survives out-of-sample before you believe it.
!A feature that helps in-sample and hurts out-of-sample was noise
The honest test of a feature is whether adding it improves out-of-sample performance in a walk-forward. In-sample, almost any feature helps — that is the definition of overfitting. Add features one at a time, keep only those that pay off on unseen data, and prefer the smaller set when two perform equally.

From features to decisions#

The feature matrix is the fork in the road. Two things can read it.

Features to rules#

A hand-written strategy reads a few features and applies a threshold: go long when momentum is positive andthe RSI isn't already overbought, size the stop off ATR%. The feature is the input; the threshold and direction are the strategy. This is the path of Part III — transparent, few-parameter, and easy to reason about when it breaks.

Features to machine learning#

A model reads all the features and learns the mapping to a label itself, instead of you hand-picking thresholds. That is the ML layer: the ML module ships its own strategy-family feature builder, ek.ml.build_features, tuned for that pipeline, alongside triple-barrier labelling and purged walk-forward. The causality rule does not relax for models — if anything it bites harder, because a flexible model exploits leaked information more aggressively than a two-line rule ever could.

# rule path: read a couple of causal features, threshold them
X = ek.timeseries.make_features(bars)
go_long = (X["mom_20"] > 0) & (X["rsi_14"] < 70)

# ML path: hand the whole (causal) matrix to a model
from edgekit.ml import build_features
Xml = build_features(bars, families=["price", "trend", "macd", "structure"])

Where this leads#

You can now build a causal feature matrix, keep it honest, scale it sensibly, and prune it to the few columns that have a reason to exist. What decides whether those features are read as a trend signal, a fade, a breakout, or a filter is the strategy archetype — and that is a map worth having before you write the first rule.

Next: A taxonomy of strategies — the archetypes these features feed, what each one exploits, and when it fails.