Python & Data Science

Advances In Financial Machine Learning L Pez De Pr

You’ve seen the book on every quant reading list. It’s cited in job descriptions, referenced in conference talks, and name-dropped on trading floors. Yet when you crack open Advances in Financial Machine Learning by Marcos López de Prado, the density hits you like a wall of equations. You’re not alone. This book has a reputation: brilliant, groundbreaking, and incredibly hard to actually finish.

Here’s the paradox. The book matters because it was the first to treat financial machine learning as its own discipline—not just “take sklearn and throw it at stock prices.” The author isn’t some academic theorist who’s never touched real money. López de Prado holds two PhDs, led quantitative research at AQR and Guggenheim (managing $$13 billion in assets), and now serves as Global Head of Quantitative R&D at the Abu Dhabi Investment Authority and Visiting Professor at Cornell. When he talks about the dangers of backtesting, he’s speaking from hard-won institutional experience.

But here’s the catch: the book was written for institutional quant teams with massive compute resources. Most of us aren’t that. We’re data scientists or analytically-minded traders working on a laptop or a modest cloud instance, trying to figure out which 20% of the book delivers 80% of the value.

That’s exactly what this article is: a practical field guide. Not a summary—a filter. We’ll walk through the book’s structure, flag what’s gold and what’s niche, and give you a concrete reading order based on your goals. Along the way, we’ll implement the three most important ideas in Python so you can see them work on real data. Let’s dive in.

The Book’s Structure: A Map Before We Dive In

Before we start picking winners and losers, let’s get oriented. The book has five parts spread across 22 chapters, and each part tackles a different stage of the quant research pipeline.

Part 1: Data Analysis (Chapters 2–5). This is where López de Prado lays the foundation. He argues that most financial ML fails not because of bad models, but because of bad data preparation. You’ll learn how to structure financial data so it’s actually useful for machine learning, how to label observations without leaking the future, how to weight samples so your model pays attention to the right things, and how to engineer features that don’t fall apart when market regimes change.

Part 2: Modelling (Chapters 6–9). Now we get to the algorithms. But even here, the focus isn’t on fancy architectures—it’s on making standard ML techniques work in a financial context. Ensemble methods, cross-validation that respects time, feature importance that accounts for correlated predictors, and hyperparameter tuning that doesn’t overfit.

Part 3: Backtesting (Chapters 10–16). This is the longest and most opinionated section. López de Prado is deeply skeptical of how backtesting is usually done, and he spends six chapters explaining why most backtests are misleading and what to do about it. Bet sizing, synthetic data, strategy risk, and the now-famous warning that “backtesting while researching is like drinking and driving.”

Part 4: Useful Financial Features (Chapters 17–19). A shorter section on specialized topics: detecting structural breaks, using entropy-based measures, and extracting features from market microstructure. Interesting, but more niche.

Part 5: High-Performance Computing (Chapters 20–22). Multiprocessing, vectorization, and even quantum computing. Spoiler: unless you’re running a compute cluster, you can safely skim this part.

Here’s the spine that runs through the whole book: every single chapter is about preventing overfitting in a domain where noise absolutely dominates signal. Financial data has an abysmal signal-to-noise ratio. If you apply standard ML techniques naively, you will find patterns. They will look impressive. And they will be completely spurious. López de Prado’s mission is to give you the tools to avoid that fate.

The Gold: Chapters Worth Your Full Attention

Practitioners who’ve actually worked through this book consistently point to three chapters as its core contribution. If you read nothing else, read these.

Chapter 2: Financial Data Structures — Why Time Bars Are Dangerous

Think about how you normally see financial data. A candlestick chart with 5-minute bars. A daily OHLC table. This is so familiar that we rarely question it. But López de Prado argues that time-based bars are fundamentally problematic for machine learning.

The problem is that markets don’t operate on a clock—they operate on information flow. A 5-minute bar at 2:30 PM on a quiet Tuesday contains very different information than a 5-minute bar at the market open on FOMC day. The first might have 50 trades; the second might have 50,000. Yet your model treats them as equivalent observations. This introduces massive heteroscedasticity and makes the statistical properties of your data time-varying in ways that violate the assumptions of most ML algorithms.

