No of Post Views:

27 hits

This article is the first in our long series on ‘ML in finance’.


Welcome to this hands-on tutorial on Time Series Momentum Analysis. In the world of quantitative finance, identifying and leveraging market features like momentum is a key skill. This guide will walk you through the process of building, evaluating, and refining a time series momentum strategy using Python. We’ll start with a simple linear regression model. Then we’ll explore more advanced regularization techniques to address common challenges like overfitting.

By the end of this tutorial, you will be able to:

  • Implement a time series momentum strategy using Python.
  • Build and train a linear regression model to predict future returns.
  • Understand and identify the concepts of overfitting and underfitting in your models.
  • Apply regularization techniques (Ridge, Lasso, and Elastic Net) to improve model performance.
  • Evaluate model performance using in-sample and out-of-sample testing
Prerequisites
  • A foundational understanding of Python programming.
  • Basic knowledge of linear algebra concepts.
Core Concepts (The Theory)

Before we dive into the hands-on practice, let’s briefly cover the key concepts that form the foundation of our analysis.

  • Time Series Momentum: This concept suggests that assets that have performed well in the past will continue to do so. Conversely, assets that have performed poorly will continue to perform poorly. We’ll be looking at this in the context of a single asset over time (the “time series” aspect).
  • Linear Regression: This is a basic predictive modeling technique that assumes a linear relationship between the input features (like past returns) and the target variable (future returns). It’s a great starting point for our analysis.
  • Overfitting and Underfitting:
    • Underfitting occurs when a model is too simple to capture the underlying patterns in the data. It performs poorly on both the data it was trained on and new data.
    • Overfitting happens when a model learns the training data too well, including the noise. It performs great on the training data but fails to generalize to new, unseen data.
  • Regularization: This is a technique used to prevent overfitting by adding a penalty to the model for having large coefficients. We will explore three types:
    • Ridge Regression: Shrinks the coefficients towards zero.
    • Lasso Regression: Can shrink coefficients all the way to zero, effectively performing feature selection.
    • Elastic Net: A combination of Ridge and Lasso regression.
Step-by-Step Walkthrough

This is where we’ll get our hands dirty with the code. Follow these steps to build and evaluate your time series momentum model.

Step 1: Import Libraries and Fetch Data

First, we need to import the necessary Python libraries and download the historical price data for our asset. We’ll use the SPY ETF, which tracks the S&P 500 index.

import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from sklearn import linear_model
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import train_test_split, GridSearchCV,RepeatedKFold
from sklearn.preprocessing import MinMaxScaler

# Getting historical market data from SPY (ETF)
df = yf.download("SPY", start="2000-01-01", end="2025-01-01")

This code imports the required libraries and uses yfinance to download the daily price data for SPY from the beginning of 2000 to the beginning of 2025.

Step 2: Create Features

Next, we’ll engineer the features for our model. These will be the past returns over different time horizons, which will serve as our predictors.

df["Ret"] = df["Close"].pct_change()

name = "Ret"

df["Ret10_i"] = (df[name].rolling(10).apply(lambda x: 100 * ((np.prod(1 + x)) ** (1 / 10) - 1)))
df["Ret25_i"] = (df[name].rolling(25).apply(lambda x: 100 * ((np.prod(1 + x)) ** (1 / 25) - 1)))
df["Ret60_i"] = (df[name].rolling(60).apply(lambda x: 100 * ((np.prod(1 + x)) ** (1 / 60) - 1)))
df["Ret120_i"] = (df[name].rolling(120).apply(lambda x: 100 * ((np.prod(1 + x)) ** (1 / 120) - 1)))
df["Ret240_i"] = (df[name].rolling(240).apply(lambda x: 100 * ((np.prod(1 + x)) ** (1 / 240) - 1)))

# Clean up the dataframe
del df["Open"]
del df["Close"]
del df["High"]
del df["Low"]
del df["Volume"]

df = df.dropna()

Here, we calculate the daily return and then create rolling compounded returns for 10, 25, 60, 120, and 240-day periods. We then remove the unnecessary columns and drop any rows with missing values.

Step 3: Define the Target Variable

Our target variable (y) will be the return of SPY over the next 25 days.

df["Ret25"] = df["Ret25_i"].shift(-25)
df = df.dropna()

This code creates our target variable by shifting the 25-day return column back by 25 days. This way, each row’s features will be used to predict the return 25 days into the future.

Step 4: Build and Train the Linear Regression Model

Now, let’s build our first model using linear regression. We’ll split our data into a training set and a testing set to evaluate its out-of-sample performance.

X, y = df.iloc[:, 0:-1], df.iloc[:, -1]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=int(len(y) * 0.5), shuffle=False
)

reg = linear_model.LinearRegression().fit(X_train, y_train)

# Make predictions
y_pred = reg.predict(X_train)
y_pred_test = reg.predict(X_test)

We separate our features (X) and target (y), then split them into training and testing sets. We train a linear regression model on the training data. Then, we make predictions on the training set and on the testing set.

Step 5: Evaluate the Linear Regression Model

Let’s see how well our model performed. We’ll look at the Mean Squared Error (MSE) and the R-squared value.

print("In-Sample Performance:")
print("Mean squared error: %.5f" % mean_squared_error(y_train, y_pred))
print("Coefficient of determination (R2): %.5f" % r2_score(y_train, y_pred))

