Machine learning in trading
Most attempts to point machine learning at markets fail the same way: they try to predict the next price, discover the signal is buried under noise, and overfit their way to a beautiful backtest that dies live. There is a narrower, more honest use — meta-labeling: keep a hand-built rule as the primary signal, and train a model only to decide whether to take or skipeach of that rule's trades. This chapter is the Lopez de Prado stack as implemented in edgekit.ml.
import edgekit.ml itself needs only numpy + pandas, but fitting a model requires a backend: pip install "edgekit[ml]" pulls in scikit-learn (for make_model("rf") / make_model("hgb")); xgboost and lightgbm are separate installs. The backends are lazy-imported, so you only pay for what you fit.Forget models for a moment. You already run a breakout rule, and some of its trades are plainly worse than others — the ones that fire into a dead, rangebound afternoon, or a minute before the close. If you had a doorman standing at the entrance who quietly turned away the trades that smell like the past losers and let through the ones that look like the past winners, your average trade would improve without touching the rule at all. That doorman is meta-labeling. The rule still decides which way to bet and when; the model only decides whether this particular firing is worth taking. A filter on a real rule, never a price oracle — that framing is the one place ML has a defensible job in trading.
Where ML actually helps#
The distinction that makes ML work in trading is primary signal versus secondary filter.
- Predicting direction from features— asking a model “will price go up?” This is a near-zero-signal, high-noise regression that overfits almost by construction. Avoid it.
- Meta-labeling an existing rule — let a proven entry rule (say the illustrative
ORBbreakout) generate candidate trades, then train a classifier on “given this rule fired, did the trade win?” The model never picks the direction; it only sizes conviction up or down. The label is grounded in a real, tradeable event, so the problem is well-posed.
Meta-labeling improves precisionat the cost of some recall: you skip the rule's worst-looking trades and keep its best, which raises expectancy and trims drawdown even when the base rule's raw edge is thin.
Features: build_features#
Features are the model's view of the bar at decision time. The one rule that cannot be broken is causality: every feature at bar must use only bars . ek.ml.build_features builds causal feature families for you, aligned to the bar frame, with the higher-timeframe context shifted one completed bar so it never peeks.
build_features(df, families=None, mtf_rule="1h") -> pd.DataFrame
# FAMILIES = ["price","atr","trend","macd","structure","volume","time","mtf","reversal","extra"]from edgekit.ml import build_features
X = build_features(bars, families=["price", "atr", "trend", "macd", "structure"])Triple-barrier labeling#
A meta-label needs a definition of “did this trade win?” The triple-barrier method answers it the way a real trade resolves — by whichever of three barriers price touches first from the entry:
- Upper barrier — the take-profit, at . Touch it first ⇒ label (win).
- Lower barrier — the stop-loss, at . Touch it first ⇒ (loss).
- Vertical barrier — a time limit of bars. If neither price barrier is hit by then, the trade is closed at expiry and labelled by its sign.
In prose, the label geometry looks like this:
price
^
|········································· TP (upper) -> y = 1
| ______
| ____/ \___ entry ---------------------
| / \____
|·······················\················ SL (lower) -> y = 0
+----------------------------------------> time
entry horizon_bars (vertical)The stop distance is set from volatility — — so the barriers adapt to the regime instead of using a fixed number of points. ek.ml.triple_barrier mirrors the backtest engine's fills (entry at the next bar's open, a bar touching both barriers counts as the stop — pessimistic), so labels and backtest agree.
import numpy as np
from edgekit.ml import triple_barrier, LabelConfig
cand_mask = np.zeros(len(bars), dtype=bool)
cand_mask[::5] = True # candidate bars (e.g. where the primary rule fires)
cfg = LabelConfig(rr=2.0, stop_atr_mult=1.0, atr_period=14, horizon_bars=48)
labels = triple_barrier(bars, cand_mask, cfg, cost=0.0)
labels[["dir", "y", "r", "outcome"]].head() # y in {0,1}; r = net R-multiplestop_atr_mult=1.0, rr=2.0, horizon_bars=48. That fixes three lines: a stop at , a take-profit at , and a 48-bar clock. Now walk the tape forward. Price grinds to 18,266, stalls, dips to 18,244 (stop intact), then pushes through 18,274 on bar 31 — the upper barrier is touched first, so this candidate gets label and minus cost. Had the 18,238 stop gone first, ; had neither line been touched by bar 48, the trade is closed at that bar and labelled by its sign. triple_barrierdoes exactly this walk for every candidate bar — and if one bar's range straddles both lines at once, it pessimistically calls it the stop, so the label never flatters what the backtest engine would actually fill.Meta-labeling: the primary signal plus a take/skip model#
The full loop: the primary rule proposes trades, the triple barrier labels them, features describe each, a classifier learns , and you trade only the ones above a threshold. The convention that pays its way is a cut-off of — a small bar above a coin flip is enough to prune the worst trades. ek.ml.MetaLabeler freezes a fitted model into a take/skip gate:
MetaLabeler(blob=None, clf=None, threshold=0.55, feature_names=None)
.proba(X) -> np.ndarray # P(win) per candidate
.take(X) -> np.ndarray # boolean take/skip at the thresholdfrom edgekit.ml import make_model, MetaLabeler
X = build_features(bars).loc[labels["entry_index"]] # features at each candidate's entry
y = labels["y"].to_numpy()
m = make_model("hgb") # HistGradientBoosting (needs sklearn)
m.fit(X, y) # meta-label: did the rule's trade win?
meta = MetaLabeler(clf=m.estimator, threshold=0.55)
take = meta.take(X_live) # take only high-conviction candidatesThe expectancy math is the same as everywhere else: you are trying to lift by raising on the trades you keep. If the filtered trades don't beat the unfiltered ones on out-of-sample expectancy, the model added nothing.
Leakage and purged, embargoed walk-forward#
This is where ML trading lives or dies. Because triple-barrier labels resolve in the future (a trade opened today closes in bars), an ordinary train/test split leaks: a training label whose outcome resolves inside the test window has seen the test period. The fix is two-fold — purge training labels that overlap the test span, and add an embargo gap after it.