The solution is to sample data based on activity, not time. Tick bars form a new bar every N trades. Volume bars form a new bar every N shares or contracts traded. Dollar bars form a new bar every N dollars of notional value exchanged. These information-driven bars produce series that are far more stationary—their statistical properties stay much more consistent over time.

This is the hardest part to internalize because it requires rethinking something so basic you probably never questioned it. But it’s also the foundation everything else builds on. If you get your data structure wrong, no amount of clever modeling downstream will save you.

Let’s see what this actually means in code. We’ll generate some synthetic tick data and compare time bars to dollar bars.

import numpy as np
import pandas as pd

# Generate synthetic tick data: 10,000 trades over ~8 hours
np.random.seed(42)
n_ticks = 10000

# Timestamps: trades cluster around market open and close
time_base = pd.date_range('2024-01-15 09:30', periods=n_ticks, freq='3s')
# Add some randomness to arrival times
arrival_noise = np.random.exponential(scale=2, size=n_ticks).astype(int)
timestamps = time_base + pd.to_timedelta(arrival_noise, unit='s')

# Prices: random walk with drift
returns = np.random.normal(0, 0.0001, n_ticks)
prices = 100 * np.exp(np.cumsum(returns))

# Volumes: log-normal, clustered (big trades come in bursts)
base_volume = np.random.lognormal(mean=4, sigma=1.5, size=n_ticks).astype(int) + 1
# Add volume clustering: every 100th trade is 10x larger
for i in range(0, n_ticks, 100):
    base_volume[i] *= 10

# Build tick DataFrame
tick_df = pd.DataFrame({
    'timestamp': timestamps,
    'price': prices,
    'volume': base_volume
}).sort_values('timestamp').reset_index(drop=True)

# Dollar value of each trade
tick_df['dollar_value'] = tick_df['price'] * tick_df['volume']

print(f"Generated {len(tick_df):,} ticks")
print(f"Price range: ${tick_df['price'].min():.2f} - ${tick_df['price'].max():.2f}")
print(f"Total dollar volume: ${tick_df['dollar_value'].sum():,.0f}")
print(tick_df.head(10))

Now let’s construct dollar bars. The idea: accumulate trades until the cumulative dollar value crosses a threshold, then form a bar and reset.

import numpy as np
import pandas as pd

# Recreate the tick data (self-contained)
np.random.seed(42)
n_ticks = 10000

time_base = pd.date_range('2024-01-15 09:30', periods=n_ticks, freq='3s')
arrival_noise = np.random.exponential(scale=2, size=n_ticks).astype(int)
timestamps = time_base + pd.to_timedelta(arrival_noise, unit='s')

returns = np.random.normal(0, 0.0001, n_ticks)
prices = 100 * np.exp(np.cumsum(returns))

base_volume = np.random.lognormal(mean=4, sigma=1.5, size=n_ticks).astype(int) + 1
for i in range(0, n_ticks, 100):
    base_volume[i] *= 10

tick_df = pd.DataFrame({
    'timestamp': timestamps,
    'price': prices,
    'volume': base_volume
}).sort_values('timestamp').reset_index(drop=True)
tick_df['dollar_value'] = tick_df['price'] * tick_df['volume']

# Build dollar bars with threshold of $500,000 notional per bar
threshold = 500_000
dollar_bars = []
cum_dollar = 0
bar_open = None
bar_high = -np.inf
bar_low = np.inf
bar_volume = 0
bar_start_time = None

for _, row in tick_df.iterrows():
    if bar_open is None:
        bar_open = row['price']
        bar_start_time = row['timestamp']
    
    cum_dollar += row['dollar_value']
    bar_high = max(bar_high, row['price'])
    bar_low = min(bar_low, row['price'])
    bar_volume += row['volume']
    
    if cum_dollar >= threshold:
        dollar_bars.append({
            'timestamp': row['timestamp'],
            'open': bar_open,
            'high': bar_high,
            'low': bar_low,
            'close': row['price'],
            'volume': bar_volume,
            'dollar_volume': cum_dollar
        })
        # Reset accumulators
        cum_dollar = 0
        bar_open = None
        bar_high = -np.inf
        bar_low = np.inf
        bar_volume = 0

