Machine Learning in Trading Strategy Design › ML for Signal Generation

Michael BrenndoerferJanuary 3, 202615 min read
Quantitative FinanceAll chapters
Part 13 of 15Machine Learning and Alternative Data
Chapter 70Machine Learning in Trading Strategy Design › ML for Signal Generation
Reading section 2 of 14 · ML for Signal Generation · 15 minSection 2 of 14 · 15 min

Book contents

Section 2 of 14 · 15 min read

Machine Learning in Trading Strategy Design › ML for Signal GenerationOne section · Sections in this chapter2 / 14 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Signal generation is the process of transforming raw market data and features into predictions that drive trading decisions. Building on the feature engineering concepts from the previous chapter, we now focus on constructing complete prediction pipelines that generate tradable signals.

Return Prediction with Supervised Learning

The most direct application of supervised learning in trading is predicting future returns. To understand why this problem is so challenging, consider what we're attempting: given a feature vector xt\mathbf{x}_t observed at time tt, we want to predict the return rt+hr_{t+h} over some horizon hh. This seemingly simple objective is difficult because financial returns are among the least predictable quantities in any domain. Markets aggregate information from millions of participants, each acting on their own analysis, making prices highly efficient and prediction exceptionally difficult.

This prediction task can be framed as either regression (predicting the magnitude of returns) or classification (predicting whether returns will be positive or negative). The choice changes how a strategy sizes positions and handles noise. Regression provides continuous predictions that can be used for position sizing, allowing you to bet more heavily when the model predicts larger returns. However, return distributions are notoriously noisy and heavy-tailed, making accurate point predictions extraordinarily difficult. The distribution of daily returns has fat tails and exhibits volatility clustering, meaning that the assumption of normal, well-behaved errors underlying many regression models is violated.

Classification simplifies the problem to directional prediction, asking only whether the next return will be positive or negative. This binary framing discards information about magnitude but may be less sensitive to the noise inherent in return data. In practice, many strategies use classification with probability outputs, where the predicted probability is a signal strength measure. A model that predicts a 70% probability of positive returns conveys more conviction than one predicting 52%, even though both would be classified as "positive." This probability can then inform position sizing decisions.

Let's build a complete signal generation pipeline for equity returns:

In[2]:
Code
import warnings

import numpy as np
import pandas as pd

warnings.filterwarnings("ignore")

np.random.seed(42)

# Generate synthetic price data for demonstration
n_days = 2000
dates = pd.date_range("2018-01-01", periods=n_days, freq="B")

# Create correlated asset returns with some predictable structure
market_returns = np.random.normal(0.0003, 0.012, n_days)
idiosyncratic = np.random.normal(0, 0.015, n_days)
momentum_signal = np.zeros(n_days)

# Add momentum effect: past returns predict future returns
for i in range(21, n_days):
    momentum_signal[i] = np.mean(market_returns[i - 21 : i]) * 0.3

stock_returns = (
    0.8 * market_returns
    + idiosyncratic
    + momentum_signal
    + np.random.normal(0, 0.008, n_days)
)
prices = 100 * np.exp(np.cumsum(stock_returns))

df = pd.DataFrame(
    {
        "date": dates,
        "price": prices,
        "return": stock_returns,
        "market_return": market_returns,
    }
).set_index("date")
In[3]:
Code
def create_features(df, lookback_windows=[5, 10, 21, 63]):
    """
    Create technical features for return prediction.
    Uses only past information to avoid lookahead bias.
    """
    features = pd.DataFrame(index=df.index)

    # Momentum features: past returns over various windows
    for window in lookback_windows:
        features[f"momentum_{window}d"] = df["return"].rolling(window).sum()
        features[f"volatility_{window}d"] = df["return"].rolling(window).std()

    # Mean reversion signal: deviation from moving average
    for window in [21, 63]:
        ma = df["price"].rolling(window).mean()
        features[f"ma_deviation_{window}d"] = (df["price"] - ma) / ma

    # Volume-weighted features (simulated volume)
    df["volume"] = np.random.lognormal(10, 0.5, len(df)) * (
        1 + df["return"].abs()
    )
    features["volume_ratio"] = df["volume"] / df["volume"].rolling(21).mean()

    # Higher moments: skewness and kurtosis of recent returns
    features["skewness_21d"] = df["return"].rolling(21).skew()
    features["kurtosis_21d"] = df["return"].rolling(21).kurt()

    # Market beta (rolling regression coefficient)
    def rolling_beta(returns, market_returns, window):
        cov = returns.rolling(window).cov(market_returns)
        var = market_returns.rolling(window).var()
        return cov / var

    features["beta_63d"] = rolling_beta(df["return"], df["market_return"], 63)

    return features


