Momentum Strategies — Could Simple Ideas Beat Mr. Market?

K for What?
9 min readApr 14, 2021

Hi guys,

Momentum strategies are well-known on Wall Street, they have been proved to be profitable strategies in many research papers, the most famous among them is by Narasimhan Jegadeesh and Sheridan Titman. The core idea of momentum strategies is to buy stocks that are rising and sell them when they appear to have peaked. The motto is “Buy high, sell higher”

In this blog, I want to test profitability of momentum strategies in Vietnam stock market. The momentum strategies that I tested are as below:

  • Buy top stocks that have best most-recent-N-day returns
  • Buy top stocks that have best most-recent-N-day returns minus last month’s return
  • Buy top stocks that have best weighted monthly returns
  • Buy top stocks that have best weighted weekly returns

The codes for these strategies are as below:

def momentum_strategy_v1(df, momentum, top_stock, stock_universe):
df_compare = pd.DataFrame()
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)
df_ticker[f'Momentum_{momentum}_day'] = df_ticker['C']/df_ticker['C'].shift(momentum)-1
df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].dropna().reset_index(drop = True)
if len(df_month) >= 5:
df_compare = df_compare.append(df_month.iloc[-1, :])
df_compare = df_compare.reset_index(drop = True)
df_compare = df_compare.sort_values(by = [f'Momentum_{momentum}_day'], ascending = False, ignore_index = True)[:top_stock]
print(df_compare)
return df_compare
def momentum_strategy_v2(df, long, short, top_stock, stock_universe):
df_compare = pd.DataFrame()
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)
df_ticker[f'Momentum_{long}_{short}_day'] = df_ticker['C']/df_ticker['C'].shift(long)- df_ticker['C']/df_ticker['C'].shift(short)
df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].dropna().reset_index(drop = True)
if len(df_month) >= 5:
df_compare = df_compare.append(df_month.iloc[-1, :])
df_compare = df_compare.reset_index(drop = True)
df_compare = df_compare.sort_values(by = [f'Momentum_{long}_{short}_day'], ascending = False, ignore_index = True)[:top_stock]
return df_compare
def momentum_strategy_v3(df, weight_scheme , value_scheme , numer_of_months ,top_stock, stock_universe):
df_compare = pd.DataFrame()
momentum = pd.DataFrame(columns = ['Date', 'Ticker', 'Momentum'])
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)

df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].dropna().reset_index(drop = True)
df_month['Monthly_Momentum'] = df_month['C']/df_month['C'].shift(1)-1
if len(df_month) >= 60:
df_month = df_month[-numer_of_months:].reset_index(drop = True)
if weight_scheme == 'Timedecay':
weights = [i+1 for i in df_month.index.to_list() ]
else:
weights = [1 for i in range(len(df_month))]
adjusted_weights = [i/sum(weights) for i in weights]
df_month['adjusted_weights'] = adjusted_weights
if value_scheme == 'Equal':
df_month['Sign'] = np.where(df_month['Monthly_Momentum'] > 0, 1, -1)
else:
df_month['Sign'] = df_month['Monthly_Momentum']
momentum.loc[len(momentum)] = [df_month['Date'].iloc[-1],ticker, np.dot(df_month['Sign'], df_month['adjusted_weights'])]
momentum = momentum.sort_values(by = ['Momentum'], ascending = False, ignore_index = True)[:top_stock]

return momentum
def momentum_strategy_v4(df, weight_scheme , value_scheme , number_of_weeks ,top_stock, stock_universe):
df_compare = pd.DataFrame()
momentum = pd.DataFrame(columns = ['Date', 'Ticker', 'Momentum'])
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Week'] = df_ticker['Date'].dt.week
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_week'] = df_ticker['Week'].shift(1)