dollar_bar_df = pd.DataFrame(dollar_bars)

# Now create comparable time bars (5-minute)
tick_df.set_index('timestamp', inplace=True)
time_bars = tick_df['price'].resample('5min').ohlc()
time_bars['volume'] = tick_df['volume'].resample('5min').sum()
time_bars.dropna(inplace=True)

print(f"Dollar bars created: {len(dollar_bar_df)}")
print(f"5-minute time bars created: {len(time_bars)}")
print(f"\nDollar bars - time between bars varies with activity:")
print(dollar_bar_df[['timestamp', 'close', 'dollar_volume']].head(10))

What do we see? The dollar bars are created at irregular time intervals. When trading is heavy, bars form quickly. When it’s quiet, they take longer. This means each bar represents a roughly constant amount of economic activity, not a constant amount of clock time. The statistical properties—volatility, autocorrelation, distribution of returns—will be far more consistent across dollar bars than across time bars. That consistency is what your ML model needs.

Chapter 3: Labeling — The Triple-Barrier Method

Labeling is the problem everyone forgets to think about. In standard ML, you have a target variable. In finance, what’s your target? “Future return over the next 5 days” seems natural, but it has a hidden flaw: it ignores the path.

Imagine two trades. Both end up +2% after 5 days. One went straight up. The other dropped 15% first, then rocketed back. A fixed-horizon label calls both “positive.” But in reality, the second trade would have been stopped out long before it recovered. Your label is lying to your model.

López de Prado’s triple-barrier method fixes this. Instead of a single fixed horizon, you set three barriers for each observation:

  1. Take-profit barrier: an upper threshold. If price hits this first, label it +1.
  2. Stop-loss barrier: a lower threshold. If price hits this first, label it -1.
  3. Time-out barrier: a maximum holding period. If neither price barrier is hit by this time, label it 0 (or the return at expiry).

The observation ends when any barrier is touched first. This respects the reality that real strategies have stops and targets, and that holding periods vary. It also produces labels that are much more informative for predicting directional moves.

Let’s implement it.

import numpy as np
import pandas as pd

# Generate a price series (random walk with drift)
np.random.seed(42)
n_bars = 1000
returns = np.random.normal(0.0001, 0.01, n_bars)
prices = 100 * np.exp(np.cumsum(returns))

# Create a DataFrame with our price bars
bars = pd.DataFrame({'close': prices})
bars['high'] = bars['close'] * (1 + np.abs(np.random.normal(0, 0.005, n_bars)))
bars['low'] = bars['close'] * (1 - np.abs(np.random.normal(0, 0.005, n_bars)))

# Triple-barrier labeling function
def triple_barrier_labels(bars, pt_factor=0.02, sl_factor=0.01, max_holding=20):
    """
    Apply triple-barrier labeling to a price series.
    
    For each bar, we look forward and check which barrier is hit first:
    - Upper barrier: entry_price * (1 + pt_factor)  -> label = 1
    - Lower barrier: entry_price * (1 - sl_factor)  -> label = -1
    - Time-out: max_holding bars later                     -> label = 0
    
    Uses high/low paths to check intra-bar barrier touches.
    """
    labels = np.full(len(bars), np.nan)
    closes = bars['close'].values
    highs = bars['high'].values
    lows = bars['low'].values
    
    for i in range(len(bars) - max_holding):
        entry_price = closes[i]
        upper = entry_price * (1 + pt_factor)
        lower = entry_price * (1 - sl_factor)
        
        hit_upper = False
        hit_lower = False
        
        # Look forward up to max_holding bars
        for j in range(1, max_holding + 1):
            if i + j >= len(bars):
                break
            
            # Check if barriers were touched during this bar
            if highs[i + j] >= upper:
                hit_upper = True
                break
            if lows[i + j] <= lower:
                hit_lower = True
                break
        
        if hit_upper and not hit_lower:
            labels[i] = 1
        elif hit_lower and not hit_upper:
            labels[i] = -1
        elif hit_upper and hit_lower:
            # Both hit in same bar — which came first? Use close as tiebreaker
            labels[i] = 1 if closes[i + j] >= entry_price else -1
        else:
            # Time-out: neither barrier hit
            labels[i] = 0
    
    return labels

