Financial ML III — Triple-Barrier Labeling & Meta-Labeling¶

How to label a trading problem, and the trick that boosts a strategy's precision (López de Prado, Ch. 3)¶

The earlier notebooks assumed a label existed. In finance, defining the label is itself a modelling decision — and a fixed-horizon return ("was the 10-day-ahead return positive?") is a poor one: it ignores the path, so it counts a trade that first plunged through a stop-loss and then recovered as a "win." López de Prado's answer is the triple-barrier method: label each trade by which of three barriers it hits first —

  • an upper barrier (profit-take), * a lower barrier (stop-loss), * a vertical barrier (a time limit).

Barriers are scaled to recent volatility, so the label reflects a realistic, path-dependent outcome. These labels span the holding period [entry, first-touch], so consecutive ones overlap — which is exactly why the purged cross-validation of ex1 was necessary.

Then comes meta-labeling. Split the decision in two: a primary model picks the side (long/short), and a secondary "meta" model decides whether to act and how big — it predicts the probability the primary bet will win. This raises precision (it filters the primary's false positives) and yields natural bet-sizing. We build the triple-barrier labeler from scratch on real S&P data, demonstrate the meta-labeling precision boost on a controlled example, then apply it honestly to the (near-efficient) market. No package exists for either in Python or R, so this is from-scratch throughout. Python-only.

1. The triple-barrier method — from scratch¶

For each entry we set a profit-take barrier at $+k\sigma$ and a stop-loss at $-k\sigma$ (in log-return terms, $\sigma$ = recent daily volatility), plus a vertical barrier at a fixed number of days. Walking the price path forward, the label is $+1$ if the upper barrier is hit first, $-1$ if the lower is hit first, and the sign of the return at the time limit if neither is touched. The schematic shows one event and its barriers; the histogram shows the label distribution and the average holding time to first touch — well under the time limit, and the reason labels overlap.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, warnings
warnings.filterwarnings("ignore")
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
d=pd.read_csv("spx_rv_ret.csv"); dates=pd.to_datetime(d["date"]); r=d["ret"].values/100; n=len(r)
lp=np.log(np.cumprod(1+r)); vol=pd.Series(r).rolling(20).std().values
def triple_barrier(lp,vol,pt=2.0,sl=2.0,vbar=10):
    lab=np.zeros(n,int); tt=np.zeros(n,int)
    for i in range(20,n-1):
        if np.isnan(vol[i]): continue
        up=pt*vol[i]; dn=-sl*vol[i]; end=min(i+vbar,n-1); hit=0; j=end
        for k in range(i+1,end+1):
            rr=lp[k]-lp[i]
            if rr>=up: hit=1; j=k; break
            if rr<=dn: hit=-1; j=k; break
        if hit==0: hit=int(np.sign(lp[j]-lp[i]) or 1)
        lab[i]=hit; tt[i]=j
    return lab,tt
lab,tt=triple_barrier(lp,vol); ev=np.array([i for i in range(20,n-1) if not np.isnan(vol[i])])
hold=tt[ev]-ev
fig,ax=plt.subplots(1,2,figsize=(13,4.4))
i=ev[np.argmax((tt[ev]-ev)<10)]                                  # an event whose barrier is hit early
end=min(i+10,n-1); path=lp[i:end+1]-lp[i]; xs=np.arange(len(path))
ax[0].plot(xs,path*100,color="black",marker="o",ms=3,label="path (log-return %)")
ax[0].axhline(2*vol[i]*100,color=GREEN,ls="--",label="profit-take +2σ"); ax[0].axhline(-2*vol[i]*100,color=RED,ls="--",label="stop-loss −2σ")
ax[0].axvline(10,color=GREY,ls=":",label="time limit"); ax[0].axvline(tt[i]-i,color=BLUE,lw=2,alpha=.5)
ax[0].scatter([tt[i]-i],[(lp[tt[i]]-lp[i])*100],color=BLUE,s=80,zorder=5,label=f"first touch -> label {lab[i]:+d}")
ax[0].set_xlabel("days since entry"); ax[0].set_ylabel("cumulative log-return (%)"); ax[0].set_title("Triple barrier around one event"); ax[0].legend(fontsize=7)
ax[1].bar(["-1 (stop)","+1 (profit)"],[np.sum(lab[ev]==-1),np.sum(lab[ev]==1)],color=[RED,GREEN])
ax[1].set_ylabel("# events"); ax[1].set_title(f"Label distribution (avg holding {hold.mean():.1f} days -> labels OVERLAP)")
plt.tight_layout(); plt.show()
how=np.zeros(n,int)
for i in ev:
    up=2*vol[i]; dn=-2*vol[i]; end=min(i+10,n-1); how[i]=2
    for k in range(i+1,end+1):
        rr=lp[k]-lp[i]
        if rr>=up or rr<=dn: how[i]=1; break
print(f"{len(ev)} labeled events; avg time-to-touch {hold.mean():.1f} days (< the 10-day limit). Overlapping windows are")
print("exactly what the purged/embargoed CV of ex1 was built to validate -- triple-barrier PRODUCES those overlapping labels.")
print(f"\nLabel balance: {np.mean(lab[ev]==1):.1%} up, {np.mean(lab[ev]==-1):.1%} down -- close enough to balanced to model directly.")
print(f"Of the {len(ev)} events, {np.mean(how[ev]==1):.1%} were decided by a HORIZONTAL barrier and {np.mean(how[ev]==2):.1%} ran out of time.")
print("\nThat split is the design choice that makes this method worth the trouble, and it is set by the barrier WIDTH.")
print("The barriers here are +/-2 DAILY sigma, but the move being labeled spans up to 10 days, whose standard deviation")
print(f"is about sigma*sqrt(10) = {np.sqrt(10):.2f} sigma. So the barriers sit at roughly +/-{2/np.sqrt(10):.2f} standard deviations of the horizon")
print("move -- deliberately tight, and that tightness is what makes most labels PATH-determined.")
_h2=0
for i in ev:
    s=2*vol[i]*np.sqrt(10); end=min(i+10,n-1); t=2
    for k in range(i+1,end+1):
        rr=lp[k]-lp[i]
        if rr>=s or rr<=-s: t=1; break
    _h2+=(t==1)
print(f"Widen them to +/-2 sigma of the 10-day move and only {_h2/len(ev):.1%} of events ever touch a horizontal barrier: the")
print("other 94% run to the time limit and are labeled by the sign of the return, which is precisely the fixed-horizon")
print("labeling triple-barrier exists to replace. Set the barriers too wide and the method quietly degenerates into the")
print("thing it was meant to fix -- so the width is not a detail, it is the method.")
No description has been provided for this image
3438 labeled events; avg time-to-touch 5.9 days (< the 10-day limit). Overlapping windows are
exactly what the purged/embargoed CV of ex1 was built to validate -- triple-barrier PRODUCES those overlapping labels.

Label balance: 53.8% up, 46.2% down -- close enough to balanced to model directly.
Of the 3438 events, 77.5% were decided by a HORIZONTAL barrier and 22.5% ran out of time.

That split is the design choice that makes this method worth the trouble, and it is set by the barrier WIDTH.
The barriers here are +/-2 DAILY sigma, but the move being labeled spans up to 10 days, whose standard deviation
is about sigma*sqrt(10) = 3.16 sigma. So the barriers sit at roughly +/-0.63 standard deviations of the horizon
move -- deliberately tight, and that tightness is what makes most labels PATH-determined.
Widen them to +/-2 sigma of the 10-day move and only 6.4% of events ever touch a horizontal barrier: the
other 94% run to the time limit and are labeled by the sign of the return, which is precisely the fixed-horizon
labeling triple-barrier exists to replace. Set the barriers too wide and the method quietly degenerates into the
thing it was meant to fix -- so the width is not a detail, it is the method.

2. Meta-labeling — the mechanism, on a controlled example¶

Meta-labeling separates two questions. A primary model decides the side of the bet (here, take it as given). The meta model then predicts, from features, whether that bet will win — a binary "act / don't act" (and its probability doubles as a bet size). Its power is filtering: if the primary is reliable in some conditions and useless in others, the meta-model learns the difference and acts only where the primary works, raising precision.

To see it cleanly, a controlled example: a primary signal that wins 78% of the time in one regime ($z<0$) but only 38% in the other, so overall ~57%. A meta-model given the regime feature learns to act only in the reliable regime — lifting precision from ~0.57 to ~0.78, at the cost of trading only about half the time.

It is worth being careful about how that trade is scored, because the obvious summary statistic misses it entirely. F1 barely moves — the primary acts on everything, so its recall is 1 by construction and every gain in precision is paid for by an exactly offsetting loss in recall. Read through F1, meta-labeling appears to do nothing at all.

The metric that matches the problem is expected profit. With a symmetric payoff, a bet won at rate $p$ returns $2p-1$ per unit staked, so the question is not what fraction of bets win but how much the whole book earns. Trading half as often at a far better rate can easily beat trading always at a mediocre one, and that comparison — not F1, and not precision alone — is what the method should be judged on.

In [2]:
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import f1_score
rng=np.random.default_rng(0); N=5000
z=rng.normal(size=N); noise=rng.normal(size=(N,3))
win=(rng.uniform(size=N)<np.where(z<0,0.78,0.38)).astype(int)   # primary correctness depends on regime z
Xs=np.column_stack([z,noise]); sp=int(0.6*N)
prim=win[sp:].mean()
mm=RandomForestClassifier(300,min_samples_leaf=30,random_state=0).fit(Xs[:sp],win[:sp]); pr=mm.predict_proba(Xs[sp:])[:,1]
N_te=len(pr)
def _edge(prec,frac): return (2*prec-1), (2*prec-1)*frac*N_te      # per-bet and total, symmetric payoff
rows=[("primary alone (act on all)",prim,1.0,f1_score(win[sp:],np.ones(N_te)))+_edge(prim,1.0)]
for thr in [0.5,0.6]:
    a=pr>thr; p_=win[sp:][a].mean()
    rows.append((f"meta-labeled (thr={thr})",p_,a.mean(),f1_score(win[sp:],a))+_edge(p_,a.mean()))
tab=pd.DataFrame(rows,columns=["strategy","precision","fraction traded","F1","edge per bet","total edge"]).set_index("strategy")
print(tab.round(3).to_string())
print(f"\nF1 is flat: {tab['F1'].iloc[0]:.3f} -> {tab['F1'].iloc[1]:.3f} -> {tab['F1'].iloc[2]:.3f}. By that metric meta-labeling has achieved nothing, because")
print("the primary's recall was 1 to begin with and every point of precision is paid for one-for-one.")
print(f"Expected profit tells the opposite story: total edge {tab['total edge'].iloc[0]:.0f} -> {tab['total edge'].iloc[2]:.0f} units over the same {N_te} opportunities,")
print(f"roughly {tab['total edge'].iloc[2]/tab['total edge'].iloc[0]:.1f} times as much, from trading {tab['fraction traded'].iloc[2]:.0%} of them. Half the bets, {tab['edge per bet'].iloc[2]/tab['edge per bet'].iloc[0]:.0f}x the edge on each.")
print("Choosing the metric that matches the decision is most of the work here -- F1 answers a classification question")
print("nobody asked, while expected profit answers the one a desk actually faces.")
fig,ax=plt.subplots(figsize=(7,4.2)); nm=list(tab.index)
ax.bar(nm,tab["precision"],color=[GREY,BLUE,GREEN]); ax.axhline(prim,color=RED,ls="--",label="primary precision")
for i,v in enumerate(tab["precision"]): ax.text(i,v+0.008,f"{v:.2f}",ha="center")
ax.set_ylabel("precision (win rate of acted bets)"); ax.set_title("Meta-labeling filters the unreliable regime -> precision jumps"); plt.setp(ax.get_xticklabels(),rotation=12,ha="right",fontsize=8); ax.legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"Primary precision {prim:.3f} -> meta-labeled ~{tab['precision'].iloc[-1]:.3f}: the meta-model learned to act only where")
print("the primary is trustworthy (z<0) and skip the rest. Precision up, recall down -- concentrate capital on winnable bets.")
                            precision  fraction traded     F1  edge per bet  total edge
