---
title: "5.3.1 Fixed-horizon labeling and its problems"
description: "The most used label in financial ML, the sign of the return over h bars, measured against the truth: noise, fixed thresholds, horizon and overlap."
url: "https://pythonparatrading.com/en/python-trading-course/level-5/fixed-horizon-labeling/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-5/fixed-horizon-labeling.png"
---

# 5.3.1 · Fixed-horizon labeling and its problems

> Before training a model you have to decide what it has to predict. The default answer, the sign of the return over a fixed horizon, looks neutral and is not: the horizon decides what the label measures, a fixed threshold fills it with volatility and overlap multiplies the apparent evidence. This lesson measures it on the level's stock panel and intraday future, where the truth is known.

- Course: [Level 5: Machine learning and deep learning (Keras/PyTorch)](https://pythonparatrading.com/en/python-trading-course/level-5/)
- Module: 5.3 · Labelling and sampling
- Duration: 60 min
- Prerequisites: [2.1.1 · Hypothesis tests, p-values, bootstrap and regression with robust standard errors](https://pythonparatrading.com/en/python-trading-course/level-2/hypothesis-testing-bootstrap-robust-errors/), [5.2.1 · Price, volume and microstructure features](https://pythonparatrading.com/en/python-trading-course/level-5/price-volume-microstructure-features/), [5.2.4 · Leakage-free normalization and cross-sectional rank](https://pythonparatrading.com/en/python-trading-course/level-5/leakage-free-normalization-cross-sectional-rank/)

## By the end of this lesson

- Define the fixed-horizon label (sign, threshold and return relative to the cross-section) and store each label's end date with it.
- Measure how much of a label is signal by comparing it with the panel's true alpha, and what happens when small returns are discarded.
- Check that a fixed threshold distributes the classes according to volatility (year, time of day) and correct it with a threshold proportional to a volatility forecast made without the future.
- Choose the horizon according to what you want to measure: the label's agreement with FNX's latent state rises up to the typical duration of the regime and then falls.
- Quantify how label overlap inflates the t statistic and correct it with Newey-West or with non-overlapping subsamples.

## What the model has to predict

Supervised learning needs an answer for each row. In tutorials on ML applied to markets the answer is almost always
the same: the sign of the return from the close of bar $t$ to that of bar $t + h$. With prices $P$ and log return
$r_{t,t+h} = \ln(P_{t+h}/P_t)$, there are three variants:

$$y_t = \operatorname{sign}(r_{t,t+h}), \qquad
y_t = \begin{cases} +1 & r_{t,t+h} > \tau_t \\ 0 & |r_{t,t+h}| \le \tau_t \\ -1 & r_{t,t+h} < -\tau_t \end{cases},
\qquad y_t = r_{t,t+h} - \operatorname{median}_j\, r_{j,t,t+h}.$$

The first is a binary classification; the second adds a “no move” class with a threshold $\tau_t$; the third is the
one for a stock panel, where what matters for a long-short portfolio is the return **relative** to the other stocks
on the same date. This family is called **fixed-horizon** because $h$ is the same for all rows, whatever happens
between $t$ and $t + h$.

```mermaid
flowchart LR
  T[Bar t: features] --> T1[End t1 = t + h]
  T1 --> R[r from t to t1]
  R --> Y[Label y]
```

It is the simplest label and the one that this level's validation and model lessons use with the panel. It has five
problems, and this lesson measures four of them with data where the truth is known (the data are synthetic: the
effects are planted and the generating model is published in `public/curso/datos/nivel-5/README.md`). The fifth, that
it ignores the path between $t$ and $t + h$, is the starting point of the triple barrier (lesson 5.3.2).

| Problem | What happens | Where it is measured |
|---|---|---|
| Noise | The label barely resembles the predictable part | Panel: sign versus the true alpha |
| Fixed threshold | Classes are distributed by volatility, not by signal | Panel by year and FNX by hour |
| Horizon | It decides which phenomenon the label measures | FNX: sign versus the latent trend |
| Overlap | If $h$ is larger than the sampling step, labels share returns | Panel: 21-session label |
| Path | A stop hit along the way does not count | Lesson 5.3.2 |

## The panel's labels

The panel of 400 fictitious stocks comes with two labels already computed, each with its end: `ret_sig_semana` runs
from the close of `fecha` (the last day of the week) to the close of the following week (`fin_sig_semana`), and
`ret_sig_21d`, over 21 sessions (`fin_sig_21d`). The classification label is built by subtracting the weekly median:

```python
import numpy as np
import pandas as pd

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
v = pd.read_parquet("panel5_verdad.parquet")
d = (f.merge(e, on=["fecha", "ticker"])
      .merge(v[["fecha", "ticker", "alfa_verdadero"]],
             on=["fecha", "ticker"])
      .dropna(subset=["ret_sig_21d"]).reset_index(drop=True))
sem = d.groupby("fecha")
d["y"] = d["ret_sig_semana"] - sem["ret_sig_semana"].transform("median")
d["alfa"] = (d["alfa_verdadero"]
             - sem["alfa_verdadero"].transform("median"))
d["etiqueta"] = np.sign(d["y"]).astype(int)
cols = ["fecha", "fin_sig_semana", "ret_sig_semana", "etiqueta",
        "fin_sig_21d", "ret_sig_21d"]
d.loc[d["ticker"] == "BAKU", cols].head(3).round(
    {"ret_sig_semana": 4, "ret_sig_21d": 4})
```
```output
         fecha fin_sig_semana  ret_sig_semana  etiqueta fin_sig_21d  ret_sig_21d
2   2012-01-13     2012-01-20         -0.0723        -1  2012-02-14       0.0902
402 2012-01-20     2012-01-27          0.0176         1  2012-02-21       0.2986
802 2012-01-27     2012-02-03          0.0688         1  2012-02-28       0.3393
```

The end dates are not `fecha` plus seven days or plus 21 calendar days: they are counted in NYSE sessions, so holidays
shift them. That is why the labeling function has to return the end and not let the rest of the code recompute it.

## How much of the label is signal

The panel publishes `alfa_verdadero`: the extra return for the following week that the generator plants in each
stock. It is the predictable part of the label; the rest is noise. If a model knew the exact alpha (the **oracle**),
its hit rate when predicting the sign of the label would be the proportion of rows in which the sign of the alpha and
that of the label match:

```python
acierta = np.sign(d["y"]) == np.sign(d["alfa"])
print(f"all: {acierta.mean():.2%}")
pct = sem["y"].transform(lambda s: s.abs().rank(pct=True))
cortes = [0.25, 0.5, 0.75, 0.9, 0.97]
pd.Series({f"|y| above percentile {int(q * 100)}":
           acierta[pct > q].mean() for q in cortes}
          ).map("{:.2%}".format)
```
```output
all: 50.93%
|y| above percentile 25    51.20%
|y| above percentile 50    51.41%
|y| above percentile 75    51.62%
|y| above percentile 90    51.63%
|y| above percentile 97    51.39%
dtype: str
```

50.93%. The cross-sectional standard deviation of the alpha is about 12 bp per week and that of the label, about
360 bp: the signal is one thirtieth of the noise, a realistic ratio for equities (lesson 5.1.1). This is the ceiling
of any classifier trained on this label: an out-of-sample hit rate of 55% is not a good model, it is a leak.

The second part of the cell answers a very widespread idea: removing small returns, “which are noise”, so that the
model learns only from clear moves. The agreement with the alpha rises a little when discarding the smallest quartile
(51.20%) and the bottom half (51.41%), plateaus at 51.6% and falls again with the 3% most extreme returns (51.39%). A
stock's large returns are not more informative: with fat tails, the most extreme ones are mainly jumps (earnings,
sector scares) that the alpha does not explain. And each filter leaves the model with fewer rows. A moderate
threshold may help a little; it does not turn noise into signal.

## Fixed threshold: the classes follow volatility

The second variant, with class 0, needs a threshold. The most common is a fixed number (“more than 3% in a week”). The
problem is that volatility changes, and with it the proportion of returns that pass the threshold:

```python
r = d["ret_sig_semana"]
sigma_sem = d["vol_63d"] / np.sqrt(52)
anio = d["fecha"].dt.year
pd.DataFrame({
    "|r| > 3%": (r.abs() > 0.03).groupby(anio).mean(),
    "|r| > 0.5·σ": (r.abs() > 0.5 * sigma_sem).groupby(anio).mean(),
}).loc[[2013, 2017, 2019, 2020, 2022, 2023]].round(3)
```
```output
       |r| > 3%  |r| > 0.5·σ
fecha
2013      0.362        0.574
2017      0.356        0.586
2019      0.392        0.578
2020      0.503        0.591
2022      0.525        0.599
2023      0.349        0.552
```

With a fixed 3%, the ±1 class is 35% of the rows in 2017 and 2023 and more than half in 2020 and 2022. A classifier
trained this way learns, before anything else, which volatility regime it is in, and the classes change proportion
between training and test. With a threshold proportional to the stock's volatility, $\tau_{i,t} = 0.5\cdot
\sigma_{i,t}/\sqrt{52}$ (the panel's `vol_63d`, known at `fecha`, scaled to one week), the proportion stays between
55% and 60% every year.

In intraday data the effect is stronger, because volatility has a U-shaped curve within each session. FNX, the
level's fictitious future, has one year of one-minute bars (247 sessions of 390 minutes). To scale without the future
you need a **forecast** volatility for the next 30 minutes: here, the variance of each minute of the day estimated
with the previous 20 sessions, smoothed across neighboring minutes and summed over the horizon.

```python
b = pd.read_parquet("hf5_barras_1min.parquet")
ver = pd.read_parquet("hf5_verdad.parquet")
C = np.log(b["cierre"].to_numpy()).reshape(-1, 390)
D = ver["direccion"].to_numpy().reshape(-1, 390)
r2 = np.diff(C, axis=1) ** 2            # 389 returns per session

# variance of each minute: mean of the PREVIOUS 20 sessions
s2 = pd.DataFrame(r2).rolling(20).mean().shift(1)
s2 = s2.T.rolling(9, center=True, min_periods=1).mean().T.to_numpy()
acum = np.c_[np.zeros(len(s2)), np.cumsum(s2, axis=1)]
print(C.shape, "sessions x minutes")
```
```output
(247, 390) sessions x minutes
```

```python
h = 30
fut = C[:, h:] - C[:, :-h]              # r from t to t+h, t = 0…359
sd = np.sqrt(acum[:, h:390] - acum[:, :390 - h])
ok = ~np.isnan(sd)
media_hora = np.tile(np.arange(390 - h) // 30, (len(C), 1))
fijo = np.abs(fut) > 0.0020
escalado = np.abs(fut) > sd
tabla = pd.DataFrame({
    "forecast σ (bp)": [np.nanmean(sd[ok & (media_hora == k)]) * 1e4
                        for k in range(12)],
    "|r| > 20 bp": [fijo[ok & (media_hora == k)].mean()
                    for k in range(12)],
    "|r| > forecast σ": [escalado[ok & (media_hora == k)].mean()
                         for k in range(12)],
}, index=[f"{9 + (30 + 30 * k) // 60:02d}:{(30 + 30 * k) % 60:02d}"
          for k in range(12)])
tabla.round(3)
```
```output
       forecast σ (bp)  |r| > 20 bp  |r| > forecast σ
09:30           34.713        0.519             0.285
10:00           25.928        0.405             0.299
10:30           22.832        0.341             0.281
11:00           21.717        0.312             0.280
11:30           21.258        0.306             0.284
12:00           21.072        0.287             0.264
12:30           21.109        0.340             0.321
13:00           21.044        0.301             0.287
13:30           21.283        0.317             0.292
14:00           21.638        0.324             0.290
14:30           22.556        0.373             0.315
15:00           23.508        0.374             0.315
```

*Fig. — FNX: share of 30-minute labels above the threshold, by starting half hour, with a fixed 20 bp threshold and with one equal to the volatility forecast from the previous 20 sessions.* Two lines by half hour from 9:30 to 15:00. With the fixed threshold, highlighted, the share of ±1 labels is 52% at 9:30, falls to 29% at 12:00 and rises to 37% at 15:00, following the U-shaped volatility curve. With the scaled threshold it stays between 26% and 32% all day.

With a fixed 20 bp threshold, 52% of the labels in the first half hour are ±1 and at midday, 29%: half of the model's
work would be learning the time of day. With the threshold equal to the forecast volatility of the 30 minutes (from
35 bp at the open to 21 bp at midday), the proportion of ±1 stays between 26% and 32% in all time slots. This is the
idea that the triple barrier takes further, with barriers at $\pm k\cdot\sigma_t\sqrt{h}$ (lesson 5.3.2).

<aside class="callout" data-kind="aviso">

The scaled threshold has to use a volatility **known at t**. Dividing the future return by the realized volatility
between $t$ and $t + h$ is a subtle leak: that volatility depends on the very move being labeled. The one in the cell
uses only the previous sessions; the panel's `vol_63d`, only the 63 sessions up to `fecha`.

</aside>

## The horizon decides what the label measures

FNX alternates two latent regimes: **trend** (informed flow with a direction $D = \pm 1$, episodes of 60 minutes on
average) and **reversion** to a 30-minute exponential mean. The minute-by-minute truth is in `hf5_verdad.parquet`.
That makes it possible to ask, for each horizon $h$, how often the sign of the label matches the true direction of the
trend at minute $t$, which is what a model should learn to recognize:

```python
def acierto_tendencia(h):
    fut = C[:, h:] - C[:, :-h]
    dd = D[:, :-h]
    m = dd != 0
    return (np.sign(fut[m]) == dd[m]).mean()

horizontes = [1, 5, 10, 15, 30, 45, 60, 90, 120, 180]
pd.Series({h: acierto_tendencia(h) for h in horizontes},
          name="sign = direction").rename_axis("h (min)").round(3)
```
```output
h (min)
1      0.540
5      0.621
10     0.662
15     0.687
30     0.714
45     0.735
60     0.732
90     0.719
120    0.711
180    0.676
Name: sign = direction, dtype: float64
```

The figure at the top draws the series. With $h = 1$ minute, the label matches the true direction 54% of the time:
one minute's noise masks the drift. As the horizon is lengthened the drift accumulates faster than the noise (the
drift grows with $h$; the noise, with $\sqrt{h}$) and the agreement rises to 73.5% at 45 minutes. Then it falls: the
regime lasts about 60 minutes on average, and a 180-minute label mixes the trend episode with whatever comes after,
which does not depend on the current direction.

There is no correct horizon in general. There is one that corresponds to the phenomenon you want to capture and to
how you are going to trade: a strategy that holds the position for half an hour needs a label of the order of half an
hour, not of one minute (which rewards predicting noise) or of a day. And if the phenomenon lasts a variable time, no
fixed $h$ fits all events: the triple barrier puts the end of each label where the price decides.

## Overlap: the same evidence counted several times

In the panel, the features are weekly and the label `ret_sig_21d` covers 21 sessions, about four and a half weeks.
One week's label shares four of its five weeks of returns with the following week's. Lesson 5.4.1 measures what that
does to a shuffled cross-validation; here the interest is in what it does to inference. A feature's weekly IC with
that label forms an autocorrelated series, and its naive $t$ statistic, $\overline{IC}/s_{IC}\cdot\sqrt{n}$, treats
each week as an independent observation. There are two usual corrections: **Newey-West** errors (lesson 2.1.1), which
estimate the variance of the mean with the autocovariances up to a lag, and **non-overlapping subsamples** (one week
out of every five, averaging the five possible ones):

```python
def ic_por_semana(col, lab):
    x = d[["fecha", col, lab]].dropna()
    rk = x.groupby("fecha")[[col, lab]].rank()
    return rk[col].groupby(x["fecha"]).corr(rk[lab]).to_numpy()

def t_ingenuo(s):
    return s.mean() / s.std() * np.sqrt(len(s))

def t_newey_west(s, retardos=6):
    x = s - s.mean()
    var = x @ x / len(x)
    for k in range(1, retardos + 1):
        var += 2 * (1 - k / (retardos + 1)) * (x[k:] @ x[:-k]) / len(x)
    return s.mean() / np.sqrt(var / len(x))

def t_sin_solape(s, paso=5):
    return np.mean([t_ingenuo(s[i::paso]) for i in range(paso)])

filas = {}
for c in ["alfa_verdadero", "valor_bp", "mom_12_1", "ey",
          "ruido_4", "ruido_3", "ruido_1"]:
    s21 = ic_por_semana(c, "ret_sig_21d")
    s5 = ic_por_semana(c, "ret_sig_semana")
    filas[c] = {"IC 21 sess.": s21.mean(), "naive t": t_ingenuo(s21),
                "t Newey-West": t_newey_west(s21),
                "non-overlap t": t_sin_solape(s21),
                "IC 1 wk.": s5.mean(), "t 1 wk.": t_ingenuo(s5)}
pd.DataFrame(filas).T.round(3)
```
```output
                IC 21 sess.  naive t  t Newey-West  non-overlap t  IC 1 wk.  t 1 wk.
alfa_verdadero        0.030    9.229         5.713          4.135     0.029    8.390
valor_bp              0.020    7.760         3.911          3.477     0.009    3.920
mom_12_1              0.017    5.542         2.951          2.477     0.008    2.445
ey                    0.018    7.874         4.061          3.536     0.009    4.059
ruido_4               0.010    4.366         2.722          1.962     0.005    1.934
ruido_3              -0.001   -0.356        -0.200         -0.151    -0.001   -0.641
ruido_1              -0.001   -0.632        -0.653         -0.284    -0.002   -1.322
```

*Fig. — t-statistic of the weekly IC with the 21-session label, which overlaps the next four weeks: naive, Newey-West (6 lags) and non-overlapping subsamples, against the t with the weekly label.* One row per feature with four marks. Naive t values are highlighted. True alpha: naive t 9.2, Newey-West 5.7, no overlap 4.1 and with the weekly label 8.4. Valor_bp: 7.8, 3.9, 3.5 and 3.9. Ey: 7.9, 4.1, 3.5 and 4.1. Mom_12_1: 5.5, 3.0, 2.5 and 2.4. Ruido_4, with no effect of its own: 4.4, 2.7, 2.0 and 1.9. Ruido_3 and ruido_1, no effect, near zero in all. Dashed line at t = 2.

With the 21-session label, the true alpha has a naive $t$ of 9.2; corrected, 5.7 (Newey-West) or 4.1 (non-overlapping).
The weekly label, with no overlap, gives $t = 8.4$ with the same alpha: the long label does not contribute twice the
evidence, it contributes less and counts it several times. The dangerous case is `ruido_4`, a feature with no effect
of its own that inherits momentum's: with the naive $t$ (4.4) it looks like a discovery; corrected, it stays at
2.0-2.7, the same level as with the weekly label (1.9). In a sweep of hundreds of features (lesson 2.1.8), the naive
$t$ of overlapping labels multiplies false positives.

```mermaid
flowchart TB
  E[Overlap] --> I[Inflated t]
  E --> V[Leak in CV]
  E --> W[Repeated rows]
  I --> I2[Newey-West]
  V --> V2[Purging 5.4.2]
  W --> W2[Uniqueness 5.3.4]
```

Overlap is not an error in itself: a 21-session label may be the right one for a strategy that holds its positions
for a month. The error is treating the rows as independent in the three parts of the process in the diagram.

## A labeling function that stores the end

All of the above comes down to a function that returns, for each bar, the end of its label, the return and the class,
with an optional threshold that must be computed with information up to $t$:

```python
def horizonte_fijo(precio, h, umbral=None):
    """Label of each bar with its log return over h bars.

    precio: Series with a time index (a single session or a
    continuous series). umbral: Series of log thresholds (or None: sign).
    """
    lp = np.log(precio)
    r = lp.shift(-h) - lp
    t1 = pd.Series(precio.index, index=precio.index).shift(-h)
    if umbral is None:
        lab = np.sign(r)
    else:
        lab = np.sign(r).where(r.abs() > umbral, 0.0)
    out = pd.DataFrame({"t1": t1, "ret": r, "etiqueta": lab})
    return out.dropna()

una = b.set_index("ts")["cierre"].iloc[20 * 390:21 * 390]  # session 21
umbral = pd.Series(sd[20], index=una.index[:390 - h])
et = horizonte_fijo(una, h, umbral.reindex(una.index))
print(et["etiqueta"].value_counts().sort_index().to_dict())
et.iloc[[0, 1, 359]].round({"ret": 5})
```
```output
{-1.0: 45, 0.0: 188, 1.0: 127}
                                                 t1      ret  etiqueta
ts
2024-07-31 13:30:00+00:00 2024-07-31 14:00:00+00:00  0.01345       1.0
2024-07-31 13:31:00+00:00 2024-07-31 14:01:00+00:00  0.01202       1.0
2024-07-31 19:29:00+00:00 2024-07-31 19:59:00+00:00  0.00605       1.0
```

The output's index is $t$ and the `t1` column, the end: it is the pair that the validation purging (5.4.2) and sample
uniqueness (5.3.4) need. In the example session, with the threshold equal to the forecast volatility, 188 of the 360
labelable minutes fall in class 0, 127 in +1 and 45 in −1: a bullish session. The last 30 bars of the session have no
label, because their horizon would cross the close; in intraday data it is advisable not to extend the label into the
next session, where the opening gap has nothing to do with the previous day's features.

## Typical mistakes

- **Labeling with $h$ in rows instead of in time.** `shift(-h)` on a DataFrame with gaps (holidays, bars without
  trades) gives different horizons for different rows. You have to know what $h$ is and store `t1`.
- **Crossing the session close** without deciding to: the overnight return is a different phenomenon.
- **A threshold computed with the whole sample** (for example, the median of $|r|$ over the whole history): the same
  statistics leak as in lesson 5.2.4.
- **Comparing hit rates between different labels.** 60% with class 0 included is not comparable with 51% on a binary
  label: you have to compare against the proportion of the majority class and, better, with economic metrics (5.1.3).
- **Trusting the $t$ of overlapping labels** without correcting it.

## Exercises

1. In FNX's **reversion** minutes, the drift pushes the price toward its 30-minute exponential mean. Measure, for
   several horizons, how often the sign of the label is the opposite of that of the deviation
   $\ln P_t - \text{EMA}_{30}$. Which horizon best reflects that regime?

**Solution.**

```python
ema = pd.DataFrame(C.T).ewm(span=30).mean().T.to_numpy()
desv = C - ema
rev = (ver["regimen"].to_numpy().reshape(-1, 390) == "reversion")

def acierto_reversion(h, minimo=0.001):
    fut = C[:, h:] - C[:, :-h]
    dv = desv[:, :-h]
    m = rev[:, :-h] & (np.abs(dv) > minimo)
    return (np.sign(fut[m]) == -np.sign(dv[m])).mean(), m.sum()

pd.DataFrame({h: acierto_reversion(h) for h in [1, 5, 15, 30, 60]},
             index=["hit rate", "minutes"]).T.round(3)
```
```output
    hit rate  minutes
1      0.517  17406.0
5      0.550  17176.0
15     0.567  16655.0
30     0.555  15830.0
60     0.528  14330.0
```

In reversion the label reflects the regime much worse than in trend: the hit rate rises from 51.7% with 1 minute to a
maximum of 56.7% with 15 minutes and falls to 52.8% with 60. Mean reversion is slow (2% of the deviation per minute)
and soon mixes with the regime change, so the useful horizon is shorter than in trend. The same $h$ does not work for
both phenomena: another argument for labels whose end depends on the event (lesson 5.3.2) or for separating the two
problems with a primary model and a meta-model (lesson 5.3.3). (Deviations smaller than 10 bp are discarded, where
the sign of the deviation is almost random.)

2. With the threshold equal to $k$ times the 30-minute forecast volatility, compute the proportion of each class for
   $k$ = 0.5, 1 and 1.5. Which $k$ leaves the three classes most balanced?

**Solution.**

```python
reparto = {}
for k in [0.5, 1.0, 1.5]:
    lab = np.where(fut > k * sd, 1, np.where(fut < -k * sd, -1, 0))
    reparto[k] = pd.Series(lab[ok]).value_counts(normalize=True)
pd.DataFrame(reparto).T.round(3)
```
```output
         0     -1      1
0.5  0.412  0.298  0.289
1.0  0.707  0.149  0.144
1.5  0.867  0.068  0.065
```

With $k = 1$, 70.7% of the labels fall in class 0; with $k = 1.5$, 86.7%. The most balanced distribution is that of
$k = 0.5$: 41% zeros and around 29-30% of each sign. There is no correct $k$: a high threshold leaves a very
imbalanced problem (a classifier that always says 0 is right 87% of the time), and a low one goes back to the binary
label with its noise. Choosing it by looking at the validation result is another form of overfitting (lesson 5.5.4);
it is better to fix it from the problem (the cost of trading, the minimum move that pays for the bid-ask spread).

3. A long-short strategy on the panel rebalances every Friday and holds the portfolio for one week. A colleague
   proposes training with `ret_sig_21d` “because it has more signal” (its IC with the alpha is 0.030 versus 0.029).
   What do you answer?

**Solution.**

That a similar mean IC does not mean more information. The 21-session label, sampled every week, overlaps with the
next four: its corrected $t$ (4.1-5.7) is lower than that of the weekly label (8.4), so it contributes less
independent evidence, not more. Besides, it measures something other than what the strategy earns: the portfolio is
rebuilt every week, and what it gains or loses is that week's return. If you want to use the 21-session one (for
example, to reduce turnover with a slower signal), you have to purge the validation with its `t1` (5.4.2), weight by
uniqueness (5.3.4) and correct any statistic with Newey-West or with non-overlapping subsamples.