print("\nOut-of-Sample Performance:")
print("Mean squared error: %.5f" % mean_squared_error(y_test, y_pred_test))
print("Coefficient of determination (R2): %.5f" % r2_score(y_test, y_pred_test))
In-Sample Performance:
Mean squared error: 0.05237
Coefficient of determination (R2): 0.00905

Out-of-Sample Performance:
Mean squared error: 0.03690
Coefficient of determination (R2): -0.05915

This code prints the MSE and R-squared for both the in-sample (training) and out-of-sample (testing) data. A low R-squared, especially a negative one for the out-of-sample data, indicates poor predictive power.

Step 6: Apply Regularization with Elastic Net

Our linear regression model performed poorly, with a very low in-sample R² and a negative out-of-sample R². This indicates the model is underfitting and failing to capture the underlying patterns in the data.

You might ask: “If the model is underfitting, why apply regularization, which is typically used to combat overfitting?” This is an excellent question. While regularization’s primary role is to reduce overfitting in complex models, it serves a few important purposes here as well:

  1. Building a More Robust Model: Financial data is inherently noisy. Regularization reduces noise sensitivity by shrinking the coefficients of less important features. This makes the model more stable.
  2. Feature Selection: Techniques like Lasso and Elastic Net can reduce the coefficients of irrelevant features to zero. This helps us see if a simpler model with fewer features perform better.
  3. Standard Workflow: Introducing regularization now is a standard step in the modeling process. It’s a tool for controlling model complexity, and we’re demonstrating its application before we consider more complex models.

Let’s apply Elastic Net regularization, which combines Ridge and Lasso penalties, to see if it can create a more stable and effective model.

from sklearn.linear_model import ElasticNet

# Train the model
e_net = ElasticNet(alpha=0.0001, l1_ratio=0.1)
e_net.fit(X_train, y_train)

# Make predictions
y_pred_elastic = e_net.predict(X_test)
mean_squared_error_elastic = np.mean((y_pred_elastic - y_test) ** 2)
print("Mean Squared Error on test set (Elastic Net):", mean_squared_error_elastic)
Mean Squared Error on test set (Elastic Net): 0.03685730229567684

Here, we train an Elastic Net model with initial hyperparameters (alpha and l1_ratio) and evaluate its performance on the test set.

If you look back at the out-of-sample performance of the model in Step 5, you will see its MSE was 0.03690. The Elastic Net model’s MSE of 0.036857 is only marginally better.

Step 7: Hyperparameter Tuning with GridSearchCV

To find the best hyperparameters for our Elastic Net model, we’ll use GridSearchCV. This will systematically test different combinations of alpha and l1_ratio to find the ones that yield the best performance.

model = ElasticNet()
cv = RepeatedKFold(n_splits=10, n_repeats=3, random_state=1)
grid = dict()
grid["alpha"] = [1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 0.0, 1.0, 10.0, 100.0]
grid["l1_ratio"] = [0, 0.01, 0.1, 0.2, 0.5, 0.7, 1]
search = GridSearchCV(model, grid, scoring="neg_mean_squared_error", cv=cv, n_jobs=-1)
results = search.fit(X_train, y_train)

print("Best MSE: %.3f" % results.best_score_)
print("Best Config: %s" % results.best_params_)
Best MSE: -0.053
Best Config: {'alpha': 0.001, 'l1_ratio': 0}

This code sets up a grid of hyperparameters to test. It uses GridSearchCV with repeated k-fold cross-validation. We will get the values for ‘alpha’ and ‘l1_ratio’ that minimize the MSE.

The fact that even the best hyperparameters from GridSearchCV provide only a minuscule improvement in MSE is highly significant. It tells you that the primary limitation is not the model’s tuning, but the model itself and the features you’re using.

Here’s a step-by-step breakdown of what this result signifies:

1. The Model is Underfitting

Your initial linear model performed poorly, and even the best-tuned regularized linear model performs poorly. This is a classic sign of underfitting. The underlying relationship between past returns and future 25-day returns is likely not linear. No amount of tweaking a linear model (which is what Elastic Net is) will help if the fundamental assumption of linearity is wrong. GridSearchCV found the “best” linear model, but the “best” linear model is still not good enough.

2. Low Signal-to-Noise Ratio

This result is very common in finance. It suggests that the features you’ve chosen (historical returns) have a very weak “signal” for predicting future returns. The market is influenced by countless factors, and past price movements alone often contain more random noise than predictive information. Regularization and hyperparameter tuning are excellent at reducing a model’s sensitivity to noise, but they can’t create a signal where one doesn’t strongly exist.

3. What It Means for Your Workflow

This is not a failure of the process; it’s a valuable finding. Your GridSearchCV experiment has effectively confirmed that you’ve likely reached the performance limit for this type of linear model with these specific features.

The minuscule improvement tells you to stop spending time tuning this model and move on to the next logical steps in the modeling process, which are:

Next Steps:
  • Explore other features: Try adding other technical indicators as features to your model (e.g., moving averages, RSI).
  • Try different models: Experiment with more advanced models like Support Vector Machines (SVMs) or tree-based models (e.g., Random Forest, Gradient Boosting).
  • Dive deeper into classification: Instead of predicting the return value, try to predict the direction of the return (up or down). This turns the problem into a classification task, where you can use models like Logistic Regression.

In next article, we will learn how to use logistic regression to predict the market movement.


Leave a Reply

Discover more from SimplifiedZone

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from SimplifiedZone

Subscribe now to keep reading and get access to the full archive.

Continue reading