<jupyter_start><jupyter_text>Double sorted Fama-french portfolios Author: Prof. <NAME> Imports<jupyter_code>import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
tr_df = pd.read_csv('tr_df.csv', index_col=[0,1]).iloc[:,0].unstack().T.astype(float)
tr_df.index = pd.to_datetime(tr_df.index)
ibov_composition = pd.read_csv('ibov_composition.csv', index_col=[0,1])['Weights'].astype(float)
ibov_composition.index = pd.MultiIndex.from_tuples([(x, pd.to_datetime(d)) for x,d in ibov_composition.index])
ibov_composition = ibov_composition.unstack().T.fillna(0)
df = pd.read_csv('IBOV_time_series.csv', index_col=0).astype(float)
df.index = pd.to_datetime(df.index)
tr_df.tail()
ibov_composition.tail()<jupyter_output><empty_output><jupyter_text>Momentum and low volatility signalsLet's now calculate a momentum and low volatility signals for every stock at every month end.<jupyter_code>k = 12 # months
mom_signals = tr_df.shift(21).pct_change(k * 12 - 21).dropna()
vol_signals = (np.log(tr_df).diff(1).rolling(252).std() * np.sqrt(252)).shift(1).dropna()<jupyter_output><empty_output><jupyter_text>Momentum portfoliosLet's now pick the top (20%) and bottom (20%) stocks according to our momentum signal:<jupyter_code>month_ends = [tr_df.index[i-1] for i in range(1,tr_df.shape[0]) if tr_df.index[i-1].month != tr_df.index[i].month]
month_ends = [eom for eom in month_ends if eom>mom_signals.index.min() and eom>vol_signals.index.min()]
mom_portfolios = {}
for eom in month_ends:
    stocks_on_date = mom_signals.loc[eom][mom_signals.loc[eom].abs()>0.0003].dropna().rank()
    n = stocks_on_date.shape[0]
    date_port = {
        'top' : list(stocks_on_date[stocks_on_date>n-n/5].index),
        'bottom' : list(stocks_on_date[stocks_on_date<n/5].index)
    }
    mom_portfolios[eom] = date_port<jupyter_output><empty_output><jupyter_text>Low vol portfoliosLet's now pick the lowest (20%) and highest (20%) volatility stocks:<jupyter_code>vol_portfolios = {}
for eom in month_ends:
    stocks_on_date = vol_signals.loc[eom][vol_signals.loc[eom].abs()>0.0003].dropna().rank(ascending=False)
    n = stocks_on_date.shape[0]
    date_port = {
        'top' : list(stocks_on_date[stocks_on_date>n-n/5].index),
        'bottom' : list(stocks_on_date[stocks_on_date<n/5].index)
    }
    vol_portfolios[eom] = date_port<jupyter_output><empty_output><jupyter_text>Momentum and Low vol intersection portfolios<jupyter_code>def intersection(lst1, lst2):
    return list(set(lst1) & set(lst2))

combo_port = {}
for eom in list(set(mom_portfolios.keys()) & set(vol_portfolios.keys())):
    date_port = {}
    top_stocks = intersection(mom_portfolios[eom]['top'], vol_portfolios[eom]['top'])
    bot_stocks = intersection(mom_portfolios[eom]['bottom'], vol_portfolios[eom]['bottom'])
    if len(top_stocks) == 0:
        print(f"No stocks for top on {eom}")
    if len(bot_stocks) == 0:
        print(f"No stocks for bottom on {eom}")
        
    date_port['top'] = top_stocks
    date_port['bottom'] = bot_stocks
    combo_port[eom] = date_port
def weights_on_date(d, port_name):
    if d in combo_port.keys() and len(combo_port[d][port_name])>-1:
        w = ibov_composition.loc[d, combo_port[d][port_name]]
        w = w/w.sum()
    else:
        w = None
    return w

def quant_on_date(d1, d0, port_name, notional):
    w = weights_on_date(d0, port_name)
    if w is not None:
        prices = tr_df.loc[d0, w.index]
        q = notional * w / prices
    else:
        q = None
    return q
    
calendar = [d for d in tr_df.index.unique() if d>=month_ends[0]]
backtests = pd.DataFrame(index=calendar, columns=['top', 'bottom'])
backtests.iloc[0] = 100

quant = {
    'q_top' : quant_on_date(month_ends[0], month_ends[0], 'top', backtests.iloc[0,0]),
    'q_bottom' : quant_on_date(month_ends[0], month_ends[0], 'bottom', backtests.iloc[0,1]),
}

