Random Forests and Gradient Boosting for Trading Signals
Random forests and gradient boosting (XGBoost, LightGBM, scikit-learn's HistGradientBoosting) are the workhorses of practical machine-learning trading. They model nonlinear, regime-dependent interactions, tolerate messy heterogeneous features, train fast, and — critically for a low-signal, small-sample domain — overfit less than deep nets when properly constrained. This guide covers how to use tree ensembles for trading signals, how to validate them without fooling yourself, and what performance to realistically expect.
Why trees fit market data
Markets are full of conditional relationships: momentum pays in low-volatility regimes and reverses in high-volatility ones; a breakout matters only if volume confirms; carry works until funding spikes. A linear model must encode every such interaction by hand, whereas a tree discovers "if vol is low and momentum is positive, lean long" automatically. Trees are also invariant to monotonic feature transforms, so you avoid much of the scaling fragility that bites linear models. The cost is that a single deep tree memorizes noise; ensembles exist to control that.
- Random forest — many deep, decorrelated trees on bootstrap samples and random
feature subsets, then averaged. Averaging cuts variance, so the ensemble generalizes far better than any single tree. Hard to misuse; a strong baseline.
- Gradient boosting — shallow trees grown sequentially, each fitting the residual
of the ensemble so far. Reduces bias and usually wins on accuracy, but because each tree chases prior mistakes, it overfits readily and demands careful regularization.
| Aspect | Random forest | Gradient boosting |
|---|---|---|
| Tree building | Independent, parallel | Sequential, corrective |
| Main effect | Variance reduction | Bias reduction |
| Overfitting risk | Low | Higher — needs care |
| Key controls | maxdepth, minsamplesleaf, maxfeatures | learningrate, maxdepth, subsample, early stopping |
| Best role | Robust baseline, signal floor | Squeezing out marginal accuracy |
Establish the forest baseline first — it tells you honestly whether your features carry any signal. Only then invest in the tuning that boosting rewards.
A constrained classification setup
Defaults matter more than the library. On daily data, force shallow trees and large leaves; the goal is a smooth, low-variance probability, not a sharp fit.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
# X: stationary, leak-free features; y: triple-barrier label {-1, 0, 1}
rf = RandomForestClassifier(
n_estimators=500,
max_depth=4, # shallow -> low variance
min_samples_leaf=100, # large leaves -> smooth signal
max_features="sqrt", # decorrelate trees
class_weight="balanced_subsample",
n_jobs=-1, random_state=0,
)
rf.fit(X_train, y_train, sample_weight=w_train) # w_train: overlap weights
classes = list(rf.classes_)
proba_up = rf.predict_proba(X_test)[:, classes.index(1)]
pos = np.where(proba_up > 0.55, 1.0, np.where(proba_up < 0.45, -1.0, 0.0))
The sample_weight carries the overlap-concurrency weights from your triple-barrier labeling: overlapping labels are not independent, and weighting by inverse concurrency stops the ensemble from counting the same move many times. The deadband (0.45-0.55) is a turnover dial tuned on net, after-cost performance.
Gradient boosting looks similar but the regularization knobs differ, and you must use early stopping on a time-ordered validation slice rather than guessing tree count:
from lightgbm import LGBMClassifier
gbm = LGBMClassifier(
n_estimators=2000, # upper bound; early stopping picks the real number
learning_rate=0.02, # small steps
num_leaves=15, max_depth=4,
min_child_samples=100, # large leaves
subsample=0.7, subsample_freq=1,
colsample_bytree=0.6, # feature subsampling
reg_lambda=5.0, random_state=0,
)
gbm.fit(
X_tr, y_tr, sample_weight=w_tr,
eval_set=[(X_val, y_val)], eval_metric="multi_logloss",
callbacks=[__import__("lightgbm").early_stopping(50)],
)
Validation: where most people cheat
The single most important choice is the cross-validation scheme. Standard k-fold shuffles rows, which in time series means training on the future to predict the past — pure leakage that manufactures gorgeous, meaningless backtests. The correct approach respects time order, purges training samples whose label horizon overlaps the test window, and embargoes a gap after it.
import numpy as np
def purged_kfold(t1, n_splits=6, embargo_frac=0.01):
"""t1: Series mapping each obs start-time to its label end-time (sorted)."""
idx = np.arange(len(t1))
folds = np.array_split(idx, n_splits)
times = t1.index
emb = int(len(idx) * embargo_frac)
for fold in folds:
test = fold
t0, t1_test = times[test[0]], t1.iloc[test[-1]]
# purge any train obs whose label window overlaps [t0, t1_test], + embargo
ok = (t1.values < t0) | (times.values > times[min(test[-1] + emb,
len(idx) - 1)])
train = idx[ok]
train = train[~np.isin(train, test)]
yield train, test
This is the practical core of purged cross-validation and walk-forward optimization. Without the purge and embargo, label information bleeds across the boundary and every metric inflates. Stitch the out-of-sample folds into a single equity curve and judge that.
Feature importance, used honestly
Impurity-based featureimportances is biased: it inflates for high-cardinality and correlated features and is computed in-sample. Prefer permutation importance on held-out data, which measures how much out-of-sample performance drops when a single feature is shuffled.
import pandas as pd
from sklearn.inspection import permutation_importance
pi = permutation_importance(rf, X_test, y_test, n_repeats=25,
random_state=0, n_jobs=-1)
imp = pd.Series(pi.importances_mean, index=X_test.columns).sort_values(ascending=False)
print(imp.head(10))
Even this is not gospel: correlated features share credit and can each look unimportant while their group matters. Use importances to prune features, then confirm by re-measuring net out-of-sample Sharpe with and without the candidate.
Overfitting controls specific to trees
- Shallow depth (3-6) and large leaves — the most effective single defense.
- Subsampling rows and columns — decorrelates trees and reduces variance.
- Few, economically-motivated features — trees reward signal density, not count;
feeding hundreds of features to "let the trees sort it out" guarantees noise-fitting.
- Early stopping for boosting on a time-ordered validation set.
- Overlap-aware sample weights so non-independent labels are not over-counted.
- Multiple-testing discipline — every tuning run is a trial; discount the best
result with a deflated Sharpe.
Accuracy is the wrong metric
A model can be 52% accurate and very profitable, or 60% accurate and lose money. Accuracy weights every prediction equally; markets do not. A model that is right on many tiny-move days and wrong on the few large-move days posts great accuracy and loses money, because the rare big losses swamp the frequent small wins. The only metric that matters is realized, after-cost, risk-adjusted PnL, measured exactly as you would judge any backtested strategy. Track the probabilistic calibration too: a well-calibrated predict_proba is what makes the deadband and downstream position sizing meaningful.
Calibration matters more than accuracy
A trading model's predict_proba is only useful if it is calibrated: when the model says 0.55, the event should happen about 55% of the time. Random forests are often under-confident (probabilities pulled toward the class base rate by averaging), while boosted trees trained too long become over-confident. Miscalibration quietly corrupts everything downstream — the deadband fires at the wrong threshold, and probability-scaled position sizing over- or under-bets. Check calibration on held-out, time-ordered data and correct it if needed.
from sklearn.calibration import CalibratedClassifierCV
from sklearn.metrics import brier_score_loss
# Fit calibration on a LATER, disjoint slice than the model's training data
cal = CalibratedClassifierCV(rf, method="isotonic", cv="prefit")
cal.fit(X_calib, y_calib) # X_calib strictly after X_train in time
p = cal.predict_proba(X_test)[:, classes.index(1)]
print("Brier score:", brier_score_loss((y_test == 1).astype(int), p))
A lower Brier score means probabilities you can actually size on. Reserving a separate, later calibration slice (rather than reusing training data) keeps the procedure leak-free.
Regression targets and meta-labeling
Classification throws away magnitude. A regression on volatility-scaled forward returns (or on the triple-barrier return rather than just its sign) lets the model express conviction proportional to expected move size, which usually improves the economic objective even when classification accuracy is unchanged. The complementary trick is meta-labeling: keep a primary directional rule and train the tree ensemble only to predict the probability that the primary signal is correct, then size by that probability. This is a precision-oriented, lower-variance problem that trees handle well, and it cleanly separates the question "which way?" from "how much?" — see triple-barrier and meta-labeling.
Turnover, capacity, and net edge
Tree ensembles produce jumpy raw signals — a small feature change can flip a leaf and hence the position — so naive deployment churns the book. Beyond the deadband, smooth the target (predict a multi-bar forward return), add hysteresis (require a larger move to flip than to hold), and penalize turnover directly when you convert probability to position. Always compare models on net Sharpe at the turnover they actually generate, and estimate how much capital the signal absorbs before market impact erodes it. A model that wins gross but trades twice as often can easily lose net.
Realistic expectations
| Outcome on daily data | Interpretation |
|---|---|
| OOS net Sharpe 0.5-1.0 | Good, deployable signal |
| OOS net Sharpe > 2.0 | Suspect leakage or overfit; investigate |
| Forest beats boosting OOS | Your features are weak/few; don't over-tune |
| Importance unstable across folds | Correlated features or noise; prune harder |
Trees do not manufacture signal that is not in the features; they extract it more efficiently and more robustly than most alternatives. Expect a modest, decaying edge and re-fit on a schedule, monitoring rolling information coefficient for decay.
Conclusion
Random forests are a strong, overfitting-resistant baseline; gradient boosting can push marginal accuracy with disciplined regularization and early stopping. Keep trees shallow with large leaves, features few and economically motivated, sample weights overlap-aware, and validation strictly time-ordered with purging and embargo. Judge everything on net, cost-adjusted, risk-adjusted PnL rather than classification accuracy, and discount for the number of configs you tried. Their native handling of nonlinear, regime-dependent interactions matches how markets actually behave, which is why they remain the practical default. Pair them with solid feature engineering and an honest walk-forward evaluation.