bars['label'] = triple_barrier_labels(bars, pt_factor=0.02, sl_factor=0.01, max_holding=20)

# What do our labels look like?
label_counts = bars['label'].value_counts().sort_index()
print("Label distribution:")
for label, count in label_counts.items():
    label_name = {1: 'Take-profit hit', -1: 'Stop-loss hit', 0: 'Time-out'}[label]
    print(f"  {label_name}: {count} ({count/len(bars)*100:.1f}%)")

# Show some examples
print("\nSample labeled bars:")
print(bars[['close', 'label']].head(20))

Notice what’s happening. With a 2% profit target and 1% stop-loss, we’re giving our winners twice as much room as our losers. This asymmetry means we can be right less than half the time and still make money—but only if our labels capture the actual path. The triple-barrier method ensures they do. The time-out label (0) is especially valuable: it tells your model “this setup went nowhere,” which is genuinely different from “this setup lost money.”

Chapter 5: Fractionally Differentiated Features

Here’s a problem that plagues financial ML. Most price data is non-stationary—its statistical properties drift over time. A stock that traded at 20in2010and20 in 2010 and 200 in 2024 has completely different variance regimes. ML models assume stationarity, so feeding them raw prices is asking for trouble.

The standard fix is differencing: compute returns by subtracting yesterday’s price from today’s. This gives you an I(0) series that’s stationary. But there’s a cost: you lose all memory. A single difference wipes out any long-term relationships in the data. If there’s a slow-moving trend that’s actually predictive, you just erased it.

Fractional differencing finds the sweet spot. Instead of differencing by an integer order (d=1 for returns), you difference by a fraction (say, d=0.3). This preserves some memory—some of the long-term structure—while still achieving stationarity. Think of it as a dial you can turn: d=0 is raw prices (all memory, non-stationary), d=1 is returns (no memory, stationary), and d=0.4 is somewhere in between.

This is the hardest part of feature engineering in finance, and it’s genuinely elegant. Let’s implement it and see the effect.

import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller

# Generate a price series with a strong trend and some mean-reverting noise
np.random.seed(42)
n_points = 2000

# Trend component (non-stationary)
trend = np.linspace(0, 2, n_points)
# Cyclical component (stationary but with memory)
cycle = 0.3 * np.sin(np.linspace(0, 20 * np.pi, n_points))
# Noise
noise = np.random.normal(0, 0.1, n_points)

log_price = trend + cycle + noise
price = np.exp(log_price)

# Fractional differencing using binomial expansion weights
def fractional_difference(series, d, threshold=1e-5):
    """
    Fractionally difference a series.
    Uses the binomial expansion: (1-B)^d = sum_{k=0}^{inf} w_k * B^k
    where w_k = -w_{k-1} * (d - k + 1) / k
    Weights are truncated when they fall below threshold.
    """
    # Compute weights (capped at len(series)//4 terms -- for small d
    # values the binomial weights decay slowly and can need more terms
    # than the series has room for before crossing the threshold, which
    # would otherwise leave zero valid output points)
    weights = [1.0]
    k = 1
    max_weights = len(series) // 4
    while k < max_weights:
        w = -weights[-1] * (d - k + 1) / k
        if abs(w) < threshold:
            break
        weights.append(w)
        k += 1
    
    weights = np.array(weights)
    
    # Apply fractional difference via convolution
    # The result at time t is: series[t] - w1*series[t-1] - w2*series[t-2] - ...
    n = len(series)
    result = np.full(n, np.nan)
    
    for i in range(len(weights) - 1, n):
        window = series[i - len(weights) + 1 : i + 1][::-1]
        result[i] = np.dot(weights[:len(window)], window)
    
    return result, weights

# Try different differencing orders
d_orders = [0.0, 0.3, 0.5, 0.7, 1.0]
results = {}

for d in d_orders:
    diffed, weights = fractional_difference(price, d)
    # Drop NaN values for ADF test
    valid = diffed[~np.isnan(diffed)]
    adf_stat, adf_pvalue, *_ = adfuller(valid, maxlag=1, regression='c', autolag=None)
    results[d] = {
        'series': diffed,
        'n_weights': len(weights),
        'adf_pvalue': adf_pvalue,
        'is_stationary': adf_pvalue < 0.05
    }
    print(f"d={d:.1f}: ADF p-value={adf_pvalue:.4f}, Stationary={adf_pvalue < 0.05}, Weights used={len(weights)}")