for tdy, yst in zip(calendar[1:], calendar[:-1]):
    
    # calculate pnl of the top stocks
    p1top = tr_df.loc[tdy, quant['q_top'].index]
    p0top = tr_df.loc[yst, quant['q_top'].index]
    toppnl = (quant['q_top'] * (p1top - p0top)).sum()
    backtests.loc[tdy, 'top'] = backtests.loc[yst, 'top'] + toppnl
    
    # calculate pnl of the bottom stocks
    p1bot = tr_df.loc[tdy, quant['q_bottom'].index]
    p0bot = tr_df.loc[yst, quant['q_bottom'].index]
    botpnl = (quant['q_bottom'] * (p1bot - p0bot)).sum()
    backtests.loc[tdy, 'bottom'] = backtests.loc[yst, 'bottom'] + botpnl
    
    if yst in combo_port.keys(): # rebalance the portfolio      
        qt = quant_on_date(tdy, yst, 'top', backtests.loc[tdy, 'top'])
        qb = quant_on_date(tdy, yst, 'bottom', backtests.loc[tdy, 'bottom'])
        if qt is not None:
            quant['q_top'] = qt.fillna(0)
        if qb is not None:
            quant['q_bottom'] = qb.fillna(0)
            
backtests.head()
backtests.plot(figsize=(15,10), fontsize=16)
plt.title('Top and bottom portfolios', fontsize=20)
plt.legend(fontsize=20)
plt.show()<jupyter_output><empty_output><jupyter_text>Low vol with Momentum portfoliosLet's now choose positive momentum stocks among those with low vol:<jupyter_code>vol_then_mom_portfolios = {}
for eom in month_ends:
    stocks_on_date = vol_signals.loc[eom][vol_signals.loc[eom].abs()>0.0003].dropna().rank(ascending=False)
    n = stocks_on_date.shape[0]
    low_vol_stocks = list(stocks_on_date[stocks_on_date>n-n/5].index)
    stocks_on_date = mom_signals.loc[eom, low_vol_stocks]
    stocks_on_date = stocks_on_date[stocks_on_date.abs()>0.0003].dropna().rank()
    n = stocks_on_date.shape[0]
    low_vol_and_mom_stocks = list(stocks_on_date[stocks_on_date>n-n/5].index)
    vol_then_mom_portfolios[eom] = low_vol_and_mom_stocks
calendar = [d for d in tr_df.index.unique() if d>=month_ends[0]]
double_sort_backtest = pd.Series(index=calendar)
double_sort_backtest.iloc[0] = 100

d = month_ends[0]
w = ibov_composition.loc[d, vol_then_mom_portfolios[d]]
w = w/w.sum()
ref_date = max([x for x in tr_df.index.unique() if x < d])
q = double_sort_backtest.iloc[0] * w / tr_df.loc[d, w.index]

for tdy, yst in zip(calendar[1:], calendar[:-1]):
    
    # calculate pnl of the top stocks
    p1 = tr_df.loc[tdy, q.index]
    p0 = tr_df.loc[yst, q.index]
    toppnl = (q * (p1 - p0)).sum()
    double_sort_backtest.loc[tdy] = double_sort_backtest.loc[yst] + toppnl
    
    if yst in vol_then_mom_portfolios.keys(): # rebalance the portfolio      
        w = ibov_composition.loc[d, vol_then_mom_portfolios[yst]]
        w = w/w.sum()
        q = double_sort_backtest.loc[tdy] * w / tr_df.loc[yst, w.index]
            
double_sort_backtest.head()
df2 = pd.concat([double_sort_backtest.to_frame('double_sort'), df], axis=1, sort=True).dropna().drop('CDI', 1).astype(float)
df2 = np.exp(np.log(df2).diff(1).fillna(0).cumsum())
df2.plot(figsize=(15,10), fontsize=16)
plt.title('Low vol stocks with positive momentum vs. Ibovespa', fontsize=20)
plt.legend(fontsize=20)
plt.show()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.1493841306215984, 'fraction_numerical': 0.020338012030936693, 'mean_word_length': 4.4940991345397325, 'pattern_counts': {'":': 0, '<': 29, '<?xml version=': 0, '>': 39, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '6', 'dir': 'jupyter-structured-clean-dedup', 'id': '447556', 'license': 'MIT', 'n_tokens_mistral': 2844, 'n_tokens_neox': 2754, 'n_words': 488, 'path': 'Quantitative Finance Lectures/lecture02/fama_french_portfolios/double_sort.ipynb', 'repo_name': 'Finance-Hub/FinanceHubMaterials', 'size': '6990'}