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']
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 stock 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
macd_cross_up = (
(Data['MSFT_MACD'] > Data['MSFT_MACD_Signal']) &
(Data['MSFT_MACD'].shift(1) <= Data['MSFT_MACD_Signal'].shift(1))
)
macd_cross_down = (
(Data['MSFT_MACD'] < Data['MSFT_MACD_Signal']) &
(Data['MSFT_MACD'].shift(1) >= Data['MSFT_MACD_Signal'].shift(1))
)Data['Buy'] = macd_cross_up
Data['Sell'] = macd_cross_down tol = 0.05 # 5% tolerance
Data['Buy'] = (
macd_cross_up &
(Data['MSFT'] <= Data['MSFT_Low'] * (1 + tol))
)
Data['Sell'] = (
macd_cross_down &
(Data['MSFT'] >= Data['MSFT_Up'] * (1 - tol))
)3.4 Implementation
Positions take values 0 and 1, corresponding to out-of-market and long positions. Short selling is not permitted here. Entry occurs upon a buy signal and exit upon a sell signal.
Data['Position'] = 0
in_market = 0
for t in range(1, len(Data)):
if (Data['Buy'].iloc[t] == 1) and (in_market == 0):
in_market = 1
elif (Data['Sell'].iloc[t] == 1) and (in_market == 1):
in_market = 0
Data.iloc[t, Data.columns.get_loc('Position')] = in_marketPlot 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:
# Asset return
Data['Asset_Return'] = np.log(Data['MSFT']).diff()
# Returns from the strategy
Data['Strategy_Return'] = Data['Position'].shift(1) * Data['Asset_Return']If Position = 1, the asset return is earned, but if Position = 0, the return is zero
To obtain cumulative returns:
Data['Date'] = pd.to_datetime(Data['Date'])
Data['Year'] = Data['Date'].dt.year
logRet_year = Data.groupby('Year')['Strategy_Return'].sum()
# Convert log returns to simple returns
ret_year = np.exp(logRet_year) - 1
ret_yearYear
2022 0.000000
2023 0.161098
2024 0.094703
Name: Strategy_Return, dtype: float64
Return from the last trading year:
Data = Data.sort_values('Date')
last_252_log_return = (
Data['Strategy_Return']
.dropna()
.tail(252)
.sum()
)
year_return = np.exp(last_252_log_return) - 1
print(f"Return over the last 252 trading days: {100 * year_return:.2f}%")Return over the last 252 trading days: 32.88%
Trading annual returns may differ substantially from calendar year returns, as they span overlapping periods and may exclude unfavorable day that are included in calendar year calculations.
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')
tol = 0.05 # 5% tolerance around bands
for ticker in tickers:
macd_col = f'{ticker}_MACD'
sig_col = f'{ticker}_MACD_Signal'
lo_col = f'{ticker}_Lo'
up_col = f'{ticker}_Up'
price_col = ticker
# MACD crossovers
macd_cross_up = (
(signals_data[macd_col] > signals_data[sig_col]) &
(signals_data[macd_col].shift(1) <= signals_data[sig_col].shift(1))
)
macd_cross_down = (
(signals_data[macd_col] < signals_data[sig_col]) &
(signals_data[macd_col].shift(1) >= signals_data[sig_col].shift(1))
)
# Buy/Sell with Bollinger tolerance
signals_data[f'{ticker}_Buy'] = (
macd_cross_up &
(signals_data[price_col] <= signals_data[lo_col] * (1 + tol))
)
signals_data[f'{ticker}_Sell'] = (
macd_cross_down &
(signals_data[price_col] >= signals_data[up_col] * (1 - tol))
)Fix positions:
for ticker in tickers:
pos = np.zeros(len(signals_data), dtype=int)
in_market = 0
buy = signals_data[f'{ticker}_Buy'].fillna(False).to_numpy()
sell = signals_data[f'{ticker}_Sell'].fillna(False).to_numpy()
for t in range(1, len(signals_data)):
if buy[t] and in_market == 0:
in_market = 1
elif sell[t] and in_market == 1:
in_market = 0
pos[t] = in_market
signals_data[f'{ticker}_Position'] = possignals_data = signals_data.sort_values('Date').reset_index(drop=True)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.130209 |
| 1 | AMZN | 0.158985 |
| 2 | AVGO | 0.539409 |
| 3 | BAC | 0.129934 |
| 4 | BRK-A | 0.097535 |
| 5 | BRK-B | 0.149144 |
| 6 | BSX | 0.070021 |
| 7 | CMCSA | -0.031534 |
| 8 | ELV | -0.000996 |
| 9 | GE | 0.062705 |
| 10 | INTC | -0.438806 |
| 11 | KLAC | 0.000000 |
| 12 | MA | 0.057436 |
| 13 | MCD | 0.034340 |
| 14 | META | 0.082720 |
| 15 | MS | 0.197538 |
| 16 | ORCL | 0.110721 |
| 17 | PANW | 0.000000 |
| 18 | PG | 0.095316 |
| 19 | QCOM | 0.000000 |
| 20 | REGN | 0.110638 |
| 21 | SCHW | 0.049005 |
| 22 | TMO | 0.196694 |
| 23 | TXN | 0.068944 |
| 24 | UPS | 0.027545 |
| 25 | V | 0.057000 |
| 26 | VRTX | 0.339992 |
| 27 | VZ | 0.221795 |
Select only the tickers with returns above 10%.
selectStocks = last_year_returns_df[last_year_returns_df['Return'] >= 0.1]
selectStocks| Ticker | Return | |
|---|---|---|
| 0 | ADI | 0.130209 |
| 1 | AMZN | 0.158985 |
| 2 | AVGO | 0.539409 |
| 3 | BAC | 0.129934 |
| 5 | BRK-B | 0.149144 |
| 15 | MS | 0.197538 |
| 16 | ORCL | 0.110721 |
| 20 | REGN | 0.110638 |
| 22 | TMO | 0.196694 |
| 26 | VRTX | 0.339992 |
| 27 | VZ | 0.221795 |
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 | |
|---|---|---|---|---|---|
| 5 | Amazon.com Inc | AMZN | US | Consumer Discretionary | 2.030020e+12 |
| 7 | Berkshire Hathaway Inc | BRK-B | US | Financials | 9.813140e+11 |
| 10 | Broadcom Inc | AVGO | US | Information Technology | 8.257580e+11 |
| 17 | Oracle Corp | ORCL | US | Information Technology | 4.846800e+11 |
| 24 | Bank of America Corp | BAC | US | Financials | 3.254660e+11 |
| 36 | Thermo Fisher Scientific Inc | TMO | US | Health Care | 2.102390e+11 |
| 43 | Morgan Stanley | MS | US | Financials | 1.907780e+11 |
| 52 | Verizon Communications Inc | VZ | US | Communication Services | 1.741730e+11 |
| 77 | Vertex Pharmaceuticals Inc | VRTX | US | Health Care | 1.224950e+11 |
| 84 | Analog Devices Inc | ADI | US | Information Technology | 1.146350e+11 |
| 94 | Regeneron Pharmaceuticals Inc | REGN | US | Health Care | 1.018900e+11 |