df_week = df_ticker.copy()
condition1 = df_week['Week'] != df_week['Lagged_time_week']
condition2 = ~ (df_week['Lagged_time_week'].isin([np.nan, None]) )
df_week = df_week[condition1 & condition2].dropna().reset_index(drop = True)
df_week['Weekly_Momentum'] = df_week['C']/df_week['C'].shift(1)-1
if len(df_week) >= 240:
df_week = df_week[-number_of_weeks:].reset_index(drop = True)
if weight_scheme == 'Timedecay':
weights = [i+1 for i in df_week.index.to_list() ]
else:
weights = [1 for i in range(len(df_week))]
adjusted_weights = [i/sum(weights) for i in weights]
df_week['adjusted_weights'] = adjusted_weights
if value_scheme == 'Equal':
df_week['Sign'] = np.where(df_week['Weekly_Momentum'] > 0, 1, -1)
else:
df_week['Sign'] = df_week['Weekly_Momentum']
momentum.loc[len(momentum)] = [df_week['Date'].iloc[-1], ticker, np.dot(df_week['Sign'], df_week['adjusted_weights'])]
momentum = momentum.sort_values(by = ['Momentum'], ascending = False, ignore_index = True)[:top_stock]

return momentum

Let’s see the backtest result for these strategies.

def backtest_momentum_strategy_v1(df, momentum_list, top_stock, stock_universe):
df_compare = pd.DataFrame()
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)
for momentum in momentum_list:
df_ticker[f'Momentum_{momentum}_day'] = df_ticker['C']/df_ticker['C'].shift(momentum)-1
df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].reset_index(drop = True)
if len(df_month) >= 60:
df_month['Forward Close'] = df_month['C'].shift(-1)
df_month['Return'] = df_month['Forward Close']/df_month['C'] -1
df_month = df_month.dropna().reset_index(drop = True)
df_compare = pd.concat([df_compare, df_month], ignore_index = True,)
dates = df_compare['Date'].drop_duplicates().to_list()
df_all = pd.DataFrame()
df_all['Date'] = dates
for momentum in momentum_list:
winning_rate = []
average_return = []
for date in dates:
df_date = df_compare[df_compare['Date'] == date]
df_date_momentum = df_date.sort_values(by = [f'Momentum_{momentum}_day'], ascending = False).reset_index(drop = True)[:top_stock]
df_date_momentum['Score'] = np.where(df_date_momentum['Return'] > 0, 1,0)
winning_rate.append(df_date_momentum['Score'].sum()/len(df_date_momentum))
average_return.append(df_date_momentum['Return'].mean())
df_all[f'{momentum}_day_winning_rate'] = winning_rate
df_all[f'{momentum}_day_avg_ret'] = average_return
df_all = df_all[df_all['Date'] >= pd.to_datetime('01012010', format = '%d%m%Y')]
df_all = df_all.sort_values(by = ['Date'], ascending = True, ignore_index = True)
return df_all

The optimal parameters for strategy 1 is buying top 5 stocks having best historical 150-day returns: The winning rate is about 55%, the average monthly return is about 2.45% (~29.4% per year since 2010 till now!), and the standard deviation is the smallest. Let’s go to the backtest code for strategy 2:

def backtest_momentum_strategy_v2(df, long_list, short_list, top_stock, stock_universe):
df_compare = pd.DataFrame()
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)
for long in long_list:
for short in short_list:
df_ticker[f'Momentum_{long}_{short}_day'] = df_ticker['C']/df_ticker['C'].shift(long) - df_ticker['C']/df_ticker['C'].shift(short)
df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].reset_index(drop = True)
if len(df_month) >= 60:
df_month['Forward Close'] = df_month['C'].shift(-1)
df_month['Return'] = df_month['Forward Close']/df_month['C'] -1
df_month = df_month.dropna().reset_index(drop = True)
df_compare = pd.concat([df_compare, df_month], ignore_index = True,)
dates = df_compare['Date'].drop_duplicates().to_list()
df_all = pd.DataFrame()
df_all['Date'] = dates
for long in long_list:
for short in short_list:
winning_rate = []
average_return = []
for date in dates:
df_date = df_compare[df_compare['Date'] == date]
df_date_momentum = df_date.sort_values(by = [f'Momentum_{long}_{short}_day'], ascending = False).reset_index(drop = True)[:top_stock]
df_date_momentum['Score'] = np.where(df_date_momentum['Return'] > 0, 1,0)
winning_rate.append(df_date_momentum['Score'].sum()/len(df_date_momentum))
average_return.append(df_date_momentum['Return'].mean())
df_all[f'Momentum_{long}_{short}_day_winning_rate'] = winning_rate
df_all[f'Momentum_{long}_{short}_day_avg_ret'] = average_return
df_all = df_all[df_all['Date'] >= pd.to_datetime('01012010', format = '%d%m%Y')]
df_all = df_all.sort_values(by = ['Date'], ascending = True, ignore_index = True)
return df_all