ek.ml.walk_forward_windows generates the rolling out-of-sample blocks with the purge and embargo built in; ek.ml.PurgedKFold does the same for hyperparameter selection inside a training window.
horizon_bars=48on M5 US100 — four trading hours. You split train/test at Friday's close. A candidate that fired at 15:40 Friday does not resolveuntil Monday, deep inside the test block — yet its features were computed Friday, so a naive split trains on a label whose outcome lives in the test period. The model has, in effect, read Monday's answer sheet. Purging drops that Friday candidate from training; a six-hour embargo (comfortably the four-hour horizon) also drops the first few Monday candidates whose windows still overlap the seam. Skip this and your OOS AUC will look wonderful for a reason that has nothing to do with an edge — which is exactly what the label-shuffle test below is built to catch.from edgekit.ml import walk_forward_windows, WalkForwardConfig
wf = WalkForwardConfig(train_days=7, test_hours=8, embargo_hours=6) # embargo >= horizon
for w in walk_forward_windows(labels, wf):
tr, te = labels.iloc[w["train_pos"]], labels.iloc[w["test_pos"]]
m = make_model("hgb").fit(X.iloc[w["train_pos"]], tr["y"])
proba = m.predict_proba(X.iloc[w["test_pos"]]) # honest OOS predictions
# tune the take/skip threshold on an inner split of tr — never on teek.ml.best_threshold on an inner validation split of the training window, then apply the frozen threshold on the test block unchanged.The label-shuffle null test#
The single most useful sanity check for a meta-label: permute the labels, refit, and confirm the out-of-sample AUC collapses to ~0.5. If a model trained on shuffled labels still scores well, it is reading the future through a leak in your pipeline — the features or the split, not the market. Real signal dies under the shuffle; leakage survives it.
shuffle_label_gate(make_model_fn, X, y, r=None, n=10, test_frac=0.3, seed=0) -> dict
# returns {mean_auc, aucs, mean_ev} -- mean_auc should sit at ~0.5from edgekit.ml import shuffle_label_gate, make_model
res = shuffle_label_gate(lambda: make_model("hgb"), X, y, n=10)
assert 0.45 < res["mean_auc"] < 0.55, "AUC on shuffled labels != 0.5 -> leakage in the pipeline"Overfitting: the amplified risk#
A hand-built rule has a handful of parameters; a gradient-boosted model has thousands of effective degrees of freedom. That flexibility is exactly what lets it memorise noise. Every overfitting lesson from overfitting detection applies here with the dial turned up: the more the model can fit, the more the in-sample score overstates the truth, and the wider the gap to out-of-sample.

Cloud-safe tree export#
A trained model is useless if you can't run it where the strategy lives. ek.ml.export_trees serialises a fitted HistGradientBoostingClassifier to a plain string blob, and the pure-Python / pure-C# inference generated from it needs no scikit-learn at runtime — it drops into a cTrader cBot. The round-trip is guaranteed to float tolerance:
import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from edgekit.ml import export_trees, tree_predict_proba, emit_csharp
clf = HistGradientBoostingClassifier(max_depth=3, max_iter=40).fit(X, y)
blob = export_trees(clf)
# the guarantee: pure-python inference matches sklearn to 1e-6
assert np.abs(tree_predict_proba(blob, X) - clf.predict_proba(X)[:, 1]).max() < 1e-6
cs = emit_csharp({"orb": blob}, feature_names=feat_names) # -> C# for a cTrader cBotNext: one validated edge is rarely the end — Portfolio construction covers combining low-correlation books so the whole is steadier than any part.