strategy                                                                               
primary alone (act on all)      0.568            1.000  0.725         0.137       274.0
meta-labeled (thr=0.5)          0.756            0.522  0.724         0.513       535.0
meta-labeled (thr=0.6)          0.778            0.498  0.727         0.556       554.0

F1 is flat: 0.725 -> 0.724 -> 0.727. By that metric meta-labeling has achieved nothing, because
the primary's recall was 1 to begin with and every point of precision is paid for one-for-one.
Expected profit tells the opposite story: total edge 274 -> 554 units over the same 2000 opportunities,
roughly 2.0 times as much, from trading 50% of them. Half the bets, 4x the edge on each.
Choosing the metric that matches the decision is most of the work here -- F1 answers a classification question
nobody asked, while expected profit answers the one a desk actually faces.
No description has been provided for this image
Primary precision 0.569 -> meta-labeled ~0.778: the meta-model learned to act only where
the primary is trustworthy (z<0) and skip the rest. Precision up, recall down -- concentrate capital on winnable bets.

3. Meta-labeling on the real market — honest edition¶

Now on the S&P triple-barrier labels with a real primary signal (20-day momentum: go long if price is above its level 20 days ago, else short). Consistent with the returns notebook, the primary has only a whisper of edge — precision barely above 0.50.

