Machine learning on returns
Supervised learning fits a function from features X to a target y. In finance the target is typically a future return and the features are things known beforehand: past returns, volatility, volume. scikit-learn gives a uniform interface: every model has fit(X, y) and predict(X). A key difference from textbook problems is the signal-to-noise ratio: returns are almost pure noise, so even a good model explains only a sliver, and a careless setup finds patterns that are not there.
import numpy as np
returns = np.array([0.01, -0.02, 0.015, 0.005, -0.01, 0.02])
n_lags = 2
# Row i holds the two returns before the target; y[i] is the return that follows them
windows = np.lib.stride_tricks.sliding_window_view(returns, n_lags)
X, y = windows[:-1], returns[n_lags:]
print(X)
print(y) # the targets are one step ahead of the featuresFeatures from lags
The simplest features for a return are the returns before it. Build a lag matrix: row i holds returns[i : i + n_lags], and the target is returns[i + n_lags], the next value. A sliding window view gives this without a loop. Everything in a row must be known at the time you would predict, which is just the look-ahead rule applied to a model: a feature shifted by one position the wrong way would let the model see the answer.
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
# Pipeline: scale each feature, then a ridge regression (a linear model with a penalty on large coefficients)
pipe = Pipeline([("scale", StandardScaler()), ("model", Ridge(alpha=10.0))])
print([name for name, _ in pipe.steps])
rng = np.random.default_rng(1)
returns = np.zeros(400)
noise = rng.normal(0, 0.01, size=400)
for t in range(1, 400):
returns[t] = 0.3 * returns[t - 1] + noise[t] # a series with a little memory
windows = np.lib.stride_tricks.sliding_window_view(returns, 3)
X, y = windows[:-1], returns[3:]
pipe.fit(X[:300], y[:300]) # fit on the first 300 rows
print(np.round(pipe.named_steps["model"].coef_, 4)) # the last lag weighs mostPipelines and leakage
A Pipeline bundles the scaler and the model so that fit learns the scaling statistics (the mean and standard deviation of each feature) from the training rows only and predict applies them unchanged to new rows. Scaling the whole data set first, then splitting, would leak the test period's mean and spread into training: the same look-ahead bias as before, in a new place. In a time series the split is also chronological: train on the early rows, test on the later ones, never a random shuffle.
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def build_pipeline(alpha):
return Pipeline([("scale", StandardScaler()), ("model", Ridge(alpha=alpha))])
rng = np.random.default_rng(1)
returns = np.zeros(600)
noise = rng.normal(0, 0.01, size=600)
for t in range(1, 600):
returns[t] = 0.3 * returns[t - 1] + noise[t]
windows = np.lib.stride_tricks.sliding_window_view(returns, 3)
X, y = windows[:-1], returns[3:]
model = build_pipeline(10.0).fit(X[:400], y[:400]) # chronological split: early rows to train
pred = model.predict(X[400:]) # later rows to test
print(round(float(np.mean(np.sign(y[400:]) == np.sign(pred))), 3)) # direction accuracy on unseen dataJudging the result honestly
For a trading signal, the direction is often what matters, so a simple score is the direction accuracy: the share of days on which the predicted and actual returns have the same sign. 50% is a coin flip. Real return predictors reach 51 to 53%, and a score of 60% on test data is more likely a leak than a discovery. Compare with the trivial baselines (always predict up, predict zero) before believing anything.
import numpy as np
y_true = np.array([0.01, -0.02, 0.015, -0.005, 0.02, -0.01])
y_pred = np.array([0.004, 0.001, 0.002, -0.001, 0.003, 0.002]) # the model is right on 4 of 6 signs
accuracy = float(np.mean(np.sign(y_true) == np.sign(y_pred)))
always_up = float(np.mean(np.sign(y_true) == 1)) # the trivial baseline: always predict a rise
print(round(accuracy, 3), round(always_up, 3)) # compare the model with the baselinealpha penalty is a knob to tune, but tune it on a validation period, never on the test period. The next topics build the features and the walk-forward testing that make this honest.- A lag matrix (
sliding_window_view) gives features from the returns before each target; everything in a row must be known at prediction time. - A
Pipeline([("scale", StandardScaler()), ("model", Ridge(alpha=alpha))])learns scaling on the training rows only; split chronologically, never randomly. - Score the direction (share of equal signs) against trivial baselines; 51 to 53% is realistic, 60% suggests a leak; regularised linear models suit noisy data.
Next up: feature engineering for financial data, building better inputs than raw lagged returns.
Lab · what's graded
- Turn a series into features with
lag_matrix25 pts - Chain the steps with
build_pipeline25 pts - Train on the past, predict the rest with
fit_predict25 pts - Score the calls with
direction_accuracy25 pts