Though I tried many values of parameters, the backtest result was not better than the best of strategy 1: the best average return of strategy 2 is about 2.3% (~27.6% annually), the best average winning rate is about 55%.

To backtest strategy 3, I realized I should change the way I backtest a strategy. Long time ago, I often write all-in-one code to make the program run faster. Now I turn to use the method divide-and-conquer, it means that I write a function to calculate list of backtest dates, a function to calculate forward-looking return of stock, and a function of strategy as below:

def first_trading_date_of_month(df):
"""
input:
- df: DataFrame
output:
- list_of_dates: list
"""
df_dates = df.copy()[['Date']].sort_values(by = ['Date'], ascending = True, ignore_index = True)
df_dates['Month'] = df_dates['Date'].dt.month
df_dates['Month_lagged_1_date'] = df_dates['Month'].shift(1)
df_dates = df_dates[df_dates['Month'] != df_dates['Month_lagged_1_date']].reset_index(drop = True)
list_of_dates = df_dates['Date'].drop_duplicates().to_list()
return list_of_dates
list_of_dates = first_trading_date_of_month(df)
def Forward_return_of_month(df, list_of_dates, stock_list):
"""
input:
- df: DataFrame
- list_of_dates: list
output:
df_fwd_return: DataFrame
"""
df_month = df.copy()
condition = df_month['Date'].isin(list_of_dates)
df_month = df_month[condition].reset_index(drop = True)
df_month = df_month.sort_values(by = ['Ticker', 'Date'], ascending = [True, True], ignore_index = True)
df_fwd_return = pd.DataFrame()
for ticker in stock_list:
df_ticker = df_month.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].reset_index(drop = True)
df_ticker['Return'] = df_ticker['C'].shift(-1)/df_ticker['C']-1
if len(df_ticker) >= 60:
df_fwd_return = pd.concat([df_fwd_return, df_ticker], ignore_index = True)
df_fwd_return = df_fwd_return.dropna().reset_index(drop = True)
return df_fwd_return[['Ticker', 'Date', 'Return']]
df_return = Forward_return_of_month(df, list_of_dates, ticker_list_vn100)
def momentum_strategy_v3(df, weight_scheme , value_scheme , numer_of_months ,top_stock, stock_universe):
df_compare = pd.DataFrame()
momentum = pd.DataFrame(columns = ['Date', 'Ticker', 'Momentum'])
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Month'] = df_ticker['Date'].dt.month
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_month'] = df_ticker['Month'].shift(1)

df_month = df_ticker.copy()
condition1 = df_month['Month'] != df_month['Lagged_time_month']
condition2 = ~ (df_month['Lagged_time_month'].isin([np.nan, None]) )
df_month = df_month[condition1 & condition2].dropna().reset_index(drop = True)
df_month['Monthly_Momentum'] = df_month['C']/df_month['C'].shift(1)-1
if len(df_month) >= 60:
df_month = df_month[-numer_of_months:].reset_index(drop = True)
if weight_scheme == 'Timedecay':
weights = [i+1 for i in df_month.index.to_list() ]
else:
weights = [1 for i in range(len(df_month))]
adjusted_weights = [i/sum(weights) for i in weights]
df_month['adjusted_weights'] = adjusted_weights
if value_scheme == 'Equal':
df_month['Sign'] = np.where(df_month['Monthly_Momentum'] > 0, 1, -1)
else:
df_month['Sign'] = df_month['Monthly_Momentum']
momentum.loc[len(momentum)] = [df_month['Date'].iloc[-1],ticker, np.dot(df_month['Sign'], df_month['adjusted_weights'])]
momentum = momentum.sort_values(by = ['Momentum'], ascending = False, ignore_index = True)[:top_stock]