features = create_features(df)

The feature set combines momentum indicators, volatility measures, mean-reversion signals, and market sensitivity metrics. Each of these feature categories captures a different aspect of market behavior that academic research and practitioner experience have identified as potentially predictive. Momentum features exploit the tendency for past winners to continue outperforming in the short term. Volatility features capture the risk environment, since returns may behave differently during calm versus turbulent periods. Mean-reversion signals detect when prices have strayed from their recent average, potentially setting up for a reversal. Market beta measures each asset's sensitivity to broad market movements, which can inform both directional predictions and risk management. each feature uses only information available at time tt to predict returns at t+1t+1, so no lookahead bias contaminates the model.

In[4]:
Code
from sklearn.preprocessing import StandardScaler

# Create target variable: next-day return direction
df["target"] = (df["return"].shift(-1) > 0).astype(int)

# Combine features and target, dropping rows with NaN values
data = features.join(df[["target", "return"]]).dropna()

# Split into training and test sets using temporal ordering
# Use first 70% for training, last 30% for testing
split_idx = int(len(data) * 0.7)
train_data = data.iloc[:split_idx]
test_data = data.iloc[split_idx:]

feature_cols = features.columns.tolist()
X_train = train_data[feature_cols]
y_train = train_data["target"]
X_test = test_data[feature_cols]
y_test = test_data["target"]

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
In[5]:
Code
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, roc_auc_score

# Train multiple models for comparison
models = {
    "Logistic Regression": LogisticRegression(C=0.1, max_iter=1000),
    "Random Forest": RandomForestClassifier(
        n_estimators=100, max_depth=5, min_samples_leaf=20
    ),
    "Gradient Boosting": GradientBoostingClassifier(
        n_estimators=100, max_depth=3, learning_rate=0.05
    ),
}

results = {}
predictions = {}

for name, model in models.items():
    model.fit(X_train_scaled, y_train)

    # Get probability predictions for the positive class
    y_pred_proba = model.predict_proba(X_test_scaled)[:, 1]
    y_pred = (y_pred_proba > 0.5).astype(int)

    results[name] = {
        "accuracy": accuracy_score(y_test, y_pred),
        "precision": precision_score(y_test, y_pred),
        "auc": roc_auc_score(y_test, y_pred_proba),
    }
    predictions[name] = y_pred_proba
Out[6]:
Console
Model Performance Comparison (Out-of-Sample)
=======================================================

Logistic Regression:
  Accuracy:  0.495
  Precision: 0.499
  AUC-ROC:   0.494

Random Forest:
  Accuracy:  0.510
  Precision: 0.511
  AUC-ROC:   0.502

Gradient Boosting:
  Accuracy:  0.500
  Precision: 0.503
  AUC-ROC:   0.500
Out[7]:
Visualization
ROC curve chart with True Positive Rate on y-axis and False Positive Rate on x-axis, comparing three classifiers against the random baseline diagonal.
ROC curves for three classification models (Logistic Regression, Random Forest, Gradient Boosting). Their AUC values cluster around the 0.5 random baseline in this synthetic out-of-sample run, illustrating how difficult directional equity-return forecasting can be.

The results show how difficult return prediction is: accuracy barely exceeds 50%. This isn't a failure of the models or an indication that better algorithms would solve the problem. Instead, it reflects the fundamental difficulty of predicting financial returns. Markets are highly competitive, and any easily exploitable signal gets arbitraged away as sophisticated traders discover and trade on it. The efficient market hypothesis, while not perfectly descriptive, captures an important truth: prices incorporate information quickly, leaving little room for systematic prediction.

The AUC scores slightly above 0.5 indicate that the models capture some predictive information, but the signal-to-noise ratio is extremely low. To put this in perspective, an AUC of 0.52 means that if you randomly select a positive case (day with positive returns) and a negative case, the model ranks the positive case higher only 52% of the time. This slim edge might seem negligible, but when applied consistently across many assets and time periods, even small advantages compound into meaningful returns. The challenge lies in extracting this weak signal reliably without overfitting to noise.

Converting Predictions to Trading Signals