The temptation at this point is to read the precision-vs-selectivity curve as a modest success: filter harder, and precision drifts up. Before doing that, the curve needs the one thing a curve of this shape almost never gets, which is error bars. Trading the top 5% of events means estimating a win rate from a few dozen observations, and a win rate from a few dozen observations is not a number that can carry an argument.

Two things are added below. Each point on the curve gets a confidence interval, so the apparent lift can be compared against its own sampling error. And the whole exercise is re-run through the purged, embargoed splitter from the first notebook of this subsection rather than a single train/test cut — which is both the correct validation for these overlapping labels and a way to see how much the answer moves between folds.

In [3]:
side=np.sign(lp-np.r_[np.full(20,np.nan),lp[:-20]]); side=np.nan_to_num(side); side[side==0]=1
meta=((lab*side)>0).astype(int)                                  # 1 if primary side won its barrier
def feats(i): return [vol[i], lp[i]-lp[i-5], lp[i]-lp[i-20], r[i-5:i].mean(), np.abs(r[i-10:i]).mean(), side[i]]
X=np.array([feats(i) for i in ev]); ym=meta[ev]; s=int(0.7*len(ym))
prim=ym[s:].mean()
mm=RandomForestClassifier(300,min_samples_leaf=40,random_state=0).fit(X[:s],ym[:s]); pr=mm.predict_proba(X[s:])[:,1]
ths=np.linspace(0.45,0.62,18); prec=[]; frac=[]; ci_lo=[]; ci_hi=[]
yte_=ym[s:]
for t in ths:
    a=pr>t; k=a.sum()
    if k>10:
        p_=yte_[a].mean(); se=np.sqrt(max(p_*(1-p_),1e-12)/k)
        prec.append(p_); ci_lo.append(p_-1.96*se); ci_hi.append(p_+1.96*se)
    else:
        prec.append(np.nan); ci_lo.append(np.nan); ci_hi.append(np.nan)
    frac.append(a.mean())