# Show the raw price and fractionally differenced series
print(f"\nRaw price (first 10): {price[:10].round(3)}")
print(f"Frac diff d=0.3 (first 10 valid): {results[0.3]['series'][~np.isnan(results[0.3]['series'])][:10].round(4)}")
print(f"Returns d=1.0 (first 10): {results[1.0]['series'][~np.isnan(results[1.0]['series'])][:10].round(4)}")

Look at those ADF p-values. At d=0 (raw prices), the series is non-stationary—the p-value is high, meaning we can’t reject the null hypothesis of a unit root. At d=1 (standard returns), it’s strongly stationary but we’ve lost all memory. At d=0.3 or 0.5, we achieve stationarity while retaining some of the long-term structure. That retained memory is what gives your model an edge—it can learn from slow-moving relationships that pure returns would erase.

The Solid Supporting Cast: Chapters That Depend on Your Context

These chapters are genuinely valuable, but whether they’re worth your time depends on what you’re building.

Chapter 7: Cross-Validation in Finance — Purging and Embargoing

If you’ve ever used KFold from scikit-learn on financial data, you’ve leaked information. Here’s why.

Standard k-fold CV randomly splits your data into k chunks, trains on k-1, and tests on the held-out fold. In time series, this is disastrous for two reasons. First, if you don’t purge, training observations that immediately precede a test observation leak information—at 9:59 AM you “know” something about 10:00 AM because markets don’t reset. Second, if you don’t embargo, you might train on data that comes after your test data, which is the cardinal sin of time-series modeling.

López de Prado’s solution is purged k-fold cross-validation. You purge: remove training observations that overlap in time with the test set’s immediate neighborhood. You embargo: remove training observations that come after the test set. The result is a CV scheme that actually respects the arrow of time.

Let’s implement it and compare it to naive CV.

import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

# Generate synthetic financial data with temporal structure
np.random.seed(42)
n_samples = 1000

# Create features that have some predictive power, but with temporal decay
X = np.random.randn(n_samples, 5)
# Target depends on features with a 5-period lag (temporal structure!)
y = np.zeros(n_samples, dtype=int)
for i in range(5, n_samples):
    signal = 0.3 * X[i-5, 0] + 0.2 * X[i-3, 1]
    y[i] = 1 if signal > 0 else 0

# Purged k-fold CV implementation
def purged_kfold_cv(X, y, n_splits=5, purge_size=10, embargo_size=5):
    """
    Purged walk-forward cross-validation.
    
    - Data is split sequentially (no shuffling)
    - purge_size observations before each test fold are removed from training
    - embargo_size observations after each test fold are removed from training
    """
    n = len(y)
    fold_size = n // n_splits
    scores = []
    
    for fold in range(n_splits):
        test_start = fold * fold_size
        test_end = min((fold + 1) * fold_size, n)
        
        # Test indices
        test_idx = np.arange(test_start, test_end)
        
        # Training indices: everything except test, purge zone, and embargo zone
        train_idx = []
        for i in range(n):
            # Skip if in test set
            if test_start <= i < test_end:
                continue
            # Skip if in purge zone (just before test)
            if test_start - purge_size <= i < test_start:
                continue
            # Skip if in embargo zone (just after test)
            if test_end <= i < test_end + embargo_size:
                continue
            train_idx.append(i)
        
        train_idx = np.array(train_idx)
        
        if len(train_idx) == 0 or len(test_idx) == 0:
            continue
        
        # Train and evaluate
        model = RandomForestClassifier(n_estimators=50, random_state=42)
        model.fit(X[train_idx], y[train_idx])
        preds = model.predict(X[test_idx])
        scores.append(accuracy_score(y[test_idx], preds))
    
    return np.mean(scores), np.std(scores)

