The Triple-Barrier Method and Meta-Labeling
Labeling is the most under-appreciated step in machine learning for trading. Everyone obsesses over features and models, but how you define "what the model should predict" silently determines whether the whole exercise is even sensible. The naive fixed-time-horizon label — "what is the return exactly H days from now?" — ignores how trades actually work, where a position is closed by a profit target or a stop, not by a calendar. The triple-barrier method fixes this, and meta-labeling builds on it to decide whether and how big to trade. These ideas, popularized by Marcos López de Prado, are among the most practical in applied quant ML.
The labeling problem
To train a supervised model you need labels y. The default choice is the fixed-horizon return sign: y = sign(price[t+H] / price[t] - 1). This is convenient and almost always wrong for trading, because:
- It ignores the path. A position that would have hit your stop on day 2 is labeled
by the day-H outcome, as if you held through a drawdown you never would have tolerated.
- It uses a fixed horizon regardless of volatility. The same H means something very
different in a calm market versus a turbulent one.
- It produces labels that do not match the trade you would actually take, so the
model optimizes the wrong objective.
A good label should answer the real question: if I enter now and manage the position with a profit target, a stop loss, and a maximum holding time, what happens first?
The triple-barrier method
The triple-barrier method places three barriers around each entry and labels the observation by whichever is touched first:
- Upper barrier — a profit-taking level (often a volatility multiple above entry).
- Lower barrier — a stop-loss level (a volatility multiple below entry).
- Vertical barrier — a maximum holding period (time limit).
If the upper barrier is hit first, label +1; lower first, -1; if the vertical barrier (time) is reached first, label by the sign of the return at expiry (or 0 for a flat outcome). Critically, the horizontal barriers are usually scaled by recent volatility, so the label adapts to regime — a fixed dollar target would be trivially easy to hit in a volatile market and nearly impossible in a quiet one. Measuring volatility well is therefore a prerequisite.
import numpy as np
import pandas as pd
def triple_barrier_labels(price, events, pt=2.0, sl=2.0, vol=None, max_h=10):
"""
price : pd.Series of prices indexed by time
events: pd.Index of entry timestamps
pt, sl: profit-take / stop-loss as multiples of vol
vol : pd.Series of per-bar volatility (e.g. EWMA of returns)
max_h : vertical barrier in bars
"""
out = pd.DataFrame(index=events, columns=["label", "t_touch"])
for t0 in events:
loc = price.index.get_loc(t0)
path = price.iloc[loc: loc + max_h + 1]
if len(path) < 2:
continue
ret = path / path.iloc[0] - 1.0
up = pt * vol.loc[t0]
dn = -sl * vol.loc[t0]
hit_up = ret[ret >= up].index.min()
hit_dn = ret[ret <= dn].index.min()
t_vert = path.index[-1]
# earliest touch among the three barriers
touches = {1: hit_up, -1: hit_dn, 0: t_vert}
touches = {k: v for k, v in touches.items() if pd.notna(v)}
first = min(touches, key=lambda k: touches[k])
t_touch = touches[first]
label = first if first != 0 else int(np.sign(ret.loc[t_vert]))
out.loc[t0] = [label, t_touch]
return out
| Labeling scheme | Captures path? | Vol-adaptive? | Matches real trade? |
|---|---|---|---|
| Fixed-horizon return sign | No | No | No |
| Fixed-horizon, vol-scaled threshold | No | Partly | Partly |
| Triple-barrier | Yes | Yes | Yes |
Sample weights for overlapping labels
There is a catch. Because each label looks forward up to max_h bars, labels from nearby entries overlap in time — they depend on many of the same future returns. This violates the i.i.d. assumption most models implicitly make and effectively overcounts redundant information.
The fix is to weight each sample by its uniqueness — roughly, the inverse of how many other labels share its time span — and/or by the absolute return attributed to it. Concretely, you compute the number of concurrent labels at each bar, then assign each label an average uniqueness over its lifespan:
def average_uniqueness(t_touch, index):
# count overlapping labels at each bar, then average over each label's span
concurrency = pd.Series(0, index=index)
for t0, t1 in t_touch.items():
concurrency.loc[t0:t1] += 1
weights = {}
for t0, t1 in t_touch.items():
weights[t0] = (1.0 / concurrency.loc[t0:t1]).mean()
return pd.Series(weights)
w = average_uniqueness(labels["t_touch"], price.index)
model.fit(X, y, sample_weight=w.loc[X.index])
Without these weights, your model treats 1,000 heavily-overlapping labels as 1,000 independent observations, dramatically overstating its effective sample and confidence. This connects directly to why time-series cross-validation needs purging and embargo — overlapping labels leak across train/test boundaries.
Meta-labeling
Meta-labeling separates two decisions that fixed labels jam together: side (long or short) and size/whether to act. The architecture is two stages:
- Primary model decides the side. This can be anything — a
trend-following rule, a mean-reversion signal, or another ML model. Tune it for high recall: catch the opportunities, accept false positives.
- Meta-model is a binary classifier that, conditional on the primary model firing,
predicts whether that specific trade will be a winner (1) or not (0). Its probability output becomes a confidence score used to filter weak signals and size strong ones.
# Stage 1: primary signal generates side in {-1, 0, +1}
side = primary_model_signal(features) # e.g. a momentum rule
# Stage 2: label each fired trade as win/loss via triple-barrier outcome
mask = side != 0
meta_y = (labels["label"].loc[mask] == side.loc[mask]).astype(int)
meta_X = features.loc[mask]
from sklearn.ensemble import RandomForestClassifier
meta = RandomForestClassifier(n_estimators=300, max_depth=4,
min_samples_leaf=50, n_jobs=-1, random_state=42)
meta.fit(meta_X, meta_y, sample_weight=w.loc[meta_X.index])
# confidence -> position size (e.g. via Kelly-style or proportional scaling)
conf = meta.predict_proba(features)[:, 1]
position = side * np.where(conf > 0.5, conf, 0.0)
The elegance is that meta-labeling never overrides the side; it only decides how much to trust it. A high-recall primary model plus a precise meta-model often beats a single model trying to do everything, and the confidence score plugs naturally into position sizing. It also improves interpretability: you can inspect what conditions make the primary signal reliable.
Common pitfalls
- Fixed-horizon labels that ignore the trade path and volatility regime.
- Forgetting sample weights, so overlapping labels inflate the effective sample and
model confidence.
- Leakage through barriers — using future volatility to set barriers, or letting the
vertical barrier extend past your data and silently truncating outcomes.
- Letting the meta-model pick the side; its job is filtering and sizing, not
direction.
- Shuffled cross-validation on overlapping labels — you must purge and embargo.
- Over-tuning barrier multiples on the same data until the backtest looks great.
- Ignoring costs — a triple-barrier label that wins by less than the round-trip
transaction cost is not a real win.
Key takeaways
- How you label data determines what the model optimizes; fixed-horizon return signs
rarely match real trades.
- The triple-barrier method labels by which of profit-take, stop-loss, or time is
touched first, with vol-scaled horizontal barriers.
- Overlapping labels break i.i.d. assumptions — use uniqueness-based sample weights
and purged, embargoed cross-validation.
- Meta-labeling splits side (primary model) from confidence/size (meta-model),
improving filtering and sizing.
- Feed both stages good features and use
tree models like random forests for the meta-model.
- Always validate on net, after-cost performance, and let meta-confidence drive
