Triple-Barrier Labeling and Meta-Labeling: A Practical Implementation Guide
Most introductions to machine learning for trading label price data the way a generic classification tutorial would: did the price go up or down N bars later? That labeling scheme ignores path -- a trade that is up 0.1% at bar N but touched a -5% stop-loss at bar 3 is not a winning trade, no matter what the fixed-horizon label says. The triple-barrier method, introduced by Marcos Lopez de Prado in Advances in Financial Machine Learning, labels each observation by whichever of three barriers is touched first: a profit-take, a stop-loss, or a time limit. Meta-labeling then adds a second model on top that answers a narrower, more tractable question than "which direction will price move" -- it answers "should I act on the signal my primary model already gave me." This guide implements both pieces end to end: volatility-scaled triple barriers, side-aware labeling for meta-labeling, and a secondary classifier that filters a primary signal's bets. All code below runs as shown against synthetic price data; the walk-forward and purged cross-validation methodology from the linked guides applies directly to validating the resulting model.Why Fixed-Horizon Labels Fail
The standard "will price be higher in N days" label has two problems specific to trading:- It ignores risk management. A real strategy exits on a stop-loss or a profit target, not on a fixed calendar date. Labeling against a fixed horizon trains the model on an outcome the strategy would never actually hold to.
- It ignores volatility. A 2% move over 5 days is a strong signal in a low-volatility regime and noise in a high-volatility one. A fixed percentage or fixed-bar threshold doesn't adapt.
Step 1: Volatility-Scaled Barriers
``python
import numpy as np
import pandas as pd
def get_daily_vol(close: pd.Series, span: int = 20) -> pd.Series:
"""EWMA volatility of daily returns, used to scale the barriers to
current market conditions rather than a fixed percentage."""
returns = close.pct_change()
return returns.ewm(span=span).std()
`
Using an EWMA of returns rather than a simple rolling standard deviation means the barrier width adapts within a few bars of a volatility regime change, instead of lagging behind a long lookback window.
Step 2: The Vertical (Time) Barrier
`python
def get_vertical_barriers(close: pd.Series, event_times: pd.DatetimeIndex,
max_holding_days: int) -> pd.Series:
"""For each event, the timestamp max_holding_days bars later."""
idx = close.index
loc = idx.searchsorted(event_times) + max_holding_days
loc = loc[loc < len(idx)]
return pd.Series(idx[loc], index=event_times[: len(loc)])
`
This caps how long a "trade" is allowed to stay open before it's labeled by whatever return it has at that point, win or lose. Without a vertical barrier, a position with a very tight profit-take relative to its volatility can sit open indefinitely waiting for a touch that never comes.
Step 3: Applying the Triple Barrier
`python
def apply_triple_barrier(close, event_times, pt_sl, target, vertical_barriers, min_ret=0.0):
"""
Label each event by whichever barrier is touched first.
Returns a DataFrame indexed by event start time with columns:
t1 - timestamp the barrier was touched
ret - realized return at t1
label - 1 (profit-take), -1 (stop-loss), 0 (time barrier)
"""
out = pd.DataFrame(index=event_times, columns=["t1", "ret", "label"])
pt_mult, sl_mult = pt_sl
for t0 in event_times:
if t0 not in target.index or pd.isna(target.loc[t0]) or target.loc[t0] < min_ret:
continue
t1 = vertical_barriers.get(t0, close.index[-1])
path = close.loc[t0:t1]
if len(path) < 2:
continue
path_returns = (path / path.iloc[0] - 1.0)[1:]
pt_level = pt_mult * target.loc[t0]
sl_level = -sl_mult * target.loc[t0]
pt_hits = path_returns[path_returns > pt_level].index
sl_hits = path_returns[path_returns < sl_level].index
first_pt = pt_hits[0] if len(pt_hits) else pd.NaT
first_sl = sl_hits[0] if len(sl_hits) else pd.NaT
candidates = [ts for ts in (first_pt, first_sl, t1) if pd.notna(ts)]
touch_time = min(candidates)
realized_ret = close.loc[touch_time] / close.loc[t0] - 1.0
if touch_time == first_pt:
label = 1
elif touch_time == first_sl:
label = -1
else:
label = 0
out.loc[t0] = [touch_time, realized_ret, label]
return out.dropna(subset=["t1"])
`
To reproduce the numbers below, generate 500 business days of synthetic prices with a fixed seed and run the functions above:
`python
rng = np.random.default_rng(7)
idx = pd.bdate_range("2022-01-03", periods=500)
close = pd.Series(100 * np.exp(np.cumsum(rng.normal(0.0003, 0.012, 500))), index=idx)
vol = get_daily_vol(close)
events = close.index[20:-10:5] # every 5th bar as an event
vb = get_vertical_barriers(close, events, max_holding_days=10)
labels_tb = apply_triple_barrier(close, vb.index, (2.0, 2.0), vol, vb)
print(labels_tb["label"].value_counts())
`
With profit-take and stop-loss both at 2.0x the EWMA volatility, this labels 94 events: 31 profit-take (33%), 46 stop-loss (49%) and 17 time-barrier (18%). The lopsided split is sampling noise, not a property of the method: with only 94 events, the standard error on each share is about 5 percentage points, and a different seed gives a different split. The properties worth checking on your own data are structural. Every t1 is at or after its event's start time (it was here), and every label is in {-1, 0, 1} (it was here).
Step 4: Meta-Labeling
Triple-barrier labels alone answer "what happened next." Meta-labeling uses them to answer a different, more useful question: given that a primary model already said "go long" or "go short," was that specific bet a good one? This turns a hard three-class (or worse, continuous-return) prediction problem into an easier binary one -- and, critically, it separates direction (the primary model's job) from confidence (the secondary model's job), so you can size or skip bets without touching the signal that generates them.
`python
def label_with_side(close, side, vol, pt_sl=(1.5, 1.5), max_hold=10):
"""
side: a Series of +1/-1/0, the primary model's direction call at each bar.
Barriers are signed by side so profit-take is in the primary model's
intended direction and stop-loss is against it.
"""
records = []
idx = close.index
for i in range(20, len(idx) - max_hold):
t0 = idx[i]
s = side.loc[t0]
if s == 0 or pd.isna(vol.loc[t0]):
continue
window = close.iloc[i : i + max_hold + 1]
path_ret = s * (window / window.iloc[0] - 1.0)[1:]
pt_level = pt_sl[0] * vol.loc[t0]
sl_level = -pt_sl[1] * vol.loc[t0]
pt_hit = path_ret[path_ret > pt_level]
sl_hit = path_ret[path_ret < sl_level]
t_pt = pt_hit.index[0] if len(pt_hit) else None
t_sl = sl_hit.index[0] if len(sl_hit) else None
candidates = [t for t in (t_pt, t_sl) if t is not None]
if candidates:
touch = min(candidates)
meta_label = 1 if touch == t_pt else 0
else:
touch = window.index[-1]
meta_label = 1 if path_ret.iloc[-1] > 0 else 0
records.append((t0, side.loc[t0], meta_label))
return pd.DataFrame(records, columns=["t0", "side", "meta_label"]).set_index("t0")
`
The secondary model's features should describe confidence in the existing signal, not direction -- direction already comes from the primary model:
`python
from sklearn.ensemble import RandomForestClassifier
features = pd.DataFrame(index=close.index)
features["vol_20"] = vol
features["mom_5"] = close.pct_change(5)
features["mom_20"] = close.pct_change(20)
features["dist_from_ma50"] = close / close.rolling(50).mean() - 1.0
data = features.reindex(labels.index).join(labels[["side", "meta_label"]]).dropna()
X = data[["vol_20", "mom_5", "mom_20", "dist_from_ma50", "side"]]
y = data["meta_label"]
clf = RandomForestClassifier(n_estimators=200, max_depth=4, min_samples_leaf=20, random_state=7)
Split in time order -- never shuffle, labels overlap across neighbouring rows
cut = int(len(data) * 0.7)
X_train, X_test = X.iloc[:cut], X.iloc[cut:]
y_train, y_test = y.iloc[:cut], y.iloc[cut:]
clf.fit(X_train, y_train)
proba = clf.predict_proba(X_test)[:, 1]
Bet sizing from meta-probability: skip low-confidence bets entirely
instead of taking every signal the primary model produces.
take_bet = proba > 0.55
`
Here is what that produces on the same kind of synthetic random walk, where no real edge exists. The primary signal is the sign of the trailing 10-day return, with 1.5x volatility barriers and a 10-day limit. On the seed-7 series above, 470 labeled bets had a 48.3% win rate when every signal was taken. The meta-model was trained on the first 70% and tested on the last 30%: 133 test bets with a 47.4% baseline win rate, 17 of them approved at a 0.55 threshold, and those 17 won 58.8% of the time. That looks like a lift, but 17 bets is far too few to mean anything, so I repeated the whole experiment on 20 different random series. Averaged across them, the baseline win rate was 49.3% and the approved bets won 52.6%, with approved bets beating the baseline in 11 of 20 runs, about 37 approved bets per run. A 3-point average lift, on data with no edge, with a coin-flip count of winning seeds, is what noise looks like. Take it as a calibration: expect roughly this much "improvement" from pure chance, and demand a clearly larger, repeatable lift on held-out real data before believing the meta-model has found anything. If it doesn't clear that bar, skipping the meta-model is the honest conclusion.
Validating the Meta-Model Without Leaking
Because the label at t0 depends on price data up to t1 (potentially several bars later), the label windows for adjacent events overlap. Standard k-fold cross-validation will leak information across folds through this overlap. Use purged k-fold with an embargo -- covered in depth in the cross-validation guide -- rather than plain sklearn.model_selection.KFold`, and validate the whole pipeline (primary signal, then meta-model) with walk-forward analysis before trusting any out-of-sample number. You can prototype the primary signal's raw entries/exits in our Strategy Builder before wiring up the meta-labeling layer around it.
Key Takeaways
- Triple-barrier labeling assigns each observation to a profit-take, stop-loss, or time-limit outcome, scaled by rolling volatility -- a closer match to how a real position actually exits than a fixed-horizon up/down label.
- Meta-labeling separates direction (primary model) from confidence (secondary model), turning "which way will price move" into the more tractable "should I act on this specific signal."
- The secondary model's features should describe market conditions and signal context, not attempt to re-derive direction the primary model already provides.
- Label windows overlap in time, which leaks information under standard k-fold cross-validation -- use purged k-fold with an embargo instead.
- A meta-model that doesn't lift the win rate among the bets it approves, relative to taking every primary signal, isn't adding information -- verify this on held-out data before adding the complexity to a live system.