import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import iguizarFuncs as igMarket Anomalies
Disclaimer: This document is intended for educational purposes only. It does not constitute financial advice. It is part of my financial modeling course at Tec de Monterrey.
1 Intro
A financial market anomaly is a situation in which a stock’s performance deviates from what is expected under the efficient market hypotheses. See here a description of some market anomalies. For example, in an efficient financial market, investors expect stocks with positive past performance to slow down, as prices would adjust quickly to any relevant information driving down the expected returns. Momentum is a market anomaly that contradict this view, by showing that stocks that performed well in the past (winners) may continue to do so in the short term, while poor performers (losers) may continue underperforming.
Momentum investment strategies involve taking long positions on stocks that show positive momentum (winners) and shorting those with negative momentum (losers). By relying on past information to predict future performance, momentum exploits a market anomaly. In this document I illustrate the strategy by generating a signal to buy (long position) or sell (short position) based on historical information.
2 Application
3 Strategy with one stock
Data
Data = yf.download('MSFT', start='2022-09-30', end='2024-09-30', progress=False)['Close']
# Reset index to make the date a regular column
Data = Data.reset_index()
# Clean the 'Date' column
Data['Date'] = pd.to_datetime(Data['Date']).dt.dateYF.download() has changed argument auto_adjust default to True
3.1 Moving Average Convergence Divergence (MACD)
The MACD line can be thought of an indicator of trend. It is calculated by subtracting the long-term (usually 26 days) exponential moving average(EMA) from the short-term exponential moving average (usually 12 days).
\[ MACD = EMA_{(12)}-EMA_{(26)} \]
An EMA of the MACD line (usually 9 days) is called the signal line. It functions as tool to easily spot buy or sell signals.
If MACD > Signal \(\Rightarrow\) Bullish momentum
If MACD < Signal \(\Rightarrow\) Bearish momentum
short_window = 12 # fast period
long_window = 26 # slow period
signal_window = 9 # signal period
# Calculate the short and long EMA
Data['MSFT_EMA12'] = Data['MSFT'].ewm(span=short_window,adjust=False).mean()
Data['MSFT_EMA26'] = Data['MSFT'].ewm(span=long_window, adjust=False).mean()
Data['MSFT_MACD'] = Data['MSFT_EMA12'] - Data['MSFT_EMA26']
# adjust=True to calculate an EMA that using all data points from the beginning of the series.
Data['MSFT_MACD_Signal'] = Data['MSFT_MACD'].ewm(span=signal_window, adjust=False).mean()Plot the MACD indicators:
# To add a MACD Histogram
Data['MSFT_MACD_Hist'] = Data['MSFT_MACD'] - Data['MSFT_MACD_Signal']
# Plot
plt.figure(figsize=(7, 4))
# MACD and Signal Line
plt.plot(Data['Date'], Data['MSFT_MACD'], label='MACD', color='blue', linewidth=1.5)
plt.plot(Data['Date'], Data['MSFT_MACD_Signal'], label='Signal', color='orange', linewidth=1.5)
# Histogram
plt.bar(Data['Date'], Data['MSFT_MACD_Hist'], label='MACD-Signal', color='grey', alpha=0.75)
# Formatting
plt.title('MACD for MSFT', fontsize=10)
plt.xlabel('Date', fontsize=10)
plt.ylabel('MACD', fontsize=10)
plt.legend(loc='lower left')
plt.grid(True, linestyle=':', alpha=0.25)
plt.axhline(0, color='red', linestyle='--', linewidth=0.5) # Zero line for MACD
plt.tight_layout()
plt.xticks(rotation=45)
# Display the plot
plt.show()3.2 Bollinger Bands
Bollinger Bands are indicators of volatility. They can be calculated as follows:
Middle Band: A Simple Moving Average (SMA) of the stock’s price (usually last 20 days).
Upper Band: Middle Band + 2 * Standard Deviation (last 20 days).
Lower Band: Middle Band - 2 * Standard Deviation (last 20 days).
As Bollinger bands measure price volatility relative to a moving average, they provide context for where the price is relative to its recent history. Bollinger Bands show whether prices are moving to extreme levels (high or low), indicating potential overbought or oversold conditions.
– If price near or outside the upper band \(\Rightarrow\) suggests the stock may be overbought
– If price near or outside the lower band \(\Rightarrow\) suggests the stock may be oversold
b_window = 20
# Calculate the middle, upper, and lower bands
Data['MSFT_Mid'] = Data['MSFT'].rolling(window=b_window).mean()
Data['MSFT_Std'] = Data['MSFT'].rolling(window=b_window).std()
Data['MSFT_Up'] = Data['MSFT_Mid'] + (2 * Data['MSFT_Std'])
Data['MSFT_Low'] = Data['MSFT_Mid'] - (2 * Data['MSFT_Std'])A plot of the Bollinger bands looks as follows:
# Plot
plt.figure(figsize=(7, 4))
# Price
plt.plot(Data['Date'], Data['MSFT'], label='Price', color='black', linewidth=1)
# Bollinger Bands
#plt.plot(Data['Date'], Data['MSFT_Mid'], label='Middle', color='black', linestyle='-', linewidth=1)
plt.plot(Data['Date'], Data['MSFT_Up'], label='Upper', color='red', linestyle='-', linewidth=1)
plt.plot(Data['Date'], Data['MSFT_Low'], label='Lower', color='green', linestyle='-', linewidth=1)
plt.fill_between(Data['Date'], Data['MSFT_Up'], Data['MSFT_Low'], color='grey', alpha=0.1)
# Formatting
plt.title('Bollinger Bands for MSFT', fontsize=10)
plt.xlabel('Date', fontsize=10)
plt.ylabel('Price', fontsize=10)
plt.legend(loc='upper left')
plt.grid(True, linestyle='-.', alpha=0.5)
plt.tight_layout()
plt.xticks(rotation=45)
plt.show()3.3 Combining MACD and Bollinger Bands
While MACD helps confirm if the trend is bullish or bearish, Bollinger Bands show if the price is at a high or low relative to its specified volatility range. In other words, MACD alone might suggest momentum changes, but without Bollinger Bands, it doesn’t indicate whether the price is at an extreme. Bollinger Bands provide that confirmation, helping avoid trades when prices are within a “normal” range.
Buy Signal: When the MACD line crosses above the signal line and the price is near or below the lower Bollinger Band, it suggests that the price has been oversold but is now gaining bullish momentum.**
Sell Signal: When the MACD line crosses below the signal line and the price is near or above the upper Bollinger Band, it suggests that the stock may be overbought and is starting to lose momentum.
Creating a signal
# Buy signal: MACD crossover and price at/below lower Bollinger Band
Data['Buy'] = ((Data['MSFT_MACD'] > Data['MSFT_MACD_Signal']) &
(Data['MSFT'] < Data['MSFT_Low']))
# Sell signal: MACD crossover and price at/above upper Bollinger Band
Data['Sell'] = ((Data['MSFT_MACD'] < Data['MSFT_MACD_Signal']) &
(Data['MSFT'] > Data['MSFT_Up']))
# If wanted to use only MACD lines for signals:
# Data['Sell'] = (Data['MSFT_MACD'] < Data['MSFT_MACD_Signal'])
# Data['Buy'] = (Data['MSFT_MACD'] > Data['MSFT_MACD_Signal'])3.4 Implementation
Data['Position'] = 0 # 0, initially neutral
Data.loc[Data['Buy'], 'Position'] = 1 # 1, buy signal
Data.loc[Data['Sell'],'Position'] = -1 # -1, sell signal
Data['Position'] = Data['Position'].replace(0, np.nan).ffill().fillna(0) # Carry forward last positionPlot position changes:
# Plot
plt.figure(figsize=(7, 4))
# Position
plt.plot(Data['Date'], Data['Position'], label='Position', color='purple', linewidth=1.5)
# Formatting
plt.title('Position Changes Over Time', fontsize=16)
plt.xlabel('Date', fontsize=12)
plt.ylabel('Position', fontsize=12)
plt.grid(True, linestyle='--', alpha=0.5)
plt.legend(loc='upper left')
plt.tight_layout()
plt.xticks(rotation=45)
plt.show()Obtain the returns:
# Daily log returns for the stock
Data['MSFT_Log_Returns'] = np.log(Data['MSFT'] / Data['MSFT'].shift(1))
# Returms from the strategy
Data['Strategy_Returns'] = Data['Position'].shift(1) * Data['MSFT_Log_Returns']Obtain cumulative returns:
Data['Date'] = pd.to_datetime(Data['Date'])
Data['Year'] = Data['Date'].dt.year
logRet_year = Data.groupby('Year')['Strategy_Returns'].sum()
# Convert log returns to simple returns
ret_year = np.exp(logRet_year) - 1
ret_yearYear
2022 0.000000
2023 -0.219479
2024 -0.126218
Name: Strategy_Returns, dtype: float64
Return form the last trading day:
last_252_log_return = Data['Strategy_Returns'].tail(252).sum()
year_return = np.exp(last_252_log_return) - 1
# Output the result
print(f"Return for Last Year (252 Trading Days): {100*year_return:.2f}%")Return for Last Year (252 Trading Days): -27.47%
4 Strategy for multiple stocks
Here we will use the list of stock that we have filtered in the previous topic, it was named “no_stocks.xlsx”. Remember these are stocks that contradicted the Efficient Market Hypothesis.
filtered = pd.read_excel('no_stocks.xlsx')
tickers = filtered['Ticker'].tolist()
start_date = '2022-09-30'
end_date = '2024-09-30'
pricesSelected = ig.get_prices(tickers, start_date, end_date)Calculate the MACD indicators using the function ‘get_macd’:
macd_results = ig.get_macd(pricesSelected)Calculate the Bollinger bands using the function ‘get_bbands’:
bollinger_results = ig.get_bbands(pricesSelected, window=20, multiplier=2)
bollinger_results.drop(columns=tickers, inplace=True) Generate signals for each stock:
signals_data = pd.merge(macd_results, bollinger_results, on='Date', how='inner') # 'inner' to keep only matching dates
for ticker in tickers:
macd_col = f'{ticker}_MACD'
macd_signal_col= f'{ticker}_MACD_Signal'
lower_band_col = f'{ticker}_Lo'
upper_band_col = f'{ticker}_Up'
price_col = f'{ticker}'
# Buy signals
signals_data[f'{ticker}_Buy'] = ((signals_data[macd_col] > signals_data[macd_signal_col]) &
(signals_data[price_col] < signals_data[lower_band_col]))
# Sell signals
signals_data[f'{ticker}_Sell'] = ((signals_data[macd_col] < signals_data[macd_signal_col]) &
(signals_data[price_col] > signals_data[upper_band_col]))
# Start with a neutral position
signals_data[f'{ticker}_Position'] = 0
# Set the Position to 1 for Buy signals
signals_data.loc[signals_data[f'{ticker}_Buy'], f'{ticker}_Position'] = 1
# Set the Position to -1 for Sell signals
signals_data.loc[signals_data[f'{ticker}_Sell'], f'{ticker}_Position'] = -1
# Carry forward the last position, replace 0 with NaN and forward-filling, then fill initial NaNs with 0
signals_data[f'{ticker}_Position'] = signals_data[f'{ticker}_Position'].replace(0, np.nan).ffill().fillna(0)Calculating the returns:
log_returns_df = pd.DataFrame(index=signals_data.index)
strategy_returns_df = pd.DataFrame(index=signals_data.index)
# Iterate over tickers and calculate log returns and strategy returns
for ticker in tickers:
# Calculate log returns for each stock
log_returns_df[f'{ticker}_Log_Returns'] = np.log(signals_data[ticker] / signals_data[ticker].shift(1))
# Calculate strategy returns using the previous day's position
strategy_returns_df[f'{ticker}_Strategy_Returns'] = signals_data[f'{ticker}_Position'].shift(1) * log_returns_df[f'{ticker}_Log_Returns']
# Combine the calculated columns with the original signals_data DataFrame
signals_data = pd.concat([signals_data, log_returns_df, strategy_returns_df], axis=1)Calculate the strategy returns from the last trading year:
last_year_returns_df = pd.DataFrame(columns=['Ticker', 'Return'])
for ticker in tickers:
strategy_col = f'{ticker}_Strategy_Returns'
last_252_log_return = signals_data[strategy_col].tail(252).sum()
# Convert the log return to simple return
last_252_return = np.exp(last_252_log_return) - 1
# Place the result in the data frame
last_year_returns_df.loc[len(last_year_returns_df)] = [ticker, last_252_return]
last_year_returns_df| Ticker | Return | |
|---|---|---|
| 0 | ADI | -0.268060 |
| 1 | AMZN | -0.329787 |
| 2 | AVGO | -0.534186 |
| 3 | BAC | 0.000000 |
| 4 | BRK-A | 0.000000 |
| 5 | BRK-B | 0.000000 |
| 6 | BSX | -0.176725 |
| 7 | CMCSA | 0.000000 |
| 8 | ELV | 0.000000 |
| 9 | GE | -0.525259 |
| 10 | INTC | 0.000000 |
| 11 | KLAC | -0.435527 |
| 12 | MA | -0.134906 |
| 13 | MCD | -0.152398 |
| 14 | META | -0.476794 |
| 15 | MS | 0.000000 |
| 16 | ORCL | -0.388344 |
| 17 | PANW | 0.041645 |
| 18 | PG | 0.000000 |
| 19 | QCOM | 0.022480 |
| 20 | REGN | -0.074615 |
| 21 | SCHW | 0.203546 |
| 22 | TMO | -0.093991 |
| 23 | TXN | -0.187381 |
| 24 | UPS | 0.000000 |
| 25 | V | -0.051336 |
| 26 | VRTX | 0.049927 |
| 27 | VZ | -0.175316 |
Select only the tickers with non-negative returns:
selectStocks = last_year_returns_df[last_year_returns_df['Return'] >= 0]
selectStocks| Ticker | Return | |
|---|---|---|
| 3 | BAC | 0.000000 |
| 4 | BRK-A | 0.000000 |
| 5 | BRK-B | 0.000000 |
| 7 | CMCSA | 0.000000 |
| 8 | ELV | 0.000000 |
| 10 | INTC | 0.000000 |
| 15 | MS | 0.000000 |
| 17 | PANW | 0.041645 |
| 18 | PG | 0.000000 |
| 19 | QCOM | 0.022480 |
| 21 | SCHW | 0.203546 |
| 24 | UPS | 0.000000 |
| 26 | VRTX | 0.049927 |
Export to an excel file for future reference as:
selectStocks.to_excel('selectStocks.xlsx', index=False)Filter from the original list of companies:
companies = pd.read_csv('toUScompanies.csv')
select_companies = companies[companies['Symbol'].isin(selectStocks['Ticker'])]
select_companies| Name | Symbol | Country | Sector | Market Cap | |
|---|---|---|---|---|---|
| 7 | Berkshire Hathaway Inc | BRK-B | US | Financials | 9.813140e+11 |
| 8 | Berkshire Hathaway Inc | BRK-A | US | Financials | 9.813140e+11 |
| 19 | Procter & Gamble Co | PG | US | Consumer Staples | 3.916550e+11 |
| 24 | Bank of America Corp | BAC | US | Financials | 3.254660e+11 |
| 43 | Morgan Stanley | MS | US | Financials | 1.907780e+11 |
| 47 | Qualcomm Inc | QCOM | US | Information Technology | 1.873800e+11 |
| 57 | Comcast Corp | CMCSA | US | Communication Services | 1.638650e+11 |
| 71 | Charles Schwab Corp | SCHW | US | Financials | 1.302400e+11 |
| 77 | Vertex Pharmaceuticals Inc | VRTX | US | Health Care | 1.224950e+11 |
| 81 | Palo Alto Networks Inc | PANW | US | Information Technology | 1.171250e+11 |
| 83 | United Parcel Service Inc | UPS | US | Industrials | 1.148200e+11 |
| 99 | Intel Corp | INTC | US | Information Technology | 9.595344e+10 |
| 101 | Elevance Health Inc | ELV | US | Health Care | 9.529940e+10 |