# Naive k-fold (what you should NOT do)
def naive_kfold_cv(X, y, n_splits=5):
    """Standard shuffled k-fold — leaks temporal information."""
    from sklearn.model_selection import KFold
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    scores = []
    
    for train_idx, test_idx in kf.split(X):
        model = RandomForestClassifier(n_estimators=50, random_state=42)
        model.fit(X[train_idx], y[train_idx])
        preds = model.predict(X[test_idx])
        scores.append(accuracy_score(y[test_idx], preds))
    
    return np.mean(scores), np.std(scores)

purged_mean, purged_std = purged_kfold_cv(X, y, n_splits=5, purge_size=10, embargo_size=5)
naive_mean, naive_std = naive_kfold_cv(X, y, n_splits=5)

print(f"Purged walk-forward CV accuracy: {purged_mean:.3f} (+/- {purged_std:.3f})")
print(f"Naive shuffled k-fold accuracy:   {naive_mean:.3f} (+/- {naive_std:.3f})")
print(f"\nNaive CV overstates performance by: {(naive_mean - purged_mean)*100:.1f} percentage points")
print("This gap is the cost of temporal leakage.")

Naive CV gives you a higher accuracy number. It feels good. But it’s a lie—the model is cheating by peeking at temporally adjacent data. Purged CV gives you a lower, more honest estimate. In live trading, the honest number is the one that matters.

Chapter 16: Hierarchical Risk Parity — The Most Controversial Chapter

No chapter in the book divides practitioners more sharply. Hierarchical Risk Parity (HRP) is López de Prado’s alternative to mean-variance optimization. The problem with mean-variance is well-known: it requires inverting a covariance matrix, and when assets are correlated (which they always are), small estimation errors in that matrix produce wildly unstable weights. HRP sidesteps this by using hierarchical clustering on the correlation matrix, then allocating risk from the top down.

Some practitioners love it. Rob Carver, author of Systematic Trading, calls it a personal favorite. Others are skeptical. The Reasonable Deviations blog notes that HRP “is not the panacea implied” and that simpler methods often work just as well. This is a genuine disagreement among smart people who’ve run real money.

Here’s a minimal implementation so you can see what HRP actually does.

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import linkage, dendrogram
from scipy.spatial.distance import squareform

# Generate correlated asset returns
np.random.seed(42)
n_assets = 8
n_periods = 500

# Create a correlation structure: assets cluster in two groups
base_returns = np.random.randn(n_periods, 2)  # Two latent factors
group1_weights = np.random.uniform(0.5, 1.5, (4, 2))
group2_weights = np.random.uniform(0.5, 1.5, (4, 2))

returns_group1 = base_returns @ group1_weights.T + 0.3 * np.random.randn(n_periods, 4)
returns_group2 = base_returns @ group2_weights.T + 0.3 * np.random.randn(n_periods, 4)
returns = np.hstack([returns_group1, returns_group2])

# Compute correlation matrix
corr = np.corrcoef(returns.T)

# Hierarchical Risk Parity (minimal implementation)
def hrp_weights(returns):
    """
    Compute Hierarchical Risk Parity portfolio weights.
    
    1. Cluster assets using hierarchical clustering on the correlation distance
    2. Perform recursive bisection: at each split, allocate risk inversely 
       proportional to the cluster variance
    """
    corr_mat = np.corrcoef(returns.T)
    # Distance matrix: sqrt(0.5 * (1 - rho))
    dist = np.sqrt(0.5 * (1 - corr_mat))
    # Condensed distance matrix for linkage
    condensed_dist = squareform(dist, checks=False)
    
    # Hierarchical clustering
    link = linkage(condensed_dist, method='single')
    
    # Recursive bisection
    n = returns.shape[1]
    weights = np.ones(n)
    clusters = [list(range(n))]  # Start with one cluster containing all assets
    
    # This is simplified; a full implementation would traverse the linkage tree
    # For demonstration, we'll compute inverse-variance weights within each cluster
    # and equal-weight between clusters
    
    # Find the two main clusters from the top split
    # The last merge in linkage joins the two main clusters
    cluster_labels = np.zeros(n, dtype=int)
    
    # Simplified: use the dendrogram structure to find two main groups
    from scipy.cluster.hierarchy import fcluster
    cluster_labels = fcluster(link, t=2, criterion='maxclust') - 1
    
    # Within each cluster, allocate inversely proportional to variance
    final_weights = np.zeros(n)
    for cluster_id in range(2):
        cluster_mask = cluster_labels == cluster_id
        cluster_returns = returns[:, cluster_mask]
        # Inverse variance weights within cluster
        cluster_var = np.var(cluster_returns, axis=0)
        inv_var = 1 / cluster_var
        cluster_weights = inv_var / inv_var.sum()
        
        # Between clusters: equal risk allocation (50/50)
        final_weights[cluster_mask] = 0.5 * cluster_weights
    
    return final_weights

