Labeling Methods¶
ML4T Engineer provides 7 labeling methods for supervised learning in finance, implementing the full workflow from Advances in Financial Machine Learning (De Prado, 2018).
Overview¶
| Method | Function | Use Case |
|---|---|---|
| Triple-barrier | triple_barrier_labels() |
Fixed profit/loss targets with time limit |
| ATR-based barriers | atr_triple_barrier_labels() |
Volatility-adjusted targets |
| Rolling percentile | rolling_percentile_binary_labels() |
Adaptive threshold from return distribution |
| Fixed time horizon | fixed_time_horizon_labels() |
Simple forward returns |
| Trend scanning | trend_scanning_labels() |
Optimal horizon via t-statistic |
| Meta-labeling | meta_labels() + compute_bet_size() |
Bet sizing for primary model |
| Calendar-aware | calendar_aware_labels() |
Session-break handling for futures |
All methods return a Polars DataFrame with standardized output columns.
Label Engineering Methods calls Engineer's labeling APIs on ETF data. The ETF case-study label notebook builds labels without Engineer, so the Book Guide classifies it as a related workflow rather than a library example.
Choosing a Method¶
Need fixed profit/loss targets?
├── Yes → Do barriers scale with volatility?
│ ├── Yes → atr_triple_barrier_labels()
│ └── No → triple_barrier_labels()
├── No → Need directional labels (long/short)?
│ ├── Yes → rolling_percentile_binary_labels()
│ └── No → Need optimal holding period?
│ ├── Yes → trend_scanning_labels()
│ └── No → fixed_time_horizon_labels()
Have a primary model? → meta_labels() for bet sizing
Trading futures with session breaks? → calendar_aware_labels()
LabelingConfig¶
All barrier-based methods accept a LabelingConfig object created via factory methods. This provides serialization, validation, and a bridge to DataContractConfig for pipeline integration.
Factory Methods¶
from ml4t.engineer.config import LabelingConfig
# Fixed barriers
config = LabelingConfig.triple_barrier(
upper_barrier=0.02, # 2% take profit
lower_barrier=0.01, # 1% stop loss
max_holding_period=20, # 20 bars (or "4h" for time-based)
side=1, # 1=long, -1=short, 0=symmetric
trailing_stop=False, # Enable trailing stop loss
)
# ATR-based barriers
config = LabelingConfig.atr_barrier(
atr_tp_multiple=2.0, # 2x ATR take profit
atr_sl_multiple=1.0, # 1x ATR stop loss
atr_period=14,
max_holding_period=20,
)
# Fixed horizon (simple forward returns)
config = LabelingConfig.fixed_horizon(
horizon=10,
return_method="returns", # "returns" | "log_returns" | "binary"
threshold=None,
)
# Trend scanning
config = LabelingConfig.trend_scanning(
min_horizon=5,
max_horizon=20,
t_value_threshold=2.0,
)
Serialization¶
Store labeling configurations for experiment reproducibility:
# Save to YAML
config.to_yaml("labeling_config.yaml")
# Reload
config = LabelingConfig.from_yaml("labeling_config.yaml")
Triple-Barrier Labels¶
The triple-barrier method from AFML Chapter 3 creates labels based on which of three barriers is touched first: upper (profit target), lower (stop loss), or vertical (time limit).
from datetime import datetime, timedelta
import polars as pl
from ml4t.engineer.config import LabelingConfig
from ml4t.engineer.labeling import triple_barrier_labels
close = [100.0 + i * 0.05 + (i % 6) * 0.2 for i in range(60)]
df = pl.DataFrame({
"timestamp": [
datetime(2024, 1, 1) + timedelta(minutes=i)
for i in range(60)
],
"open": close,
"high": [price + 0.5 for price in close],
"low": [price - 0.5 for price in close],
"close": close,
})
config = LabelingConfig.triple_barrier(
upper_barrier=0.02, # 2% profit target
lower_barrier=0.01, # 1% stop loss
max_holding_period=20, # 20 bar horizon
side=1, # Long-only signals
)
result = triple_barrier_labels(
data=df,
config=config,
price_col="close",
open_col="open", # Executes gaps at the open
high_col="high", # For intrabar barrier touches
low_col="low", # For intrabar barrier touches
timestamp_col="timestamp", # Required for time-based max_holding
calculate_uniqueness=False,
uniqueness_weight_scheme="returns_uniqueness",
)
assert {"label", "label_return", "barrier_hit"} <= set(result.columns)
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
pl.DataFrame |
required | OHLCV data |
config |
LabelingConfig |
required | Barrier configuration |
price_col |
str |
"close" |
Price column for barrier calculations |
open_col |
str \| None |
None |
Open column for gap execution |
high_col |
str \| None |
None |
High column for intrabar touch detection |
low_col |
str \| None |
None |
Low column for intrabar touch detection |
timestamp_col |
str \| None |
None |
Required when max_holding_period is time-based |
calculate_uniqueness |
bool |
False |
Compute label uniqueness and sample weights |
uniqueness_weight_scheme |
str |
"returns_uniqueness" |
Weight scheme (see Sample Weighting) |
Output Columns¶
| Column | Description |
|---|---|
label |
+1 (upper hit), -1 (lower hit), 0 (vertical hit) |
label_time |
Timestamp when barrier was hit |
label_price |
Price at barrier touch |
label_return |
Return from entry to barrier |
label_bars |
Number of bars until barrier |
label_duration |
Time duration until barrier |
barrier_hit |
Which barrier: "upper", "lower", "time" |
label_uniqueness |
Average uniqueness (when calculate_uniqueness=True) |
sample_weight |
Sample weight (when calculate_uniqueness=True) |
The side Parameter¶
Controls directional bias:
side=1: Long-only. Upper barrier = profit, lower barrier = loss.side=-1: Short-only. Upper barrier = profit, lower barrier = loss.side=0: Symmetric. Both barriers treated equally. Label is +1 or -1 based on direction.
Barrier distances and holding periods must be positive. Dynamic side columns accept only -1, 0, and 1.
With high and low columns, an intrabar touch exits at the barrier price. Supplying
open_col makes a gap beyond a barrier exit at the open. If one bar touches both
barriers, the function selects the stop-loss exit because OHLC data does not show
which extreme occurred first.
Trailing Stop¶
Enable a trailing stop loss that ratchets up as price moves favorably:
config = LabelingConfig.triple_barrier(
upper_barrier=0.03,
lower_barrier=0.01,
max_holding_period=20,
trailing_stop=True, # Stop loss follows highest price
)
With trailing_stop=True, the lower barrier moves up as the trade moves in favor. This reduces the time spent in losing positions.
Book: Label Engineering Methods calls
triple_barrier_labelsand visualizes barrier touches.
ATR-Based Dynamic Barriers¶
Volatility-adjusted barriers adapt to changing market conditions. The barriers scale with the Average True Range (ATR), so they're wider in volatile markets and tighter in calm markets.
from ml4t.engineer.labeling import atr_triple_barrier_labels
result = atr_triple_barrier_labels(
data=df,
atr_tp_multiple=2.0, # Take profit at 2x ATR
atr_sl_multiple=1.0, # Stop loss at 1x ATR
atr_period=14, # ATR lookback
max_holding_bars=20, # Can also be "4h" for time-based
side=1,
price_col="close",
timestamp_col="timestamp",
trailing_stop=False,
)
# Or via LabelingConfig
config = LabelingConfig.atr_barrier(
atr_tp_multiple=2.0,
atr_sl_multiple=1.0,
atr_period=14,
max_holding_period=20,
)
result = atr_triple_barrier_labels(df, config=config)
ATR is computed independently within each asset group. The returned
upper_barrier_distance and lower_barrier_distance columns are price-unit
distances. Internally, the function divides them by the event price before
calling triple-barrier labeling. ATR warm-up rows and rows with zero ATR have
null labels. High and low prices determine intrabar touches.
When to Use ATR Barriers¶
| Scenario | Recommendation |
|---|---|
| Single asset, stable volatility | Fixed barriers sufficient |
| Multi-asset (different volatility levels) | ATR barriers adapt per asset |
| Regime changes (calm → volatile) | ATR barriers avoid premature stops |
| Futures with varying contract sizes | ATR normalizes across contracts |
Book: Label Engineering Methods calls Engineer's ATR-barrier labeling API.
Rolling Percentile Labels¶
Adaptive labeling computes each threshold from outcomes whose forward horizons have ended. An unresolved outcome never contributes to an earlier threshold.
from ml4t.engineer.labeling import rolling_percentile_binary_labels
result = rolling_percentile_binary_labels(
data=df,
horizon=10, # Forward return horizon (bars or "1h")
percentile=95, # 95th percentile for long signals
direction="long", # "long" or "short"
lookback_window=252 * 24, # ~1 year of hourly bars
price_col="close",
session_col=None, # Session-aware forward returns
min_samples=None, # Minimum samples for percentile
timestamp_col=None, # Required for time-based horizons
tolerance=None, # E.g., "2m" for time-based horizons
)
Output Columns¶
| Column | Example | Description |
|---|---|---|
forward_return_10 |
0.0123 | Forward return over horizon |
threshold_p95_h10 |
0.0089 | Rolling 95th percentile threshold |
label_long_p95_h10 |
1 | 1 if return exceeds threshold, 0 otherwise |
Decimal percentiles use p in column names. For example, percentile 95.5
produces threshold_p95p5_h10 and label_long_p95p5_h10.
Multiple Horizons and Percentiles¶
Generate labels for multiple combinations simultaneously:
from ml4t.engineer.labeling import rolling_percentile_multi_labels
result = rolling_percentile_multi_labels(
data=df,
horizons=[5, 10, 20],
percentiles=[90, 95],
direction="long",
lookback_window=252,
)
# Produces: label_long_p90_h5, label_long_p95_h5, label_long_p90_h10, ...
Book: Label Engineering Methods calls Engineer's percentile and triple-barrier functions. ETFs: Label Engineering constructs its case-study labels without calling Engineer.
Fixed Time Horizon¶
The simplest labeling method: compute forward returns over a fixed horizon. Supports both bar-count and time-based horizons.
from ml4t.engineer.labeling import fixed_time_horizon_labels
# Bar-based horizon
result = fixed_time_horizon_labels(
data=df,
horizon=10, # 10 bars forward
method="binary",
threshold=0.01, # +1 above 1%, -1 below -1%, otherwise 0
price_col="close",
)
# Time-based horizon
result = fixed_time_horizon_labels(
data=df,
horizon="1h", # 1 hour forward
timestamp_col="timestamp",
price_col="close",
)
Output names identify the method and horizon, such as label_return_10p,
label_log_return_1h, or label_direction_10p. A supplied LabelingConfig
controls the horizon, method, threshold, and column mapping. Conflicting explicit
arguments fail before calculation.
Trend Scanning¶
De Prado's trend-scanning method finds the optimal holding period for each observation by fitting linear regressions over a range of horizons and selecting the one with the highest t-statistic.
from ml4t.engineer.labeling import trend_scanning_labels
result = trend_scanning_labels(
data=df,
min_window=5,
max_window=20,
step=1,
t_value_threshold=2.0,
price_col="close",
)
Output includes label (+1, -1, or null), optimal_window, and t_value.
The function selects the tested window with the largest absolute slope t-statistic.
Labels below t_value_threshold are null while optimal_window and t_value
remain available. A supplied trend-scanning config controls all four numerical settings.
Constant windows have null outputs. Exact nonconstant linear fits use the largest
finite float as the signed t_value.
Book: Label Engineering Methods calls
trend_scanning_labelsalongside the barrier and percentile methods.
Meta-Labeling & Bet Sizing¶
Meta-labeling is a two-stage workflow (AFML Chapter 3):
- A primary model generates directional signals (+1/-1/0)
- A meta-model predicts whether the primary signal will be profitable (1/0)
- Bet sizing converts meta-model probability into position sizes
Step 1: Generate Meta-Labels¶
from ml4t.engineer.labeling import meta_labels
meta_result = meta_labels(
data=df,
signal_col="primary_signal", # +1/-1/0 from primary model
return_col="forward_return", # Actual forward returns
threshold=0.0, # Minimum return for "correct"
)
# Adds "meta_label" column: 1 if signal was correct, 0 otherwise
Step 2: Train Meta-Model¶
Train any classifier on the meta-labels to predict P(primary signal is correct).
Step 3: Compute Bet Sizes¶
from ml4t.engineer.labeling import compute_bet_size, apply_meta_model
# Low-level: get bet size expression
bet_expr = compute_bet_size(
probability="meta_probability", # Column name or pl.Expr
method="sigmoid", # "linear" | "sigmoid" | "discrete"
scale=5.0, # Sigmoid steepness
threshold=0.5, # Minimum probability to bet
)
# High-level: apply meta-model to size positions
result = apply_meta_model(
data=df,
primary_signal_col="signal",
meta_probability_col="meta_prob",
bet_size_method="sigmoid",
scale=5.0,
threshold=0.5,
output_col="sized_signal", # signal * bet_size
)
Bet Sizing Methods¶
| Method | Formula | When to Use |
|---|---|---|
"linear" |
max(0, p - threshold) / (1 - threshold) |
Simple, interpretable |
"sigmoid" |
2 / (1 + exp(-scale * (p - 0.5))) - 1 |
Smooth, differentiable |
"discrete" |
1 if p >= threshold else 0 |
Binary position sizing |
Book: Label Engineering Methods calls the meta-labeling and bet-sizing APIs on SPY.
Calendar-Aware Labels¶
For futures and other instruments with defined trading sessions, calendar-aware labeling respects session boundaries. A label that would span an overnight gap is handled correctly.
from ml4t.engineer.labeling import calendar_aware_labels
result = calendar_aware_labels(
data=df,
config=config, # LabelingConfig
calendar="CME_Equity", # "NYSE", "CME_Equity", etc.
price_col="close",
timestamp_col="timestamp",
)
The calendar prevents forward returns from crossing session breaks (e.g., CME overnight gap from 4:00 PM to 5:00 PM CT).
Sample Weighting¶
AFML Chapter 4 addresses the problem of overlapping labels creating correlated samples. The library provides the full toolkit:
Label Uniqueness¶
from ml4t.engineer.labeling import (
build_concurrency,
calculate_label_uniqueness,
calculate_sample_weights,
)
# Count how many labels overlap each bar
concurrency = build_concurrency(
event_indices=starts, # Label start indices
label_indices=ends, # Label end indices
n_bars=len(df),
)
# Average uniqueness per label (range [0, 1])
uniqueness = calculate_label_uniqueness(
event_indices=starts,
label_indices=ends,
n_bars=len(df),
)
# Combine uniqueness with return magnitude
weights = calculate_sample_weights(
uniqueness=uniqueness,
returns=returns_array,
weight_scheme="returns_uniqueness",
# Options: "returns_uniqueness", "uniqueness_only", "returns_only", "equal"
)
Sequential Bootstrap¶
The sequential bootstrap (AFML Chapter 4) draws samples while accounting for label overlap, producing a more independent training set:
from ml4t.engineer.labeling import sequential_bootstrap
selected = sequential_bootstrap(
starts=start_indices,
ends=end_indices,
n_bars=len(df),
n_draws=1000, # Number of samples to draw
with_replacement=True,
random_state=42,
)
# selected: array of indices for training
Integrated Computation¶
Triple-barrier labels can compute uniqueness and weights in a single call:
result = triple_barrier_labels(
df,
config=config,
calculate_uniqueness=True,
uniqueness_weight_scheme="returns_uniqueness",
)
# Result includes label_uniqueness and sample_weight columns
Label Statistics¶
Quick summary of label balance:
from ml4t.engineer.labeling import compute_label_statistics
stats = compute_label_statistics(df, label_col="label")
# Returns: {"n_samples", "n_positive", "n_negative", "n_neutral",
# "positive_ratio", "negative_ratio", "neutral_ratio"}
Book: Label Engineering Methods calls the sample-weighting and sequential-bootstrap APIs.
Time-Based Durations¶
All labeling functions that accept max_holding_period or horizon support duration strings in addition to bar counts:
# Bar-based (integer)
config = LabelingConfig.triple_barrier(max_holding_period=20)
# Time-based (duration string)
config = LabelingConfig.triple_barrier(max_holding_period="4h")
config = LabelingConfig.triple_barrier(max_holding_period="1d")
config = LabelingConfig.triple_barrier(max_holding_period="30m")
Supported Duration Formats¶
| Format | Example | Meaning |
|---|---|---|
| Minutes | "30m" |
30 minutes |
| Hours | "4h" |
4 hours |
| Days | "1d" |
1 day |
| Combined | "1h30m" |
1 hour 30 minutes |
Utility Functions¶
from ml4t.engineer.labeling.utils import (
is_duration_string, # Check: is_duration_string("4h") → True
parse_duration, # Parse: parse_duration("1h30m") → timedelta(hours=1, minutes=30)
time_horizon_to_bars, # Convert to per-event bar counts using timestamps
get_future_price_at_time, # Price at exact time offset
)
Time-based horizons require a timestamp_col in the input DataFrame.
Runtime behavior¶
Benchmark with your row count, horizon, labeling method, and hardware. Runtime and memory use depend on those inputs.
Best Practices¶
-
Match barriers to transaction costs: Barriers should exceed expected round-trip costs. A 0.1% barrier with 0.05% commission leaves little net profit.
-
Handle class imbalance: Triple-barrier often creates imbalanced labels (many vertical barrier hits). Check with
compute_label_statistics()and consider adjusting barrier levels or using sample weights. -
Prevent leakage with sample weighting: Overlapping labels create correlated training samples. Use
calculate_uniqueness=Trueorsequential_bootstrap()to address this. -
Use ATR barriers for multi-asset: Fixed barriers work for single-asset studies but fail across assets with different volatility levels.
-
Time-based horizons for irregular data: If your bars are not equally spaced (e.g., volume bars), use duration strings (
"4h") instead of bar counts to ensure consistent holding periods.
See It In The Book¶
- Label Engineering Methods calls Engineer's labeling APIs.
- ETFs: Label Engineering is a related workflow that does not call Engineer.
- Book Guide records the pinned revision and task mapping.
Next Steps¶
- Read Dataset Builder when labeled data moves into training and CV workflows.
- Read Preprocessing if labels are part of a broader feature preparation pipeline.
- Use the API Reference for the full labeling surface and config objects.
References¶
- Lopez de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. Chapters 3-4.
- Lopez de Prado, M. (2020). Machine Learning for Asset Managers. Cambridge.