## Self-assessment

1. In the panel, the sign of the weekly label (return minus the weekly median) matches the sign of the true alpha in 50.9% of the rows. What does it mean?

   - A. That the label is miscalculated
   - B. That the true alpha is useless for prediction
   - C. That a shorter horizon should be used
   - D. That almost all of the label is noise: the planted alpha is small compared with the dispersion of returns, and a perfect model would only get that 50.9% right

   *Answer D: That almost all of the label is noise: the planted alpha is small compared with the dispersion of returns, and a perfect model would only get that 50.9% right.* The oracle is the best possible forecast. If even it does not match the sign of the label more than 50.9% of the time, no model can go beyond that; a higher out-of-sample hit rate indicates a leak.

2. With a fixed 20 bp threshold to label FNX's 30-minute return, 52% of the 9:30 labels are ±1 and only 29% of the 12:00 ones. What is the label mainly measuring?

   - A. That there is more signal in the morning
   - B. That the primary model works better early in the day
   - C. The volatility of each time slot: with a U-shaped curve, the same threshold is small at the open and large at midday
   - D. The volume of each time slot

   *Answer C: The volatility of each time slot: with a U-shaped curve, the same threshold is small at the open and large at midday.* With the threshold scaled by the forecast volatility of each time slot (computed with the previous 20 sessions), the fraction of ±1 labels stays between 26% and 32% throughout the day.