# Compare HRP to naive equal-weight and inverse-variance
hrp_w = hrp_weights(returns)
equal_w = np.ones(n_assets) / n_assets
inv_var_w = 1 / np.var(returns, axis=0)
inv_var_w = inv_var_w / inv_var_w.sum()

print("Portfolio weights comparison:")
print(f"{'Asset':<8} {'HRP':<10} {'Equal':<10} {'Inv-Var':<10}")
for i in range(n_assets):
    print(f"{i:<8} {hrp_w[i]:<10.3f} {equal_w[i]:<10.3f} {inv_var_w[i]:<10.3f}")

# Compute portfolio volatilities
hrp_vol = np.sqrt(np.var(returns @ hrp_w))
equal_vol = np.sqrt(np.var(returns @ equal_w))
inv_var_vol = np.sqrt(np.var(returns @ inv_var_w))

print(f"\nPortfolio volatility:")
print(f"  HRP:          {hrp_vol:.4f}")
print(f"  Equal-weight: {equal_vol:.4f}")
print(f"  Inv-variance: {inv_var_vol:.4f}")

HRP produces weights that respect the correlation structure: assets within the same cluster get similar treatment, and risk is balanced across clusters. Whether this outperforms simpler methods in practice depends on your universe and rebalancing frequency. The point isn’t that HRP is always better—it’s that it avoids the catastrophic failure mode of mean-variance when your covariance estimates are noisy. And in finance, they’re always noisy.

What to Skip (or Skim): Chapters for a Different Audience

Let’s be honest about what’s low-ROI for most readers.

Chapters 20–22 (High-Performance Computing). Multiprocessing, vectorization, and quantum computing. The Reasonable Deviations review puts it bluntly: these chapters are “not very applicable to small players.” If you’re running a compute cluster with hundreds of cores, you’ll find value here. If you’re on a laptop or a single cloud instance, skim for awareness and move on. The quantum computing chapter was speculative in 2018 and hasn’t aged into practicality.

Chapters 17–19 (Structural Breaks, Entropy, Microstructural Features). These are interesting ideas from a research perspective. Detecting when market regimes change, measuring information content with entropy, extracting signals from order book dynamics. But they’re niche. If you work with high-frequency data or market microstructure, they’re worth a read. If you’re working with daily data and standard factors, they’re low priority.

The book’s blind spots. It assumes you have institutional resources—data feeds, compute, and a team. It doesn’t address transaction costs in depth, which is a critical omission for anyone actually trading. And as the Risky Finance review warns, a reader looking for a “get rich quick” shortcut “will find themselves heavily disheartened.” This is a reference for professionals, not a playbook.

A Practical Reading Order Based on Your Goals

Here’s the actionable part. Based on what practitioners consistently flag as high-value, here are three reading paths.

Path 1: “I want to build better features for my existing models.” Read: Chapter 2 (Data Structures) → Chapter 3 (Labeling) → Chapter 5 (Fractional Differencing) → Chapter 7 (Cross-Validation). This is the data-to-features pipeline. Master these four chapters and your input data will be dramatically better than 90% of what’s out there.

Path 2: “I want to backtest strategies without fooling myself.” Read: Chapter 2 (Data Structures) → Chapter 7 (Cross-Validation) → Chapter 10 (Bet Sizing) → Chapter 12 (Backtesting via CV) → Chapter 14 (Strategy Risk). This is the anti-overfitting backbone. It won’t make your strategies more profitable, but it will stop you from believing in strategies that never actually worked.

Path 3: “I want the full institutional quant stack.” Read cover to cover. But supplement Chapter 16 (HRP) with other sources—the practitioner community is genuinely split on this one. Treat Chapters 20–22 as reference material, not required reading.