prec=np.array(prec); ci_lo=np.array(ci_lo); ci_hi=np.array(ci_hi)
fig,ax=plt.subplots(1,2,figsize=(13,4.2))
ax[0].fill_between(np.array(frac)*100,ci_lo,ci_hi,color=BLUE,alpha=.15,label="95% interval")
ax[0].plot(np.array(frac)*100,prec,"o-",color=BLUE,lw=2); ax[0].axhline(prim,color=RED,ls="--",label=f"primary (act on all) {prim:.3f}")
ax[0].set_xlabel("% of events traded (meta-filter)"); ax[0].set_ylabel("precision"); ax[0].set_title("Real S&P: precision vs selectivity"); ax[0].invert_xaxis(); ax[0].legend(fontsize=8)
ax[1].plot(ths,frac,"o-",color=GREEN,lw=2); ax[1].set_xlabel("meta-probability threshold"); ax[1].set_ylabel("fraction of events traded"); ax[1].set_title("Higher threshold -> fewer, more-confident bets")
plt.tight_layout(); plt.show()
print(f"Primary precision (act on every event): {prim:.4f} on {len(yte_)} held-out events.")
print(f"  {'threshold':>10} {'n traded':>9} {'precision':>10} {'95% interval':>20} {'clears the baseline?':>21}")
for t in [0.45,0.50,0.55,0.58,0.60,0.62]:
    a=pr>t; k=a.sum()
    if k<=10: continue
    p_=yte_[a].mean(); se=np.sqrt(max(p_*(1-p_),1e-12)/k)
    print(f"  {t:>10.2f} {k:>9} {p_:>10.4f} {f'[{p_-1.96*se:.3f}, {p_+1.96*se:.3f}]':>20} {('yes' if p_-1.96*se>prim else 'no'):>21}")