3. In FNX's trend minutes, the sign of the return over h minutes matches the true direction 54.0% of the time with h = 1, 73.5% with h = 45 and 67.6% with h = 180. Why does it fall when the horizon is lengthened?

   - A. Because the regime lasts about 60 minutes on average: beyond that, the label mixes the trend episode with whatever comes next, which does not depend on the current direction
   - B. Because of price rounding
   - C. Because there are fewer observations
   - D. Because volatility falls in the afternoon

   *Answer A: Because the regime lasts about 60 minutes on average: beyond that, the label mixes the trend episode with whatever comes next, which does not depend on the current direction.* With short horizons each minute's noise dominates; with long horizons, the regime change. The horizon that best reflects the state at t is close to the typical duration of the phenomenon you want to capture.

4. The IC of the true alpha with the 21-session label, sampled every week, has a naive t of 9.2; with Newey-West it is 5.7 and with non-overlapping subsamples, 4.1. What is the correct reading?

   - A. That the 21-session label has twice the information of the weekly one
   - B. That each return enters about five consecutive labels: the naive t counts the same evidence several times and has to be corrected
   - C. That Newey-West always halves the t
   - D. That the true alpha is not significant

   *Answer B: That each return enters about five consecutive labels: the naive t counts the same evidence several times and has to be corrected.* The 21-session labels of consecutive weeks share four of their five weeks, so the IC series is autocorrelated. The weekly label, with no overlap, gives t = 8.4 with the same alpha.

