Why are fixed-horizon labels wrong for trading?
A fixed-horizon label (sign of next-day return) ignores the path: a position stopped out on day 2 and a position held profitably for 20 days can share the same 20-day return label. The model then learns to predict a number no trade can actually realize.
Triple-barrier labels encode the trade: from entry time t, the label is +1 if the upper (profit-take) barrier is touched first, −1 if the lower (stop-loss) barrier is touched first, 0 if the vertical (time) barrier expires first. The model predicts trade outcomes under explicit risk control.
Barrier widths scale with volatility (e.g., ±2σ for profit/stop, σ from 20-day EWMA), so labels adapt to regime: wide in stress, tight in calm. Fixed point thresholds cannot do this.
How do you set the three barriers?
Profit-take and stop-loss need not be symmetric. Symmetric (±2σ) suits direction models; asymmetric (+3σ/−1σ) suits trend-following where winners run; (+1σ/−2σ) suits mean-reversion where edge is small and stops are wide. The asymmetry is a strategy choice — document it as a trial (it counts toward N).
The vertical barrier (max holding, e.g., 10 days) forces a decision: labels that never touch a horizontal barrier become 0 (no trade). Without it, every observation is forced long/short and the model never learns patience.
Meta-labelling adds a second stage: the primary model gives direction, a secondary model predicts whether the primary is right (1) or wrong (0) from features available at entry. Position size scales with meta-confidence; low-confidence signals are skipped rather than reversed.
What is the reproducible code?
Vectorized triple-barrier labeller on close prices with volatility-scaled barriers, plus a meta-label stub. Handles the vertical barrier and returns per-row label, touch time, and realized return to touch.
python# triple_barrier.py # Triple-barrier labelling (Lopez de Prado 2018, Ch.3). # Run: python triple_barrier.py import numpy as np import pandas as pd def triple_barrier_labels( close: pd.Series, vol: pd.Series, pt_mult: float = 2.0, sl_mult: float = 2.0, max_holding: int = 10, ) -> pd.DataFrame: """Label each bar by first barrier touch. Returns label/touch_time/ret.""" out = [] prices = close.values index = close.index n = len(close) for i in range(n - 1): entry = prices[i] upper = entry * (1 + pt_mult * vol.iloc[i]) lower = entry * (1 - sl_mult * vol.iloc[i]) end = min(n, i + 1 + max_holding) label, touch, ret = 0, index[min(end - 1, n - 1)], 0.0 for j in range(i + 1, end): hi_lo = (prices[j], prices[j]) # daily bars: use close path # NOTE: with OHLC, check high>=upper then low<=lower per bar. if prices[j] >= upper: label, touch, ret = 1, index[j], (upper - entry) / entry break if prices[j] <= lower: label, touch, ret = -1, index[j], (lower - entry) / entry break else: ret = (prices[end - 1] - entry) / entry out.append((label, touch, ret)) out.append((0, index[-1], 0.0)) return pd.DataFrame(out, columns=["label", "touch_time", "ret"], index=index) if __name__ == "__main__": rng = np.random.default_rng(11) n = 500 dates = pd.date_range("2023-01-01", periods=n, freq="B") close = pd.Series(100 * np.exp(np.cumsum(rng.normal(0, 0.01, n))), index=dates) vol = close.pct_change().ewm(span=20).std().bfill() labels = triple_barrier_labels(close, vol) print(labels["label"].value_counts().to_dict()) print(f"mean |ret| on touched: {labels.loc[labels.label != 0, 'ret'].abs().mean():.4f}")
What mistakes does this prevent?
Using close-only paths when OHLC is available understates barrier touches (intraday excursions trigger real stops). With OHLC data, test high against the upper barrier and low against the lower barrier within each bar, in the correct sequence when both trigger.
Forgetting that the label end time defines the purge boundary: barrier labels with 10-day horizons need 10-day purges in CV. The labeller's touch_time output plugs directly into the PurgedKFold label_ends (see purged-cv asset).
Tuning barrier multiples on the test set. The (pt, sl, horizon) grid is a trial grid — nest it inside purged CV and count it toward N for the DSR.
What are the honest limits?
Barriers encode a trade hypothesis; a bad hypothesis yields clean labels for a bad trade. Labelling quality cannot rescue a strategy with negative expectancy after costs — it only measures the hypothesis honestly.
Volatility scaling assumes the vol estimator is point-in-time. Trailing estimators lag regime breaks; forward-looking vol (options-implied at t is fine, realized-after-t is not) must be strictly timestamped.
Meta-labelling doubles the modelling surface (primary + meta) and therefore doubles overfitting surface. The meta-model needs its own purged validation; a meta-model validated on primary-model training predictions is leakage, not skill.