return momentum

The backtest result of strategy can be computed as below:

weight_scheme_list = ['Timedecay', '']
value_scheme_list = ['Equal', '']
numer_of_months_list = [12, 18, 24]
top_stock = 5
stock_universe = ticker_list_vn100
backtest_dates = df_return['Date'].drop_duplicates().to_list()
backtest_dates.sort(reverse = True)
backtest_dates = backtest_dates[:120]
df_result = pd.DataFrame(columns = ['Date', 'Weight_scheme', 'Value_scheme', 'Numer_of_months', 'Return', 'Winning_rate'])
for date in backtest_dates:
print(date)
for weight_scheme in weight_scheme_list:
for value_scheme in value_scheme_list:
for numer_of_months in numer_of_months_list:
df_date = df[df['Date'] <= date].reset_index(drop = True)
df_strategy = momentum_strategy_v3(df_date, weight_scheme , value_scheme , numer_of_months ,top_stock, stock_universe)
df_strategy = df_strategy.merge(df_return, how = 'inner', on = ['Ticker', 'Date'])
Return = df_strategy['Return'].mean()
Winning_rate = len(df_strategy[df_strategy['Return'] >0])/len(df_strategy)
df_result.loc[len(df_result)] = [date, weight_scheme, value_scheme, numer_of_months, Return, Winning_rate]

df_result = df_result.dropna().reset_index(drop = True)
df_result.groupby(['Weight_scheme', 'Value_scheme', 'Numer_of_months'])[['Return', 'Winning_rate']].mean()

The result is shown as below:

It shows that the best parameters are Weigh_scheme = 'Timedecay', Value_scheme = '', Number_of_months = 12 . The best average return is about 2.78% (~ 33.4% annually), and the best winning rate is about 57%.

To backtest the last strategy, which is a weekly strategy, the forward return needs to be calculated weekly. You will see that I can do it easily when I apply the same approach as above.

def first_trading_date_of_week(df):
"""
input:
- df: DataFrame
output:
- list_of_dates: list
"""
df_dates = df.copy()[['Date']].sort_values(by = ['Date'], ascending = True, ignore_index = True)
df_dates['Week'] = df_dates['Date'].dt.week
df_dates['Week_lagged_1_date'] = df_dates['Week'].shift(1)
df_dates = df_dates[df_dates['Week'] != df_dates['Week_lagged_1_date']].reset_index(drop = True)
list_of_dates = df_dates['Date'].drop_duplicates().to_list()
return list_of_dates
list_of_dates = first_trading_date_of_week(df)
def Forward_return_of_week(df, list_of_dates, stock_list):
"""
Compute forward 1-week return of stocks
input:
- df: DataFrame
- list_of_dates: list
output:
df_fwd_return: DataFrame
"""
df_week = df.copy()
condition = df_week['Date'].isin(list_of_dates)
df_week = df_week[condition].reset_index(drop = True)
df_week = df_week.sort_values(by = ['Ticker', 'Date'], ascending = [True, True], ignore_index = True)
df_fwd_return = pd.DataFrame()
for ticker in stock_list:
df_ticker = df_week.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].reset_index(drop = True)
df_ticker['Return'] = df_ticker['C'].shift(-1)/df_ticker['C']-1
if len(df_ticker) >= 60:
df_fwd_return = pd.concat([df_fwd_return, df_ticker], ignore_index = True)
df_fwd_return = df_fwd_return.dropna().reset_index(drop = True)
return df_fwd_return[['Ticker', 'Date', 'Return']]
df_return = Forward_return_of_week(df, list_of_dates, ticker_list_vn100)
def momentum_strategy_v4(df, weight_scheme , value_scheme , number_of_weeks ,top_stock, stock_universe):
df_compare = pd.DataFrame()
momentum = pd.DataFrame(columns = ['Date', 'Ticker', 'Momentum'])
for ticker in stock_universe:
df_ticker = df.copy()
df_ticker = df_ticker[df_ticker['Ticker'] == ticker].dropna().reset_index(drop = True)
df_ticker['Week'] = df_ticker['Date'].dt.week
df_ticker['Year'] = df_ticker['Date'].dt.year
df_ticker['Lagged_time_week'] = df_ticker['Week'].shift(1)