For everyone: read Chapter 1 first. It’s short and it frames why financial ML is fundamentally different from standard ML. That mental model is worth the 15 minutes.

What the Book Doesn’t Cover (and Where to Go Next)

Advances in Financial Machine Learning was published in 2018, and the field hasn’t stood still. The book is light on causal inference—a gap López de Prado himself recognized and addressed in his 2023 book Causal Factor Investing (Cambridge University Press). If you want to move from prediction to understanding why factors work, that’s your next stop.

His 2020 book Machine Learning for Asset Managers (Cambridge University Press) is a shorter, more accessible companion. It distills many of the key ideas from AFML into a tighter package. If AFML feels overwhelming, start there.

The book also predates the transformer revolution. You won’t find attention mechanisms or modern deep learning for time series. Its strength is in classical ensemble methods and feature engineering—areas where finance’s low signal-to-noise ratio makes simplicity a virtue.

For the solo practitioner: the HPC chapters are skippable. But the data-structuring and labeling chapters (2 and 3) are genuinely transformative even on a laptop. They change how you think about financial data, and that change in perspective is worth more than any specific technique.

Recap

  • Chapter 2 teaches you to stop using time bars and start using information-driven bars. This alone fixes a fundamental statistical problem most people don’t know they have.
  • Chapter 3 gives you the triple-barrier labeling method, which respects the path-dependency of financial returns and produces labels your model can actually learn from.
  • Chapter 5 introduces fractional differencing, letting you make price data stationary without destroying its long-term memory.
  • Chapter 7 shows you how to do cross-validation that doesn’t leak temporal information—essential for honest model evaluation.
  • Chapter 16 offers an alternative to mean-variance optimization that’s more stable, though practitioners disagree on how much it actually helps.
  • Chapters 20–22 are for institutional teams with compute clusters. Most of us can skim them.

Check Your Understanding

Remember: What are the three barriers in the triple-barrier labeling method?

Understand: Why do standard time bars create problems for ML models, and how do dollar bars help?

Apply: Given a DataFrame of tick data with columns timestamp, price, and volume, write pseudocode to construct volume bars with a threshold of 1,000 contracts.

Analyze: Why might Hierarchical Risk Parity produce more stable portfolio weights than mean-variance optimization?

Evaluate: A colleague says “I just use standard 5-fold CV on my stock return data.” What specific leakage problems would you flag, and which chapter of AFML would you point them to?

Create: Design a labeling scheme for a mean-reversion strategy that uses a profit-taking barrier of 2%, a stop-loss of 1%, and a maximum holding period of 5 days. What would the labels look like for a stock that: (a) rises 3% on day 2, (b) falls 0.5% and stays flat for 5 days, (c) falls 1.5% on day 3 then recovers?

  • Causal Factor Investing (López de Prado, 2023): The natural next step after AFML—moves from predicting returns to understanding the causal mechanisms behind factor performance.
  • Machine Learning for Asset Managers (López de Prado, 2020): A shorter, more accessible distillation of AFML’s core ideas, ideal if the original book feels overwhelming.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans
  • Python Engineering Under review

    Refactoring A God Class Into Four Single Responsib

    Before we touch anything, let’s look at what we’re dealing with. The `DataProcessor` class has four groups of methods, each doing a completely different job:

  • Python Engineering Under review

    Multi Armed Bandits The Simplest Reinforcement Lea

    Let's start with the name. A "one-armed bandit" is slang for a slot machine — you pull the lever (the arm), and you either win or lose. A "multi-armed bandit" is a row of those machines.

  • Python Engineering Under review

    Q Learning From Scratch Teaching An Agent To Navig

    Here's the setup. You have a 4x4 grid: - **S**: Start position (top-left corner) - **F**: Frozen surface (safe to walk on) - **H**: Hole (fall in and you lose) - **G**: Goal (the treasure, bottom-right corner)

  • Python Engineering Under review

    Machine Learning In Marketing Churn Segmentation A

    Churn prediction sounds simple: build a binary classifier that predicts whether a customer will leave. But here's the catch — the hard part isn't the model. It's defining 'churn' and avoiding data leakage.

Looking for something else?

Search every article by title, summary or topic.