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']

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 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_market

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:

# 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_year
Year
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'] = pos
signals_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