Market Anomalies

Isai Guizar

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

Our aim here is to select those stocks that consistently show to be winners or losers. We will use the moving average convergence divergence (MACD) and Bollinger band indicators to generate automated trading signals first for only one stock and then we will generalize the strategy to multiple stocks.

3 Strategy with one stock

import yfinance as yf
import pandas   as pd
import numpy    as np 
import matplotlib.pyplot as plt
import iguizarFuncs as ig

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.date
YF.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:

  1. Middle Band: A Simple Moving Average (SMA) of the stock’s price (usually last 20 days).

  2. Upper Band: Middle Band + 2 * Standard Deviation (last 20 days).

  3. 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 position

Plot 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_year
Year
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