Raw model predictions must be transformed into actionable trading signals before they can generate trades. This transformation step involves deciding the direction of trades, their size, and the conditions under which to trade at all. A common approach uses predicted probabilities to determine both direction and position size:

In[8]:
Code
def predictions_to_signals(
    probabilities, threshold_long=0.55, threshold_short=0.45
):
    """
    Convert probability predictions to trading signals.

    Parameters:

    - probabilities: Predicted probability of positive return
    - threshold_long: Probability above which to go long
    - threshold_short: Probability below which to go short

    Returns:

    - signals: -1 (short), 0 (no position), +1 (long)
    - position_sizes: Scaled by confidence (distance from 0.5)
    """
    signals = np.zeros(len(probabilities))
    signals[probabilities > threshold_long] = 1
    signals[probabilities < threshold_short] = -1

    # Scale position size by confidence
    # Maximum confidence at probability 0 or 1, minimum at 0.5
    confidence = np.abs(probabilities - 0.5) * 2
    position_sizes = signals * confidence

    return signals, position_sizes


# Apply to gradient boosting predictions
signals, position_sizes = predictions_to_signals(
    predictions["Gradient Boosting"]
)
Out[9]:
Console
Signal Distribution:
  Long signals:  221 (38.0%)
  Short signals: 163 (28.0%)
  No position:   198 (34.0%)

Average position size when active: 0.239
Out[10]:
Visualization
Line chart of predicted probability over 150 time steps with horizontal dashed threshold lines at 0.55 and 0.45.
Predicted probability of positive returns over time relative to trading thresholds. The green (0.55) and red (0.45) dashed lines define high-conviction zones, allowing the strategy to filter out low-confidence signals near the 0.5 neutral level and trade only when the model indicates a strong directional edge.
Out[11]:
Visualization
Bar chart of position sizes over 150 time steps with green bars for long positions and red bars for short positions.
Trading position sizes dynamically scaled by model confidence. Green bars represent long positions and red bars represent short positions, with magnitudes proportional to the distance of the predicted probability from 0.5. This sizing mechanism allocates more capital to high-conviction signals while reducing exposure during uncertain periods.

The threshold approach embodies an important principle: not every prediction deserves a trade. By filtering out low-conviction predictions (those close to 50% probability), the strategy only takes positions when the model has sufficient confidence. This filtering serves multiple purposes. It reduces turnover and the associated transaction costs, which can erode returns quickly in frequent-trading strategies. It focuses capital on the highest-quality signals where the model's edge is most pronounced. And it acknowledges the reality that predictions near the decision boundary are essentially random guesses dressed up with spurious precision.

The position sizing component extends this logic further. Rather than taking equal-sized positions regardless of conviction, the strategy scales position size by the model's confidence, measured as the distance from 50%. A prediction of 70% positive return leads to a larger position than a prediction of 56%, which reflects the intuition that stronger signals warrant more capital allocation. This confidence-weighted sizing can materially improve risk-adjusted returns by ensuring that the portfolio's exposure concentrates in the model's highest-conviction bets.

Evaluating Signal Quality

Beyond classification metrics like accuracy and AUC, we need to evaluate how signals translate into actual trading performance. The connection between prediction quality and trading profit is not straightforward. A model might achieve 55% accuracy, but if its correct predictions tend to coincide with small moves while incorrect predictions coincide with large moves, the trading results could be disappointing.

The information coefficient (IC), which we discussed in Part IV, measures the correlation between predicted and realized returns. This metric directly captures whether the model's signal strength corresponds to return magnitude:

In[12]:
Code
def evaluate_signals(signals, actual_returns):
    """
    Evaluate trading signal quality with multiple metrics.
    """
    # Information Coefficient: correlation between signal and actual return
    ic = np.corrcoef(signals, actual_returns)[0, 1]

    # Hit rate: percentage of correct directional predictions
    # Only count positions where we had a signal
    active_mask = signals != 0
    if active_mask.sum() > 0:
        correct_direction = np.sign(signals[active_mask]) == np.sign(
            actual_returns[active_mask]
        )
        hit_rate = correct_direction.mean()
    else:
        hit_rate = np.nan

    # Profit factor: gross profits / gross losses
    returns_from_signal = signals * actual_returns
    gross_profit = returns_from_signal[returns_from_signal > 0].sum()
    gross_loss = -returns_from_signal[returns_from_signal < 0].sum()
    profit_factor = gross_profit / gross_loss if gross_loss > 0 else np.inf

    return {
        "information_coefficient": ic,
        "hit_rate": hit_rate,
        "profit_factor": profit_factor,
    }