df_week = df_ticker.copy()
condition1 = df_week['Week'] != df_week['Lagged_time_week']
condition2 = ~ (df_week['Lagged_time_week'].isin([np.nan, None]) )
df_week = df_week[condition1 & condition2].dropna().reset_index(drop = True)
df_week['Weekly_Momentum'] = df_week['C']/df_week['C'].shift(1)-1
if len(df_week) >= 240:
df_week = df_week[-number_of_weeks:].reset_index(drop = True)
if weight_scheme == 'Timedecay':
weights = [i+1 for i in df_week.index.to_list() ]
else:
weights = [1 for i in range(len(df_week))]
adjusted_weights = [i/sum(weights) for i in weights]
df_week['adjusted_weights'] = adjusted_weights
if value_scheme == 'Equal':
df_week['Sign'] = np.where(df_week['Weekly_Momentum'] > 0, 1, -1)
else:
df_week['Sign'] = df_week['Weekly_Momentum']
momentum.loc[len(momentum)] = [df_week['Date'].iloc[-1], ticker, np.dot(df_week['Sign'], df_week['adjusted_weights'])]
momentum = momentum.sort_values(by = ['Momentum'], ascending = False, ignore_index = True)[:top_stock]

return momentum
weight_scheme_list = ['Timedecay', '']
value_scheme_list = ['Equal', '']
number_of_weeks_list = [4, 8, 12, 16, 20, 24, 32, 40, 48]
top_stock = 5
stock_universe = ticker_list_vn100
backtest_dates = df_return['Date'].drop_duplicates().to_list()
backtest_dates.sort(reverse = True)
backtest_dates = backtest_dates[:480]
df_result = pd.DataFrame(columns = ['Date', 'Weight_scheme', 'Value_scheme', 'number_of_weeks', 'Return', 'Winning_rate'])
for date in backtest_dates:
print(date)
for weight_scheme in weight_scheme_list:
for value_scheme in value_scheme_list:
for number_of_weeks in number_of_weeks_list:
df_date = df[df['Date'] <= date].reset_index(drop = True)
df_strategy = momentum_strategy_v4(df_date, weight_scheme , value_scheme , number_of_weeks ,top_stock, stock_universe)
df_strategy = df_strategy.merge(df_return, how = 'inner', on = ['Ticker', 'Date'])
Return = df_strategy['Return'].mean()
Winning_rate = len(df_strategy[df_strategy['Return'] >0])/len(df_strategy)
df_result.loc[len(df_result)] = [date, weight_scheme, value_scheme, number_of_weeks, Return, Winning_rate]

df_result = df_result.dropna().reset_index(drop = True)
pprint(df_result.groupby(['Weight_scheme', 'Value_scheme', 'number_of_weeks'])[['Return', 'Winning_rate']].mean())

The result is shown as below:

Don’t be fool by the numbers! It’s weekly return, when you convert into annual return, the result is good enough. However, the thing that makes me concern most is the winning rate, it’s almost 50–50, so I don’t think I can take profit from these strategies.

Yay, Ca y est! Finally I have completed my third blog! Thank you very much for your kind patience. Hope to get feedbacks from you and see you in next blog!

--

--

K for What?

Quant Researcher, Data Scientist, Food Hater (so I eat them a lot).