Machine Learning in Trading Strategy Design › ML for Signal Generation
Quantitative FinanceAll chapters 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 observed at time , we want to predict the return over some horizon . 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:
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")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 to predict returns at , so no lookahead bias contaminates the model.
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)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_probaModel 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

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:
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"]
)Signal Distribution: Long signals: 221 (38.0%) Short signals: 163 (28.0%) No position: 198 (34.0%) Average position size when active: 0.239


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:
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)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:
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)

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.