# Evaluate signals against actual next-day returns
actual_returns = test_data["return"].shift(-1).iloc[:-1].values
eval_signals = signals[:-1]  # Align with returns

signal_quality = evaluate_signals(eval_signals, actual_returns)
Out[13]:
Console
Signal Quality Metrics:
  Information Coefficient: -0.0478
  Hit Rate:                49.1%
  Profit Factor:           0.87

The information coefficient is small but positive, indicating weak predictive power. Understanding what these numbers mean in context is essential. In the world of trading, even an IC of 0.02 to 0.05 can be valuable when applied across many assets and combined with appropriate leverage. Consider a strategy trading 500 stocks daily: with an IC of 0.03, the model provides a small but consistent edge on each trade that, when aggregated across the entire portfolio, can generate attractive returns.

The profit factor above 1.0 provides complementary information. It tells us that the strategy generates more profit on winning trades than it loses on losing trades. A profit factor of 1.2, for instance, means that for every dollar lost on losing trades, the strategy earns $1.20 on winning trades. Combined with the hit rate, these metrics paint a complete picture of strategy economics: how often we win, how much we win when we're right, and how much we lose when we're wrong.

Regime-Aware Signal Generation

Market conditions change over time, and a model trained on one regime may fail spectacularly in another. During a strong bull market, momentum strategies thrive as trends persist. During volatile sideways markets, mean-reversion strategies often perform better. A model that doesn't account for these regime shifts will apply momentum signals during ranging markets and mean-reversion signals during trends, suffering losses in both cases.

Regime-aware approaches address this challenge by first identifying the current market state, then adjusting predictions based on the detected regime:

In[14]:
Code
from sklearn.mixture import GaussianMixture


def detect_regime(returns, volatility, n_regimes=3):
    """
    Detect market regime using Gaussian Mixture Model on returns and volatility.
    """
    # Prepare features for regime detection
    regime_features = np.column_stack(
        [returns.rolling(21).mean().values, volatility.values]
    )

    # Remove NaN values
    valid_mask = ~np.isnan(regime_features).any(axis=1)

    # Fit GMM
    gmm = GaussianMixture(
        n_components=n_regimes, random_state=42, covariance_type="full"
    )
    regimes = np.full(len(returns), np.nan)
    regimes[valid_mask] = gmm.fit_predict(regime_features[valid_mask])

    return regimes, gmm


# Detect regimes in test data
test_volatility = test_data["volatility_21d"]
test_returns_series = test_data["return"]
regimes, gmm = detect_regime(test_returns_series, test_volatility)
Out[15]:
Visualization
Scatter plot of market regimes with 21-day momentum on the x-axis and 21-day volatility on the y-axis, colored by regime.
Market regime classification using a Gaussian Mixture Model based on 21-day momentum and volatility. The scatter plot reveals distinct clusters corresponding to trending states (low volatility), stressed markets (high volatility), and transitional periods. These detected regimes allow the strategy to adapt its signal generation logic to prevailing market conditions.
Out[16]:
Visualization
Time series scatter plot of regime labels (0, 1, 2) over date, showing transitions between market states.
Time series evolution of detected market regimes over the test period. The distinct bands show how the market transitions between stable, trending, and volatile states, enabling the strategy to dynamically switch between momentum and mean-reversion logic based on the active regime.

Regime detection enables strategies to adapt their behavior to current market conditions. In practice, you might use momentum signals more aggressively in trending regimes, where past returns have historically predicted future returns, while switching to mean-reversion signals in ranging regimes, where prices oscillate around stable levels. The regime model is a meta-layer that conditions the primary prediction model, effectively learning "when" different patterns apply rather than just "what" patterns exist.

The Gaussian Mixture Model approach shown here identifies regimes based on the joint distribution of recent returns and volatility. The three identified regimes typically correspond to distinct market states: calm trending periods with low volatility and consistent directional movement, high-volatility periods often associated with market stress or major uncertainty, and transitional periods that don't fit neatly into either category. By recognizing which regime the market currently occupies, the strategy can apply the most appropriate trading rules for that environment. The next chapter on alternative data explores how external information can improve regime identification beyond what price data alone reveals.