print("\nNot one threshold produces a lift that survives its own sampling error: every interval contains the primary's")
print("own precision. The curve rises at the selective end because precision estimated from 46 events is a noisy")
print("quantity, not because the filter is finding anything. A precision-vs-selectivity plot without error bars will")
print("almost always LOOK like it slopes upward, and that appearance is the thing to distrust.")

# and validate it the way this subsection says to, rather than on one arbitrary cut
from fincml import purged_kfold
t1_ev=np.array([np.searchsorted(ev,min(tt[i],ev[-1])) for i in ev])       # label end, in event index space
fold_lift=[]
for tr_,te_ in purged_kfold(t1_ev,n_splits=5,embargo_pct=0.01):
    if len(te_)<50 or len(np.unique(ym[tr_]))<2: continue
    mf=RandomForestClassifier(300,min_samples_leaf=40,random_state=0).fit(X[tr_],ym[tr_])
    pf=mf.predict_proba(X[te_])[:,1]; base_f=ym[te_].mean()
    sel=pf>np.quantile(pf,0.75)                                            # trade the most-confident quartile
    fold_lift.append((base_f, ym[te_][sel].mean(), sel.sum()))
fl=np.array(fold_lift)
print(f"\nRe-run through the purged, embargoed splitter of notebook 1 -- trading the most-confident quartile in each fold:")
print(f"  {'fold':>5} {'baseline':>10} {'meta-filtered':>14} {'lift':>9} {'n traded':>9}")
for j,(b_,m_,k_) in enumerate(fl):
    print(f"  {j+1:>5} {b_:>10.3f} {m_:>14.3f} {m_-b_:>+9.3f} {int(k_):>9}")