5. Why should the labeling function return, in addition to the label, the moment it ends (`t1`)?

   - A. To plot it
   - B. Because scikit-learn requires it
   - C. To compute the threshold
   - D. Because the validation purging (5.4.2) and uniqueness weights (5.3.4) need to know which price interval each label uses

   *Answer D: Because the validation purging (5.4.2) and uniqueness weights (5.3.4) need to know which price interval each label uses.* Two labels overlap if their intervals from t to t1 intersect. Without t1 you cannot purge the training set or measure concurrency; with horizons in sessions, holidays and session closes, t1 is not simply t + h.

## Key points

- The fixed-horizon label is the sign (or the return) from t to t + h, sometimes with a threshold that creates a class 0. Its end, t1, has to be stored with it.
- Almost all of the label is noise: in the panel, the sign of the weekly label matches that of the true alpha in 50.9% of the rows. Removing small returns only raises it to 51.6% and removing too many lowers it again.
- A fixed threshold distributes the classes according to volatility: from 35% to 53% of ±1 labels depending on the year in the panel and from 29% to 52% depending on the time of day in FNX. A threshold proportional to the forecast volatility stabilizes it.
- The horizon decides what the label measures. In FNX, the sign over h minutes reflects the latent trend 54% of the time with 1 minute, 73.5% with 45 and 67.6% with 180.
- Overlapping labels inflate the evidence: with 21 sessions sampled every week, the naive t of the true alpha is 9.2 and the corrected one, between 4.1 and 5.7.
- The triple-barrier method (5.3.2) adds the path and an event-dependent horizon; uniqueness weights (5.3.4) and purging (5.4.2) deal with the overlap.

## Resources

### Documentation

- [statsmodels: cov_hac (Newey-West standard errors)](https://www.statsmodels.org/stable/generated/statsmodels.stats.sandwich_covariance.cov_hac.html) · statsmodels.org

### Books

- [López de Prado (2018): Advances in Financial Machine Learning, Wiley (ch. 3, labeling)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Data

- [Level 5 data: panel labels (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_etiquetas.parquet) · Download · panel5_etiquetas.parquet
- [Level 5 data: FNX 1-minute bars (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Download · hf5_barras_1min.parquet
- [Level 5 data: FNX ground truth, regime and direction (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_verdad.parquet) · Download · hf5_verdad.parquet

Previous lesson: [5.2.5 · Feature importance: MDI, MDA, SFI and SHAP](https://pythonparatrading.com/en/python-trading-course/level-5/feature-importance-mdi-mda-sfi-shap/)

Next lesson: [5.3.2 · Triple-barrier method: labels that mimic a trade](https://pythonparatrading.com/en/python-trading-course/level-5/triple-barrier-labeling-method/)