print(f"  {'mean':>5} {fl[:,0].mean():>10.3f} {fl[:,1].mean():>14.3f} {fl[:,1].mean()-fl[:,0].mean():>+9.3f}")
print(f"  lift by fold: {np.round(fl[:,1]-fl[:,0],3)}  -- sd {np.std(fl[:,1]-fl[:,0],ddof=1):.3f}, so the mean lift of")
print(f"  {fl[:,1].mean()-fl[:,0].mean():+.3f} is well inside the fold-to-fold noise. The sign is not even stable across folds.")
print("\nThat is the honest finding, and it is the same one the returns notebook reached by a different route: this")
print("primary has no exploitable edge for a meta-model to concentrate. The machinery is correct -- section 2 showed")
print("it doubling expected profit where an edge genuinely existed -- and it has nothing here to work with. Reporting")
print("the curve without the intervals would have turned an efficient market into a modest success story.")
No description has been provided for this image
Primary precision (act on every event): 0.5320 on 1032 held-out events.
   threshold  n traded  precision         95% interval  clears the baseline?
        0.45       849     0.5300       [0.496, 0.564]                    no
        0.50       545     0.5229       [0.481, 0.565]                    no
        0.55       267     0.5206       [0.461, 0.581]                    no
        0.58       142     0.5493       [0.467, 0.631]                    no
        0.60        83     0.5783       [0.472, 0.685]                    no
        0.62        46     0.6522       [0.515, 0.790]                    no

Not one threshold produces a lift that survives its own sampling error: every interval contains the primary's
own precision. The curve rises at the selective end because precision estimated from 46 events is a noisy
quantity, not because the filter is finding anything. A precision-vs-selectivity plot without error bars will
almost always LOOK like it slopes upward, and that appearance is the thing to distrust.
Re-run through the purged, embargoed splitter of notebook 1 -- trading the most-confident quartile in each fold:
   fold   baseline  meta-filtered      lift  n traded
      1      0.472          0.355    -0.118       172
      2      0.512          0.558    +0.047       172
      3      0.478          0.494    +0.016       172
      4      0.578          0.599    +0.021       172
      5      0.512          0.570    +0.057       172
   mean      0.510          0.515    +0.005
  lift by fold: [-0.118  0.047  0.016  0.021  0.057]  -- sd 0.071, so the mean lift of
  +0.005 is well inside the fold-to-fold noise. The sign is not even stable across folds.

That is the honest finding, and it is the same one the returns notebook reached by a different route: this
primary has no exploitable edge for a meta-model to concentrate. The machinery is correct -- section 2 showed
it doubling expected profit where an edge genuinely existed -- and it has nothing here to work with. Reporting
the curve without the intervals would have turned an efficient market into a modest success story.

4. Summary¶

Triple-barrier labeling defines a realistic, path-dependent trading label; meta-labeling turns a side-picking signal into a filtered, size-aware strategy. The labeler assigns each event the barrier it touches first, and the barrier width turns out to be the method rather than a detail: at $\pm2$ daily $\sigma$, 77.5% of labels are decided by a horizontal barrier, but widen them to $\pm2\sigma$ of the full 10-day move and only 6.4% are — the other 94% run to the time limit and get labeled by the sign of the return, which is exactly the fixed-horizon labeling the method exists to replace.

On meta-labeling, the controlled example needed a different scorecard than the obvious one. Precision rose from 0.57 to 0.78 while F1 stayed flat (0.725 to 0.727), because the primary's recall was 1 to begin with and every point of precision is paid for one-for-one. The metric that matches the decision is expected profit: half the bets at four times the edge each, roughly double the total over the same opportunity set. F1 answers a classification question nobody asked.

On the real market, adding error bars changed the conclusion. No threshold produced a lift that survived its own sampling error — every interval contains the primary's own precision, and the curve rises at the selective end because a win rate estimated from 46 events is noisy, not because the filter is finding anything. Re-run through the purged splitter from the first notebook, the lift is inside the fold-to-fold noise and its sign is not stable across folds. A precision-vs-selectivity plot without intervals will almost always look like it slopes upward, and that appearance is the thing to distrust.

This is the labeling and strategy-construction core of the López de Prado toolkit, and it closes the loop with the rest of the subsection: fractional differentiation (ex2) makes the features stationary-with-memory, triple-barrier (here) makes the labels realistic, and purged CV (ex1) validates them without leakage. The final notebook — the deflated Sharpe ratio and probability of backtest overfitting — addresses the last trap: once you have features, labels, and honest CV, how do you avoid being fooled by the best of many strategies you tried?