This chapter provides a replication of the Fama-French factor portfolios for the Vietnamese stock market. The Fama-French factor models represent a cornerstone of empirical asset pricing, originating from the seminal work of Fama and French (1992) and later extended in Fama and French (2015). These models have transformed how academics and practitioners understand the cross-section of expected stock returns, moving beyond the single-factor Capital Asset Pricing Model to incorporate multiple sources of systematic risk.
We construct both the three-factor and five-factor models at monthly and daily frequencies. The monthly factors serve as the foundation for most asset pricing tests and portfolio analyses, while the daily factors enable higher-frequency applications including short-horizon event studies, market microstructure research, and daily beta estimation. By constructing factors at both frequencies, we create a complete toolkit for empirical finance research in the Vietnamese market.
The chapter proceeds as follows. We first discuss the theoretical motivation for each factor and the economic intuition behind the Fama-French methodology. We then prepare the necessary data, merging stock returns with accounting characteristics. Next, we implement the portfolio sorting procedures that form the basis of factor construction, carefully following the original Fama-French protocols while adapting them for Vietnamese market characteristics. We construct the three-factor model (market, size, and value) before extending to the five-factor model (adding profitability and investment). Finally, we construct daily factors and validate our replicated factors through various diagnostic checks.
17.1 Theoretical Background
17.1.1 The Evolution from CAPM to Multi-Factor Models
The Capital Asset Pricing Model of Sharpe (1964) posits that a single factor—the market portfolio—should explain all cross-sectional variation in expected returns. However, decades of empirical research have documented persistent patterns that CAPM cannot explain. Fama and French (1992) demonstrated that two firm characteristics—size and book-to-market ratio—capture substantial variation in average returns that the market beta leaves unexplained.
Small firms tend to earn higher returns than large firms, a pattern known as the size effect. Similarly, firms with high book-to-market ratios (value stocks) tend to outperform firms with low book-to-market ratios (growth stocks), known as the value premium. The three-factor model formalizes these observations by constructing tradeable factor portfolios:
\(r_{i,t} - r_{f,t}\) is the excess return on asset \(i\)
\(r_{m,t} - r_{f,t}\) is the market excess return
\(SMB_t\) (Small Minus Big) is the size factor
\(HML_t\) (High Minus Low) is the value factor
17.1.2 The Five-Factor Extension
Fama and French (2015) extended the model to include two additional factors motivated by the dividend discount model. Firms with higher profitability should have higher expected returns (all else equal), and firms with aggressive investment policies should have lower expected returns:
\(RMW_t\) (Robust Minus Weak) is the profitability factor
\(CMA_t\) (Conservative Minus Aggressive) is the investment factor
17.1.3 Factor Construction Methodology
The Fama-French methodology constructs factors through double-sorted portfolios:
Size sorts: Stocks are divided into Small and Big groups based on median market capitalization.
Characteristic sorts: Within each size group, stocks are sorted into terciles based on book-to-market (for HML), operating profitability (for RMW), or investment (for CMA).
Factor returns: Factors are computed as the difference between average returns of portfolios with high versus low characteristic values, averaging across size groups to neutralize size effects.
Timing: Portfolios are formed at the end of June each year using accounting data from the prior fiscal year, ensuring all information was publicly available at formation.
17.2 Setting Up the Environment
We load the required Python packages for data manipulation, statistical analysis, and visualization.
Show code
import pandas as pdimport numpy as npimport sqlite3import statsmodels.formula.api as smffrom scipy.stats.mstats import winsorizeimport matplotlib.pyplot as pltfrom plotnine import*from mizani.formatters import percent_format, comma_format
We connect to our SQLite database containing the processed Vietnamese financial data.
We load the monthly stock returns data, which includes excess returns, market capitalization, and the risk-free rate. These variables are essential for computing value-weighted portfolio returns and factor premiums.
Monthly returns: 165,499 observations
Unique stocks: 1,457
Date range: 2010-02 to 2023-12
17.3.2 Loading Company Fundamentals
We load the company fundamentals data containing book equity, operating profitability, and investment—the characteristics needed for constructing the Fama-French factors.
Show code
comp_vn = pd.read_sql_query( sql=""" SELECT symbol, datadate, be, op, inv FROM comp_vn """, con=tidy_finance, parse_dates={"datadate"}).dropna()print(f"Fundamentals: {len(comp_vn):,} firm-year observations")print(f"Unique firms: {comp_vn['symbol'].nunique():,}")
Following Fama-French conventions, we construct the sorting variables with careful attention to timing. The key principles are:
Size (June Market Cap): We use market capitalization at the end of June of year \(t\) to sort stocks into size groups. This ensures we capture the firm’s size at the moment of portfolio formation.
Book-to-Market Ratio: We use book equity from fiscal year \(t-1\) divided by market equity at the end of December \(t-1\). This creates a six-month gap between the accounting data and portfolio formation, ensuring the information was publicly available.
Portfolio Formation Date: Portfolios are formed on July 1st and held for twelve months until the following June.
Show code
def construct_sorting_variables(prices_monthly, comp_vn):""" Construct sorting variables following Fama-French methodology. Parameters ---------- prices_monthly : pd.DataFrame Monthly stock returns with market cap comp_vn : pd.DataFrame Company fundamentals with book equity, profitability, investment Returns ------- pd.DataFrame Sorting variables aligned with July 1st formation dates """# 1. Size: June market capitalization# Portfolio formation is July 1st, so we use June market cap size = (prices_monthly .query("date.dt.month == 6") .assign( sorting_date=lambda x: x["date"] + pd.offsets.MonthBegin(1) ) [["symbol", "sorting_date", "mktcap"]] .rename(columns={"mktcap": "size"}) )print(f"Size observations: {len(size):,}")# 2. Market Equity: December market cap for B/M calculation# December t-1 market cap is used with fiscal year t-1 book equity# This is then used for July t portfolio formation market_equity = (prices_monthly .query("date.dt.month == 12") .assign(# December year t-1 maps to July year t formation sorting_date=lambda x: x["date"] + pd.offsets.MonthBegin(7) ) [["symbol", "sorting_date", "mktcap"]] .rename(columns={"mktcap": "me"}) )print(f"Market equity observations: {len(market_equity):,}")# 3. Book-to-Market and other characteristics# Fiscal year t-1 data is used for July t portfolio formation book_to_market = (comp_vn .assign(# Fiscal year-end + 6 months = July formation sorting_date=lambda x: pd.to_datetime( (x["datadate"].dt.year +1).astype(str) +"-07-01" ) ) .merge(market_equity, on=["symbol", "sorting_date"], how="inner") .assign(# Scale book equity to match market equity units# BE is in VND, ME is in millions VND bm=lambda x: x["be"] / (x["me"] *1e9) ) [["symbol", "sorting_date", "me", "bm", "op", "inv"]] )print(f"Book-to-market observations: {len(book_to_market):,}")# 4. Merge size with characteristics sorting_variables = (size .merge(book_to_market, on=["symbol", "sorting_date"], how="inner") .dropna() .drop_duplicates(subset=["symbol", "sorting_date"]) )return sorting_variablessorting_variables = construct_sorting_variables(prices_monthly, comp_vn)print(f"\nFinal sorting variables: {len(sorting_variables):,} stock-years")print(f"Sorting date range: {sorting_variables['sorting_date'].min():%Y-%m} to {sorting_variables['sorting_date'].max():%Y-%m}")
Size observations: 13,756
Market equity observations: 14,286
Book-to-market observations: 13,389
Final sorting variables: 12,046 stock-years
Sorting date range: 2011-07 to 2023-07
17.3.4 Validating Sorting Variables
Before proceeding, we validate that our sorting variables have reasonable distributions. The book-to-market ratio should center around 1.0 for a typical market, though emerging markets may differ.
Show code
print("Sorting Variable Summary Statistics:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))# Check for extreme values that might indicate data issuesprint(f"\nB/M Median: {sorting_variables['bm'].median():.4f}")print(f"B/M 1st percentile: {sorting_variables['bm'].quantile(0.01):.4f}")print(f"B/M 99th percentile: {sorting_variables['bm'].quantile(0.99):.4f}")
Extreme values in sorting characteristics can distort portfolio assignments and factor returns. We apply winsorization to limit the influence of outliers while preserving the general ranking of stocks.
Show code
# Check BEFORE winsorizationprint("BEFORE Winsorization:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))# Apply winsorizationdef winsorize_characteristics(df, columns, limits=(0.01, 0.99)):""" Apply winsorization using pandas clip. """ df = df.copy()for col in columns:if col in df.columns: lower = df[col].quantile(limits[0]) upper = df[col].quantile(limits[1]) df[col] = df[col].clip(lower=lower, upper=upper)print(f" {col}: clipped to [{lower:.4f}, {upper:.4f}]")return dfsorting_variables = winsorize_characteristics( sorting_variables, columns=["bm", "op", "inv"], # Don't winsorize size limits=(0.01, 0.99))# Check AFTER winsorizationprint("\nAFTER Winsorization:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))
BEFORE Winsorization:
size bm op inv
count 12046.0000 12046.0000 12046.0000 12046.0000
mean 2225.5648 1.7033 0.1852 0.1322
std 14680.4225 3.8683 0.2782 2.5410
min 0.4864 0.0014 -0.7529 -0.9569
25% 62.7556 0.7595 0.0309 -0.0495
50% 182.6410 1.1849 0.1367 0.0342
75% 641.8896 1.8853 0.2952 0.1582
max 426020.9817 272.1893 1.4256 261.3355
bm: clipped to [0.1710, 8.0262]
op: clipped to [-0.7319, 1.2192]
inv: clipped to [-0.3990, 1.5195]
AFTER Winsorization:
size bm op inv
count 12046.0000 12046.0000 12046.0000 12046.0000
mean 2225.5648 1.5544 0.1837 0.0894
std 14680.4225 1.2843 0.2705 0.2721
min 0.4864 0.1710 -0.7319 -0.3990
25% 62.7556 0.7595 0.0309 -0.0495
50% 182.6410 1.1849 0.1367 0.0342
75% 641.8896 1.8853 0.2952 0.1582
max 426020.9817 8.0262 1.2192 1.5195
17.4 Portfolio Assignment Functions
17.4.1 The Portfolio Assignment Function
We create a flexible function for assigning stocks to portfolios based on quantile breakpoints. This function handles both independent sorts (where breakpoints are computed across all stocks) and dependent sorts (where breakpoints are computed within subgroups).
Show code
def assign_portfolio(data, sorting_variable, percentiles):"""Assign portfolios to a bin according to a sorting variable."""# Get the values values = data[sorting_variable].dropna()iflen(values) ==0:return pd.Series([np.nan] *len(data), index=data.index)# Calculate breakpoints breakpoints = values.quantile(percentiles, interpolation="linear")# Handle duplicate breakpoints by using unique values unique_breakpoints = np.unique(breakpoints)# If all values are the same, assign all to portfolio 1iflen(unique_breakpoints) <=1:return pd.Series([1] *len(data), index=data.index)# Set boundaries to -inf and +inf unique_breakpoints.iloc[0] =-np.inf unique_breakpoints.iloc[unique_breakpoints.size-1] = np.inf# Assign to bins assigned = pd.cut( data[sorting_variable], bins=unique_breakpoints, labels=pd.Series(range(1, breakpoints.size)), include_lowest=True, right=False )return assigned
Show code
# Check the distribution of characteristics BEFORE portfolio assignmentprint("Operating Profitability Distribution:")print(sorting_variables["op"].describe())print(f"\nUnique OP values: {sorting_variables['op'].nunique()}")print("\nInvestment Distribution:")print(sorting_variables["inv"].describe())print(f"\nUnique INV values: {sorting_variables['inv'].nunique()}")# Check breakpoints for a specific datetest_date = sorting_variables["sorting_date"].iloc[0]test_data = sorting_variables.query("sorting_date == @test_date")print(f"\nBreakpoints for {test_date}:")print(f"OP 30th percentile: {test_data['op'].quantile(0.3):.4f}")print(f"OP 70th percentile: {test_data['op'].quantile(0.7):.4f}")print(f"INV 30th percentile: {test_data['inv'].quantile(0.3):.4f}")print(f"INV 70th percentile: {test_data['inv'].quantile(0.7):.4f}")
Operating Profitability Distribution:
count 12046.000000
mean 0.183738
std 0.270509
min -0.731888
25% 0.030913
50% 0.136675
75% 0.295185
max 1.219223
Name: op, dtype: float64
Unique OP values: 11804
Investment Distribution:
count 12046.000000
mean 0.089388
std 0.272147
min -0.399042
25% -0.049497
50% 0.034157
75% 0.158155
max 1.519474
Name: inv, dtype: float64
Unique INV values: 11805
Breakpoints for 2019-07-01 00:00:00:
OP 30th percentile: 0.0541
OP 70th percentile: 0.2566
INV 30th percentile: -0.0343
INV 70th percentile: 0.1116
17.4.2 Assigning Portfolios for Three-Factor Model
For the three-factor model, we perform independent double sorts on size and book-to-market. Size is split at the median (2 groups), and book-to-market is split at the 30th and 70th percentiles (3 groups), creating 6 portfolios.
We merge the portfolio assignments with monthly returns. The key insight is that portfolios formed in July of year \(t\) are held through June of year \(t+1\). We implement this by computing a sorting_date for each monthly return observation.
Show code
def merge_portfolios_with_returns(prices_monthly, portfolio_assignments):""" Merge portfolio assignments with monthly returns. Portfolios formed in July t are held through June t+1. Parameters ---------- prices_monthly : pd.DataFrame Monthly stock returns portfolio_assignments : pd.DataFrame Portfolio assignments with sorting_date Returns ------- pd.DataFrame Returns merged with portfolio assignments """ portfolios = (prices_monthly .assign(# Map each return month to its portfolio formation date sorting_date=lambda x: pd.to_datetime( np.where( x["date"].dt.month <=6, (x["date"].dt.year -1).astype(str) +"-07-01", x["date"].dt.year.astype(str) +"-07-01" ) ) ) .merge( portfolio_assignments, on=["symbol", "sorting_date"], how="inner" ) )return portfoliosportfolios_monthly_ff3 = merge_portfolios_with_returns( prices_monthly, portfolios_ff3[["symbol", "sorting_date", "portfolio_size", "portfolio_bm"]])print(f"Merged observations: {len(portfolios_monthly_ff3):,}")
Merged observations: 136,444
17.5.2 Computing Value-Weighted Portfolio Returns
We compute value-weighted returns for each of the six portfolios. Value-weighting uses lagged market capitalization to avoid look-ahead bias.
Show code
def compute_portfolio_returns(data, grouping_vars):""" Compute value-weighted portfolio returns. Parameters ---------- data : pd.DataFrame Returns data with portfolio assignments and mktcap_lag grouping_vars : list Variables defining portfolio groups Returns ------- pd.DataFrame Value-weighted returns for each portfolio-date """ portfolio_returns = (data .groupby(grouping_vars + ["date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]),"n_stocks": len(x) })) .reset_index() )return portfolio_returns# Compute portfolio returnsportfolio_returns_ff3 = compute_portfolio_returns( portfolios_monthly_ff3, ["portfolio_size", "portfolio_bm"])print("Portfolio Returns Summary:")print(portfolio_returns_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True)["ret"].describe().round(4))
factors_ff3_monthly.to_sql( name="factors_ff3_monthly", con=tidy_finance, if_exists="replace", index=False)print("Three-factor monthly data saved to database.")
Three-factor monthly data saved to database.
17.6 Fama-French Five-Factor Model (Monthly)
17.6.1 Portfolio Assignments with Dependent Sorts
For the five-factor model, we use dependent sorts: size is sorted independently, but profitability and investment are sorted within size groups. This controls for the correlation between size and these characteristics.
# Check characteristic monotonicity for each dimensionprint("Profitability by Portfolio (should be increasing):")print(portfolios_ff5.groupby("portfolio_op", observed=True)["op"].median().round(4))print("\nInvestment by Portfolio (should be increasing):")print(portfolios_ff5.groupby("portfolio_inv", observed=True)["inv"].median().round(4))# Check portfolio countsprint("\nStocks per Size/Profitability Bin:")print(portfolios_ff5.groupby(["portfolio_size", "portfolio_op"], observed=True).size().unstack(fill_value=0))# Check number of unique firms per year(portfolios_ff5 .groupby("sorting_date")["symbol"] .nunique())
factors_ff5_monthly.to_sql( name="factors_ff5_monthly", con=tidy_finance, if_exists="replace", index=False)print("Five-factor monthly data saved to database.")
Five-factor monthly data saved to database.
17.7 Daily Fama-French Factors
17.7.1 Motivation for Daily Factors
Daily factors are essential for several applications:
Daily beta estimation: CAPM regressions using daily data require daily market excess returns.
High-frequency research: Market microstructure studies need daily or intraday factor data.
The construction methodology mirrors the monthly approach, but we compute portfolio returns at daily frequency while maintaining the same annual portfolio formation dates.
Daily returns: 3,462,157 observations
Date range: 2010-01-05 to 2023-12-29
17.7.3 Adding Market Cap for Daily Weighting
For value-weighted daily returns, we need market capitalization. We use the most recent monthly market cap as the weight for daily returns within that month.
Show code
# Get monthly market cap to use as weights for daily returnsmktcap_monthly = (prices_monthly [["symbol", "date", "mktcap_lag"]] .assign(year_month=lambda x: x["date"].dt.to_period("M")))# Add year_month to daily data for mergingprices_daily = (prices_daily .assign(year_month=lambda x: x["date"].dt.to_period("M")) .merge( mktcap_monthly[["symbol", "year_month", "mktcap_lag"]], on=["symbol", "year_month"], how="left" ) .dropna(subset=["ret_excess", "mktcap_lag"]))print(f"Daily returns with weights: {len(prices_daily):,} observations")
Daily returns with weights: 3,443,815 observations
17.7.4 Merging Daily Returns with Portfolios
We use the same portfolio assignments (formed annually in July) for daily returns.
Factor Models Explained: The Fama-French three-factor model adds size (SMB) and value (HML) factors to the CAPM, while the five-factor model further includes profitability (RMW) and investment (CMA) factors.
Construction Methodology: Factors are constructed through double-sorted portfolios with careful attention to timing. Portfolios are formed in July using accounting data from the prior fiscal year to ensure information was publicly available.
Independent vs. Dependent Sorts: The three-factor model uses independent sorts on size and book-to-market, creating a 2×3 grid. The five-factor model uses dependent sorts where characteristics are sorted within size groups.
Value-Weighted Returns: Portfolio returns are computed using value-weighting with lagged market capitalization to avoid look-ahead bias.
Daily Factors: Daily factors use the same annual portfolio assignments but compute returns at daily frequency, enabling higher-frequency applications like daily beta estimation.
Market Factor: The market factor is computed independently as the value-weighted return of all stocks minus the risk-free rate.
Validation: Factor quality can be assessed through characteristic monotonicity, portfolio diversification, cumulative returns, and consistency between daily and monthly frequencies.
Vietnamese Market Adaptation: While following the original Fama-French methodology, we adapt for Vietnamese market characteristics including VAS accounting standards, reporting timelines, and currency units.
Fama, Eugene F., and Kenneth R. French. 1992. “The cross-section of expected stock returns.”The Journal of Finance 47 (2): 427–65. https://doi.org/2329112.
# Fama-French FactorsThis chapter provides a replication of the Fama-French factor portfolios for the Vietnamese stock market. The Fama-French factor models represent a cornerstone of empirical asset pricing, originating from the seminal work of @Fama1992 and later extended in @FamaFrench2015. These models have transformed how academics and practitioners understand the cross-section of expected stock returns, moving beyond the single-factor Capital Asset Pricing Model to incorporate multiple sources of systematic risk.We construct both the three-factor and five-factor models at monthly and daily frequencies. The monthly factors serve as the foundation for most asset pricing tests and portfolio analyses, while the daily factors enable higher-frequency applications including short-horizon event studies, market microstructure research, and daily beta estimation. By constructing factors at both frequencies, we create a complete toolkit for empirical finance research in the Vietnamese market.The chapter proceeds as follows. We first discuss the theoretical motivation for each factor and the economic intuition behind the Fama-French methodology. We then prepare the necessary data, merging stock returns with accounting characteristics. Next, we implement the portfolio sorting procedures that form the basis of factor construction, carefully following the original Fama-French protocols while adapting them for Vietnamese market characteristics. We construct the three-factor model (market, size, and value) before extending to the five-factor model (adding profitability and investment). Finally, we construct daily factors and validate our replicated factors through various diagnostic checks.## Theoretical Background### The Evolution from CAPM to Multi-Factor ModelsThe Capital Asset Pricing Model of @Sharpe1964 posits that a single factor—the market portfolio—should explain all cross-sectional variation in expected returns. However, decades of empirical research have documented persistent patterns that CAPM cannot explain. @Fama1992 demonstrated that two firm characteristics—size and book-to-market ratio—capture substantial variation in average returns that the market beta leaves unexplained.Small firms tend to earn higher returns than large firms, a pattern known as the size effect. Similarly, firms with high book-to-market ratios (value stocks) tend to outperform firms with low book-to-market ratios (growth stocks), known as the value premium. The three-factor model formalizes these observations by constructing tradeable factor portfolios:$$r_{i,t} - r_{f,t} = \alpha_i + \beta_i^{MKT}(r_{m,t} - r_{f,t}) + \beta_i^{SMB} \cdot SMB_t + \beta_i^{HML} \cdot HML_t + \varepsilon_{i,t}$$ {#eq-ff3}where:- $r_{i,t} - r_{f,t}$ is the excess return on asset $i$- $r_{m,t} - r_{f,t}$ is the market excess return- $SMB_t$ (Small Minus Big) is the size factor- $HML_t$ (High Minus Low) is the value factor### The Five-Factor Extension@FamaFrench2015 extended the model to include two additional factors motivated by the dividend discount model. Firms with higher profitability should have higher expected returns (all else equal), and firms with aggressive investment policies should have lower expected returns:$$r_{i,t} - r_{f,t} = \alpha_i + \beta_i^{MKT}MKT_t + \beta_i^{SMB}SMB_t + \beta_i^{HML}HML_t + \beta_i^{RMW}RMW_t + \beta_i^{CMA}CMA_t + \varepsilon_{i,t}$$ {#eq-ff5}where:- $RMW_t$ (Robust Minus Weak) is the profitability factor- $CMA_t$ (Conservative Minus Aggressive) is the investment factor### Factor Construction MethodologyThe Fama-French methodology constructs factors through double-sorted portfolios:1. **Size sorts**: Stocks are divided into Small and Big groups based on median market capitalization.2. **Characteristic sorts**: Within each size group, stocks are sorted into terciles based on book-to-market (for HML), operating profitability (for RMW), or investment (for CMA).3. **Factor returns**: Factors are computed as the difference between average returns of portfolios with high versus low characteristic values, averaging across size groups to neutralize size effects.4. **Timing**: Portfolios are formed at the end of June each year using accounting data from the prior fiscal year, ensuring all information was publicly available at formation.## Setting Up the EnvironmentWe load the required Python packages for data manipulation, statistical analysis, and visualization.```{python}import pandas as pdimport numpy as npimport sqlite3import statsmodels.formula.api as smffrom scipy.stats.mstats import winsorizeimport matplotlib.pyplot as pltfrom plotnine import*from mizani.formatters import percent_format, comma_format```We connect to our SQLite database containing the processed Vietnamese financial data.```{python}tidy_finance = sqlite3.connect(database="data/tidy_finance_python.sqlite")```## Data Preparation### Loading Stock ReturnsWe load the monthly stock returns data, which includes excess returns, market capitalization, and the risk-free rate. These variables are essential for computing value-weighted portfolio returns and factor premiums.```{python}prices_monthly = pd.read_sql_query( sql=""" SELECT symbol, date, ret_excess, mktcap, mktcap_lag, risk_free FROM prices_monthly """, con=tidy_finance, parse_dates={"date"}).dropna()print(f"Monthly returns: {len(prices_monthly):,} observations")print(f"Unique stocks: {prices_monthly['symbol'].nunique():,}")print(f"Date range: {prices_monthly['date'].min():%Y-%m} to {prices_monthly['date'].max():%Y-%m}")```### Loading Company FundamentalsWe load the company fundamentals data containing book equity, operating profitability, and investment—the characteristics needed for constructing the Fama-French factors.```{python}comp_vn = pd.read_sql_query( sql=""" SELECT symbol, datadate, be, op, inv FROM comp_vn """, con=tidy_finance, parse_dates={"datadate"}).dropna()print(f"Fundamentals: {len(comp_vn):,} firm-year observations")print(f"Unique firms: {comp_vn['symbol'].nunique():,}")```### Constructing Sorting VariablesFollowing Fama-French conventions, we construct the sorting variables with careful attention to timing. The key principles are:1. **Size (June Market Cap)**: We use market capitalization at the end of June of year $t$ to sort stocks into size groups. This ensures we capture the firm's size at the moment of portfolio formation.2. **Book-to-Market Ratio**: We use book equity from fiscal year $t-1$ divided by market equity at the end of December $t-1$. This creates a six-month gap between the accounting data and portfolio formation, ensuring the information was publicly available.3. **Portfolio Formation Date**: Portfolios are formed on July 1st and held for twelve months until the following June.```{python}def construct_sorting_variables(prices_monthly, comp_vn):""" Construct sorting variables following Fama-French methodology. Parameters ---------- prices_monthly : pd.DataFrame Monthly stock returns with market cap comp_vn : pd.DataFrame Company fundamentals with book equity, profitability, investment Returns ------- pd.DataFrame Sorting variables aligned with July 1st formation dates """# 1. Size: June market capitalization# Portfolio formation is July 1st, so we use June market cap size = (prices_monthly .query("date.dt.month == 6") .assign( sorting_date=lambda x: x["date"] + pd.offsets.MonthBegin(1) ) [["symbol", "sorting_date", "mktcap"]] .rename(columns={"mktcap": "size"}) )print(f"Size observations: {len(size):,}")# 2. Market Equity: December market cap for B/M calculation# December t-1 market cap is used with fiscal year t-1 book equity# This is then used for July t portfolio formation market_equity = (prices_monthly .query("date.dt.month == 12") .assign(# December year t-1 maps to July year t formation sorting_date=lambda x: x["date"] + pd.offsets.MonthBegin(7) ) [["symbol", "sorting_date", "mktcap"]] .rename(columns={"mktcap": "me"}) )print(f"Market equity observations: {len(market_equity):,}")# 3. Book-to-Market and other characteristics# Fiscal year t-1 data is used for July t portfolio formation book_to_market = (comp_vn .assign(# Fiscal year-end + 6 months = July formation sorting_date=lambda x: pd.to_datetime( (x["datadate"].dt.year +1).astype(str) +"-07-01" ) ) .merge(market_equity, on=["symbol", "sorting_date"], how="inner") .assign(# Scale book equity to match market equity units# BE is in VND, ME is in millions VND bm=lambda x: x["be"] / (x["me"] *1e9) ) [["symbol", "sorting_date", "me", "bm", "op", "inv"]] )print(f"Book-to-market observations: {len(book_to_market):,}")# 4. Merge size with characteristics sorting_variables = (size .merge(book_to_market, on=["symbol", "sorting_date"], how="inner") .dropna() .drop_duplicates(subset=["symbol", "sorting_date"]) )return sorting_variablessorting_variables = construct_sorting_variables(prices_monthly, comp_vn)print(f"\nFinal sorting variables: {len(sorting_variables):,} stock-years")print(f"Sorting date range: {sorting_variables['sorting_date'].min():%Y-%m} to {sorting_variables['sorting_date'].max():%Y-%m}")```### Validating Sorting VariablesBefore proceeding, we validate that our sorting variables have reasonable distributions. The book-to-market ratio should center around 1.0 for a typical market, though emerging markets may differ.```{python}print("Sorting Variable Summary Statistics:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))# Check for extreme values that might indicate data issuesprint(f"\nB/M Median: {sorting_variables['bm'].median():.4f}")print(f"B/M 1st percentile: {sorting_variables['bm'].quantile(0.01):.4f}")print(f"B/M 99th percentile: {sorting_variables['bm'].quantile(0.99):.4f}")```### Handling OutliersExtreme values in sorting characteristics can distort portfolio assignments and factor returns. We apply winsorization to limit the influence of outliers while preserving the general ranking of stocks.```{python}# Check BEFORE winsorizationprint("BEFORE Winsorization:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))# Apply winsorizationdef winsorize_characteristics(df, columns, limits=(0.01, 0.99)):""" Apply winsorization using pandas clip. """ df = df.copy()for col in columns:if col in df.columns: lower = df[col].quantile(limits[0]) upper = df[col].quantile(limits[1]) df[col] = df[col].clip(lower=lower, upper=upper)print(f" {col}: clipped to [{lower:.4f}, {upper:.4f}]")return dfsorting_variables = winsorize_characteristics( sorting_variables, columns=["bm", "op", "inv"], # Don't winsorize size limits=(0.01, 0.99))# Check AFTER winsorizationprint("\nAFTER Winsorization:")print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))```## Portfolio Assignment Functions### The Portfolio Assignment FunctionWe create a flexible function for assigning stocks to portfolios based on quantile breakpoints. This function handles both independent sorts (where breakpoints are computed across all stocks) and dependent sorts (where breakpoints are computed within subgroups).```{python}def assign_portfolio(data, sorting_variable, percentiles):"""Assign portfolios to a bin according to a sorting variable."""# Get the values values = data[sorting_variable].dropna()iflen(values) ==0:return pd.Series([np.nan] *len(data), index=data.index)# Calculate breakpoints breakpoints = values.quantile(percentiles, interpolation="linear")# Handle duplicate breakpoints by using unique values unique_breakpoints = np.unique(breakpoints)# If all values are the same, assign all to portfolio 1iflen(unique_breakpoints) <=1:return pd.Series([1] *len(data), index=data.index)# Set boundaries to -inf and +inf unique_breakpoints.iloc[0] =-np.inf unique_breakpoints.iloc[unique_breakpoints.size-1] = np.inf# Assign to bins assigned = pd.cut( data[sorting_variable], bins=unique_breakpoints, labels=pd.Series(range(1, breakpoints.size)), include_lowest=True, right=False )return assigned``````{python}# Check the distribution of characteristics BEFORE portfolio assignmentprint("Operating Profitability Distribution:")print(sorting_variables["op"].describe())print(f"\nUnique OP values: {sorting_variables['op'].nunique()}")print("\nInvestment Distribution:")print(sorting_variables["inv"].describe())print(f"\nUnique INV values: {sorting_variables['inv'].nunique()}")# Check breakpoints for a specific datetest_date = sorting_variables["sorting_date"].iloc[0]test_data = sorting_variables.query("sorting_date == @test_date")print(f"\nBreakpoints for {test_date}:")print(f"OP 30th percentile: {test_data['op'].quantile(0.3):.4f}")print(f"OP 70th percentile: {test_data['op'].quantile(0.7):.4f}")print(f"INV 30th percentile: {test_data['inv'].quantile(0.3):.4f}")print(f"INV 70th percentile: {test_data['inv'].quantile(0.7):.4f}")```### Assigning Portfolios for Three-Factor ModelFor the three-factor model, we perform independent double sorts on size and book-to-market. Size is split at the median (2 groups), and book-to-market is split at the 30th and 70th percentiles (3 groups), creating 6 portfolios.```{python}def assign_ff3_portfolios(sorting_variables):""" Assign portfolios for Fama-French three-factor model. Independent 2x3 sort on size and book-to-market. """ df = sorting_variables.copy()# Independent size sort (median split) df["portfolio_size"] = df.groupby("sorting_date")["size"].transform(lambda x: pd.qcut(x, q=[0, 0.5, 1], labels=[1, 2], duplicates='drop') )# Independent B/M sort (30/70 split) df["portfolio_bm"] = df.groupby("sorting_date")["bm"].transform(lambda x: pd.qcut(x, q=[0, 0.3, 0.7, 1], labels=[1, 2, 3], duplicates='drop') )return df# Assign portfoliosportfolios_ff3 = assign_ff3_portfolios(sorting_variables)# Validateprint("FF3 Book-to-Market by Portfolio (should be INCREASING):")print(portfolios_ff3.groupby("portfolio_bm", observed=True)["bm"].median().round(4))print("Three-Factor Portfolio Assignments:")print(portfolios_ff3[["symbol", "sorting_date", "portfolio_size", "portfolio_bm"]].head(10))```### Validating Portfolio AssignmentsWe verify that the portfolio assignments create the expected 2×3 grid with reasonable stock counts in each cell.```{python}# Check portfolio distribution for most recent yearlatest_date = portfolios_ff3["sorting_date"].max()portfolio_counts = (portfolios_ff3 .query("sorting_date == @latest_date") .groupby(["portfolio_size", "portfolio_bm"], observed=True) .size() .unstack(fill_value=0))print(f"Portfolio Counts for {latest_date:%Y-%m}:")print(portfolio_counts)# Verify characteristic monotonicityprint("\nBook-to-Market by Portfolio (should be increasing):")print(portfolios_ff3.groupby("portfolio_bm", observed=True)["bm"].median().round(4))```We verify that for a single stock, the portfolio assignment remains constant between July of one year and June of the next.```{python}# Trace a single symbol (e.g., 'A32') across a formation windowpersistence_check = (portfolios_ff3 .query("symbol == 'A32' & sorting_date >= '2022-01-01' & sorting_date <= '2023-12-31'") .sort_values("sorting_date") [['symbol', 'sorting_date', 'portfolio_size', 'portfolio_bm']])print("\nTemporal Persistence Check (Symbol A32):")print(persistence_check.head(15))```## Fama-French Three-Factor Model (Monthly)### Merging Portfolios with ReturnsWe merge the portfolio assignments with monthly returns. The key insight is that portfolios formed in July of year $t$ are held through June of year $t+1$. We implement this by computing a `sorting_date` for each monthly return observation.```{python}def merge_portfolios_with_returns(prices_monthly, portfolio_assignments):""" Merge portfolio assignments with monthly returns. Portfolios formed in July t are held through June t+1. Parameters ---------- prices_monthly : pd.DataFrame Monthly stock returns portfolio_assignments : pd.DataFrame Portfolio assignments with sorting_date Returns ------- pd.DataFrame Returns merged with portfolio assignments """ portfolios = (prices_monthly .assign(# Map each return month to its portfolio formation date sorting_date=lambda x: pd.to_datetime( np.where( x["date"].dt.month <=6, (x["date"].dt.year -1).astype(str) +"-07-01", x["date"].dt.year.astype(str) +"-07-01" ) ) ) .merge( portfolio_assignments, on=["symbol", "sorting_date"], how="inner" ) )return portfoliosportfolios_monthly_ff3 = merge_portfolios_with_returns( prices_monthly, portfolios_ff3[["symbol", "sorting_date", "portfolio_size", "portfolio_bm"]])print(f"Merged observations: {len(portfolios_monthly_ff3):,}")```### Computing Value-Weighted Portfolio ReturnsWe compute value-weighted returns for each of the six portfolios. Value-weighting uses lagged market capitalization to avoid look-ahead bias.```{python}def compute_portfolio_returns(data, grouping_vars):""" Compute value-weighted portfolio returns. Parameters ---------- data : pd.DataFrame Returns data with portfolio assignments and mktcap_lag grouping_vars : list Variables defining portfolio groups Returns ------- pd.DataFrame Value-weighted returns for each portfolio-date """ portfolio_returns = (data .groupby(grouping_vars + ["date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]),"n_stocks": len(x) })) .reset_index() )return portfolio_returns# Compute portfolio returnsportfolio_returns_ff3 = compute_portfolio_returns( portfolios_monthly_ff3, ["portfolio_size", "portfolio_bm"])print("Portfolio Returns Summary:")print(portfolio_returns_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True)["ret"].describe().round(4))```### Constructing SMB and HML FactorsWe now construct the SMB and HML factors from the portfolio returns.**SMB (Small Minus Big)**: Average return of three small portfolios minus average return of three big portfolios.**HML (High Minus Low)**: Average return of two high B/M portfolios minus average return of two low B/M portfolios.```{python}def construct_ff3_factors(portfolio_returns):""" Construct Fama-French three factors from portfolio returns. Parameters ---------- portfolio_returns : pd.DataFrame Value-weighted returns for 2x3 portfolios Returns ------- pd.DataFrame Monthly SMB and HML factors """ factors = (portfolio_returns .groupby("date") .apply(lambda x: pd.Series({# SMB: Small minus Big (average across B/M groups)"smb": ( x.loc[x["portfolio_size"] ==1, "ret"].mean() - x.loc[x["portfolio_size"] ==2, "ret"].mean() ),# HML: High minus Low B/M (average across size groups)"hml": ( x.loc[x["portfolio_bm"] ==3, "ret"].mean() - x.loc[x["portfolio_bm"] ==1, "ret"].mean() ) })) .reset_index() )return factorsfactors_smb_hml = construct_ff3_factors(portfolio_returns_ff3)print("SMB and HML Factors:")print(factors_smb_hml.head(10))```### Computing the Market FactorThe market factor is the value-weighted return of all stocks minus the risk-free rate. We compute this independently from the sorted portfolios.```{python}def compute_market_factor(prices_monthly):""" Compute value-weighted market excess return. Parameters ---------- prices_monthly : pd.DataFrame Monthly stock returns with mktcap_lag Returns ------- pd.DataFrame Monthly market excess return """ market_factor = (prices_monthly .groupby("date") .apply(lambda x: pd.Series({"mkt_excess": np.average(x["ret_excess"], weights=x["mktcap_lag"]),"n_stocks": len(x) }), include_groups=False) .reset_index() )return market_factormarket_factor = compute_market_factor(prices_monthly)print("Market Factor Summary:")print(market_factor["mkt_excess"].describe().round(4))```### Combining Three FactorsWe combine SMB, HML, and the market factor into the complete three-factor dataset.```{python}factors_ff3_monthly = (factors_smb_hml .merge(market_factor[["date", "mkt_excess"]], on="date", how="inner"))# Add risk-free rate for completenessrf_monthly = (prices_monthly .groupby("date")["risk_free"] .first() .reset_index())factors_ff3_monthly = factors_ff3_monthly.merge(rf_monthly, on="date", how="left")print("Fama-French Three Factors (Monthly):")print(factors_ff3_monthly.head(10))print("\nFactor Summary Statistics:")print(factors_ff3_monthly[["mkt_excess", "smb", "hml"]].describe().round(4))```### Saving Three-Factor Data```{python}factors_ff3_monthly.to_sql( name="factors_ff3_monthly", con=tidy_finance, if_exists="replace", index=False)print("Three-factor monthly data saved to database.")```## Fama-French Five-Factor Model (Monthly)### Portfolio Assignments with Dependent SortsFor the five-factor model, we use dependent sorts: size is sorted independently, but profitability and investment are sorted within size groups. This controls for the correlation between size and these characteristics.```{python}def assign_ff5_portfolios(sorting_variables):""" Assign portfolios for Fama-French five-factor model. """ df = sorting_variables.copy()# Independent size sort df["portfolio_size"] = df.groupby("sorting_date")["size"].transform(lambda x: pd.qcut(x, q=[0, 0.5, 1], labels=[1, 2], duplicates='drop') )# Dependent sorts within size groups df["portfolio_bm"] = df.groupby(["sorting_date", "portfolio_size"])["bm"].transform(lambda x: pd.qcut(x, q=[0, 0.3, 0.7, 1], labels=[1, 2, 3], duplicates='drop') ) df["portfolio_op"] = df.groupby(["sorting_date", "portfolio_size"])["op"].transform(lambda x: pd.qcut(x, q=[0, 0.3, 0.7, 1], labels=[1, 2, 3], duplicates='drop') ) df["portfolio_inv"] = df.groupby(["sorting_date", "portfolio_size"])["inv"].transform(lambda x: pd.qcut(x, q=[0, 0.3, 0.7, 1], labels=[1, 2, 3], duplicates='drop') )return df# Runportfolios_ff5 = assign_ff5_portfolios(sorting_variables)```### Validating Five-Factor Portfolios```{python}# Check characteristic monotonicity for each dimensionprint("Profitability by Portfolio (should be increasing):")print(portfolios_ff5.groupby("portfolio_op", observed=True)["op"].median().round(4))print("\nInvestment by Portfolio (should be increasing):")print(portfolios_ff5.groupby("portfolio_inv", observed=True)["inv"].median().round(4))# Check portfolio countsprint("\nStocks per Size/Profitability Bin:")print(portfolios_ff5.groupby(["portfolio_size", "portfolio_op"], observed=True).size().unstack(fill_value=0))# Check number of unique firms per year(portfolios_ff5 .groupby("sorting_date")["symbol"] .nunique())```### Merging and Computing Portfolio Returns```{python}# Merge with returnsportfolios_monthly_ff5 = merge_portfolios_with_returns( prices_monthly, portfolios_ff5[["symbol", "sorting_date", "portfolio_size", "portfolio_bm", "portfolio_op", "portfolio_inv"]])print(f"Five-factor merged observations: {len(portfolios_monthly_ff5):,}")```### Constructing All Five FactorsWe construct each factor from the appropriate portfolio sorts.```{python}def construct_ff5_factors(portfolios_monthly):""" Construct Fama-French five factors from portfolio data. Parameters ---------- portfolios_monthly : pd.DataFrame Monthly returns with all portfolio assignments Returns ------- pd.DataFrame Monthly five-factor returns """# HML: Value factor from B/M sorts portfolios_bm = (portfolios_monthly .groupby(["portfolio_size", "portfolio_bm", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_hml = (portfolios_bm .groupby("date") .apply(lambda x: pd.Series({"hml": (x.loc[x["portfolio_bm"] ==3, "ret"].mean() - x.loc[x["portfolio_bm"] ==1, "ret"].mean()) })) .reset_index() )# RMW: Profitability factor from OP sorts portfolios_op = (portfolios_monthly .groupby(["portfolio_size", "portfolio_op", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_rmw = (portfolios_op .groupby("date") .apply(lambda x: pd.Series({"rmw": (x.loc[x["portfolio_op"] ==3, "ret"].mean() - x.loc[x["portfolio_op"] ==1, "ret"].mean()) })) .reset_index() )# CMA: Investment factor from INV sorts# Note: CMA is Conservative minus Aggressive (low inv - high inv) portfolios_inv = (portfolios_monthly .groupby(["portfolio_size", "portfolio_inv", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_cma = (portfolios_inv .groupby("date") .apply(lambda x: pd.Series({"cma": (x.loc[x["portfolio_inv"] ==1, "ret"].mean() - x.loc[x["portfolio_inv"] ==3, "ret"].mean()) })) .reset_index() )# SMB: Size factor (average across all characteristic portfolios) all_portfolios = pd.concat([portfolios_bm, portfolios_op, portfolios_inv]) factors_smb = (all_portfolios .groupby("date") .apply(lambda x: pd.Series({"smb": (x.loc[x["portfolio_size"] ==1, "ret"].mean() - x.loc[x["portfolio_size"] ==2, "ret"].mean()) })) .reset_index() )# Combine all factors factors = (factors_smb .merge(factors_hml, on="date", how="outer") .merge(factors_rmw, on="date", how="outer") .merge(factors_cma, on="date", how="outer") )return factorsfactors_ff5 = construct_ff5_factors(portfolios_monthly_ff5)# Add market factorfactors_ff5_monthly = (factors_ff5 .merge(market_factor[["date", "mkt_excess"]], on="date", how="inner") .merge(rf_monthly, on="date", how="left"))print("Fama-French Five Factors (Monthly):")print(factors_ff5_monthly.head(10))print("\nFactor Summary Statistics:")print(factors_ff5_monthly[["mkt_excess", "smb", "hml", "rmw", "cma"]].describe().round(4))```### Factor CorrelationsWe examine correlations between factors, which should generally be low for the factors to capture distinct sources of risk.```{python}print("Factor Correlation Matrix:")correlation_matrix = factors_ff5_monthly[["mkt_excess", "smb", "hml", "rmw", "cma"]].corr()print(correlation_matrix.round(3))```### Saving Five-Factor Data```{python}factors_ff5_monthly.to_sql( name="factors_ff5_monthly", con=tidy_finance, if_exists="replace", index=False)print("Five-factor monthly data saved to database.")```## Daily Fama-French Factors### Motivation for Daily FactorsDaily factors are essential for several applications:1. **Daily beta estimation**: CAPM regressions using daily data require daily market excess returns.2. **Event studies**: Measuring abnormal returns around corporate events requires daily factor adjustments.3. **High-frequency research**: Market microstructure studies need daily or intraday factor data.The construction methodology mirrors the monthly approach, but we compute portfolio returns at daily frequency while maintaining the same annual portfolio formation dates.### Loading Daily Returns```{python}# Load daily price dataprices_daily = pd.read_sql_query( sql=""" SELECT symbol, date, ret_excess FROM prices_daily """, con=tidy_finance, parse_dates={"date"})print(f"Daily returns: {len(prices_daily):,} observations")print(f"Date range: {prices_daily['date'].min():%Y-%m-%d} to {prices_daily['date'].max():%Y-%m-%d}")```### Adding Market Cap for Daily WeightingFor value-weighted daily returns, we need market capitalization. We use the most recent monthly market cap as the weight for daily returns within that month.```{python}# Get monthly market cap to use as weights for daily returnsmktcap_monthly = (prices_monthly [["symbol", "date", "mktcap_lag"]] .assign(year_month=lambda x: x["date"].dt.to_period("M")))# Add year_month to daily data for mergingprices_daily = (prices_daily .assign(year_month=lambda x: x["date"].dt.to_period("M")) .merge( mktcap_monthly[["symbol", "year_month", "mktcap_lag"]], on=["symbol", "year_month"], how="left" ) .dropna(subset=["ret_excess", "mktcap_lag"]))print(f"Daily returns with weights: {len(prices_daily):,} observations")```### Merging Daily Returns with PortfoliosWe use the same portfolio assignments (formed annually in July) for daily returns.```{python}# Step 1: Ensure portfolios_ff3 has correct formatportfolios_ff3_clean = portfolios_ff3[["symbol", "sorting_date", "portfolio_size", "portfolio_bm"]].copy()portfolios_ff3_clean["sorting_date"] = pd.to_datetime(portfolios_ff3_clean["sorting_date"])print("Portfolio sorting dates:")print(portfolios_ff3_clean["sorting_date"].unique()[:5])# Step 2: Create sorting_date for daily dataprices_daily_with_sort = prices_daily.copy()prices_daily_with_sort["sorting_date"] = prices_daily_with_sort["date"].apply(lambda x: pd.Timestamp(f"{x.year}-07-01") if x.month >6else pd.Timestamp(f"{x.year -1}-07-01"))print("\nDaily sorting dates:")print(prices_daily_with_sort["sorting_date"].unique()[:5])# Step 3: Mergeportfolios_daily_ff3 = prices_daily_with_sort.merge( portfolios_ff3_clean, on=["symbol", "sorting_date"], how="inner")print(f"\nMerged daily observations: {len(portfolios_daily_ff3):,}")print(f"Unique dates: {portfolios_daily_ff3['date'].nunique():,}")# Step 4: Verify portfolio distributionprint("\nPortfolio distribution in daily data:")print(portfolios_daily_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True).size().unstack(fill_value=0))``````{python}# Diagnostic: Check the daily portfolio mergeprint("="*50)print("DIAGNOSTIC: Daily Portfolio Merge")print("="*50)print(f"\nDaily prices rows: {len(prices_daily):,}")print(f"Daily FF3 portfolios rows: {len(portfolios_daily_ff3):,}")print(f"Match rate: {len(portfolios_daily_ff3)/len(prices_daily)*100:.1f}%")# Check portfolio distribution in daily dataprint("\nDaily portfolio distribution:")print(portfolios_daily_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True).size().unstack(fill_value=0))# Check a specific datetest_date = portfolios_daily_ff3["date"].iloc[1000]print(f"\nSample date: {test_date}")print(portfolios_daily_ff3.query("date == @test_date").groupby(["portfolio_size", "portfolio_bm"], observed=True).size().unstack(fill_value=0))```### Computing Daily Three Factors```{python}def compute_daily_ff3_factors(portfolios_daily):""" Compute daily Fama-French three factors. Parameters ---------- portfolios_daily : pd.DataFrame Daily returns with portfolio assignments Returns ------- pd.DataFrame Daily SMB and HML factors """# Compute daily portfolio returns portfolio_returns = (portfolios_daily .groupby(["portfolio_size", "portfolio_bm", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() )# Compute factors factors = (portfolio_returns .groupby("date") .apply(lambda x: pd.Series({"smb": (x.loc[x["portfolio_size"] ==1, "ret"].mean() - x.loc[x["portfolio_size"] ==2, "ret"].mean()),"hml": (x.loc[x["portfolio_bm"] ==3, "ret"].mean() - x.loc[x["portfolio_bm"] ==1, "ret"].mean()) })) .reset_index() )return factorsfactors_daily_smb_hml = compute_daily_ff3_factors(portfolios_daily_ff3)print(f"Daily factor observations: {len(factors_daily_smb_hml):,}")print(factors_daily_smb_hml.head(10))```### Computing Daily Market Factor```{python}def compute_daily_market_factor(prices_daily):""" Compute daily value-weighted market excess return. Parameters ---------- prices_daily : pd.DataFrame Daily returns with mktcap_lag Returns ------- pd.DataFrame Daily market excess return """ market_daily = (prices_daily .groupby("date") .apply(lambda x: pd.Series({"mkt_excess": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() )return market_dailymarket_factor_daily = compute_daily_market_factor(prices_daily)print(f"Daily market factor: {len(market_factor_daily):,} days")```### Combining Daily Three Factors```{python}factors_ff3_daily = (factors_daily_smb_hml .merge(market_factor_daily, on="date", how="inner"))# Add risk-free rate (use monthly rate / 21 as daily approximation, or load actual daily rate)factors_ff3_daily["risk_free"] =0.04/252# Approximate daily risk-freeprint("Daily Fama-French Three Factors:")print(factors_ff3_daily.head(10))print("\nDaily Factor Summary Statistics:")print(factors_ff3_daily[["mkt_excess", "smb", "hml"]].describe().round(6))```### Computing Daily Five Factors```{python}# Step 1: Clean portfoliosportfolios_ff5_clean = portfolios_ff5[["symbol", "sorting_date", "portfolio_size", "portfolio_bm", "portfolio_op", "portfolio_inv"]].copy()portfolios_ff5_clean["sorting_date"] = pd.to_datetime(portfolios_ff5_clean["sorting_date"])# Step 2: Merge with daily pricesportfolios_daily_ff5 = prices_daily_with_sort.merge( portfolios_ff5_clean, on=["symbol", "sorting_date"], how="inner")print(f"FF5 Daily merged observations: {len(portfolios_daily_ff5):,}")``````{python}def compute_daily_ff5_factors(portfolios_daily):"""Compute daily Fama-French five factors."""# HML from B/M sorts portfolios_bm = (portfolios_daily .groupby(["portfolio_size", "portfolio_bm", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_hml = (portfolios_bm .groupby("date") .apply(lambda x: pd.Series({"hml": (x.loc[x["portfolio_bm"] ==3, "ret"].mean() - x.loc[x["portfolio_bm"] ==1, "ret"].mean()) })) .reset_index() )# RMW from OP sorts portfolios_op = (portfolios_daily .groupby(["portfolio_size", "portfolio_op", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_rmw = (portfolios_op .groupby("date") .apply(lambda x: pd.Series({"rmw": (x.loc[x["portfolio_op"] ==3, "ret"].mean() - x.loc[x["portfolio_op"] ==1, "ret"].mean()) })) .reset_index() )# CMA from INV sorts (note: low minus high) portfolios_inv = (portfolios_daily .groupby(["portfolio_size", "portfolio_inv", "date"], observed=True) .apply(lambda x: pd.Series({"ret": np.average(x["ret_excess"], weights=x["mktcap_lag"]) })) .reset_index() ) factors_cma = (portfolios_inv .groupby("date") .apply(lambda x: pd.Series({"cma": (x.loc[x["portfolio_inv"] ==1, "ret"].mean() - x.loc[x["portfolio_inv"] ==3, "ret"].mean()) })) .reset_index() )# SMB from all sorts all_portfolios = pd.concat([portfolios_bm, portfolios_op, portfolios_inv]) factors_smb = (all_portfolios .groupby("date") .apply(lambda x: pd.Series({"smb": (x.loc[x["portfolio_size"] ==1, "ret"].mean() - x.loc[x["portfolio_size"] ==2, "ret"].mean()) })) .reset_index() )# Combine factors = (factors_smb .merge(factors_hml, on="date", how="outer") .merge(factors_rmw, on="date", how="outer") .merge(factors_cma, on="date", how="outer") )return factors# Compute daily FF5 factorsfactors_daily_ff5 = compute_daily_ff5_factors(portfolios_daily_ff5)# Add market factorfactors_ff5_daily = (factors_daily_ff5 .merge(market_factor_daily, on="date", how="inner"))factors_ff5_daily["risk_free"] =0.04/252print("Daily Fama-French Five Factors:")print(factors_ff5_daily.head(10))print("\nDaily Five-Factor Summary Statistics:")print(factors_ff5_daily[["mkt_excess", "smb", "hml", "rmw", "cma"]].describe().round(6))```### Saving Daily Factors```{python}factors_ff3_daily.to_sql( name="factors_ff3_daily", con=tidy_finance, if_exists="replace", index=False)factors_ff5_daily.to_sql( name="factors_ff5_daily", con=tidy_finance, if_exists="replace", index=False)print("Daily factor data saved to database.")```## Factor Validation and Diagnostics```{python}# Verify all tables are in databaseprint("\n"+"="*50)print("DATABASE SUMMARY")print("="*50)tables = ["factors_ff3_monthly", "factors_ff5_monthly", "factors_ff3_daily", "factors_ff5_daily"]for table in tables: df = pd.read_sql_query(f"SELECT COUNT(*) as n FROM {table}", con=tidy_finance)print(f"{table}: {df['n'].iloc[0]:,} observations")# Correlation check: Monthly vs Daily (aggregated)print("\n"+"="*50)print("MONTHLY VS DAILY CONSISTENCY CHECK")print("="*50)factors_daily_agg = (factors_ff3_daily .assign(year_month=lambda x: x["date"].dt.to_period("M")) .groupby("year_month")[["mkt_excess", "smb", "hml"]] .sum() .reset_index())factors_monthly_check = (factors_ff3_monthly .assign(year_month=lambda x: x["date"].dt.to_period("M")))comparison = factors_monthly_check.merge( factors_daily_agg, on="year_month", suffixes=("_monthly", "_daily"))for factor in ["mkt_excess", "smb", "hml"]: corr = comparison[f"{factor}_monthly"].corr(comparison[f"{factor}_daily"])print(f"{factor}: Monthly-Daily correlation = {corr:.4f}")```### Cumulative Factor ReturnsWe visualize the cumulative performance of each factor to assess whether the factors generate meaningful premiums over time.```{python}#| label: fig-cumulative-factors#| out-width: "70%"#| fig-align: center#| fig-cap: "Cumulative returns of Fama-French factors for the Vietnamese market. The figure shows the growth of $1 invested in each factor portfolio."#| fig-alt: "Line chart showing cumulative factor returns over time."# Compute cumulative returnsfactors_cumulative = (factors_ff5_monthly .set_index("date") [["mkt_excess", "smb", "hml", "rmw", "cma"]] .add(1) .cumprod())# Plotfig, ax = plt.subplots(figsize=(12, 6))factors_cumulative.plot(ax=ax)ax.set_title("Cumulative Factor Returns (Vietnam)")ax.set_xlabel("")ax.set_ylabel("Growth of $1")ax.legend(title="Factor")ax.axhline(y=1, color='gray', linestyle='--', alpha=0.5)plt.tight_layout()plt.show()```### Average Factor PremiumsWe compute annualized average factor premiums and their statistical significance.```{python}# Annualized average returns (monthly returns * 12)factor_premiums = (factors_ff5_monthly [["mkt_excess", "smb", "hml", "rmw", "cma"]] .mean() *12*100# Annualized percentage)# Standard errorsfactor_se = (factors_ff5_monthly [["mkt_excess", "smb", "hml", "rmw", "cma"]] .std() / np.sqrt(len(factors_ff5_monthly)) * np.sqrt(12) *100)# T-statisticsfactor_tstat = factor_premiums / factor_seprint("Annualized Factor Premiums (%):")print(factor_premiums.round(2))print("\nT-Statistics:")print(factor_tstat.round(2))```### Comparing Monthly and Daily FactorsWe verify consistency between monthly and daily factors by computing correlations.```{python}# Aggregate daily factors to monthly for comparisonfactors_daily_monthly = (factors_ff5_daily .assign(year_month=lambda x: x["date"].dt.to_period("M")) .groupby("year_month") [["mkt_excess", "smb", "hml", "rmw", "cma"]] .sum() # Sum daily returns to get monthly .reset_index())# Merge with actual monthly factorscomparison = (factors_ff5_monthly .assign(year_month=lambda x: x["date"].dt.to_period("M")) .merge( factors_daily_monthly, on="year_month", suffixes=("_monthly", "_daily") ))# Correlationsfor factor in ["mkt_excess", "smb", "hml", "rmw", "cma"]: corr = comparison[f"{factor}_monthly"].corr(comparison[f"{factor}_daily"])print(f"{factor}: Monthly-Daily correlation = {corr:.4f}")```## Key Takeaways1. **Factor Models Explained**: The Fama-French three-factor model adds size (SMB) and value (HML) factors to the CAPM, while the five-factor model further includes profitability (RMW) and investment (CMA) factors.2. **Construction Methodology**: Factors are constructed through double-sorted portfolios with careful attention to timing. Portfolios are formed in July using accounting data from the prior fiscal year to ensure information was publicly available.3. **Independent vs. Dependent Sorts**: The three-factor model uses independent sorts on size and book-to-market, creating a 2×3 grid. The five-factor model uses dependent sorts where characteristics are sorted within size groups.4. **Value-Weighted Returns**: Portfolio returns are computed using value-weighting with lagged market capitalization to avoid look-ahead bias.5. **Daily Factors**: Daily factors use the same annual portfolio assignments but compute returns at daily frequency, enabling higher-frequency applications like daily beta estimation.6. **Market Factor**: The market factor is computed independently as the value-weighted return of all stocks minus the risk-free rate.7. **Validation**: Factor quality can be assessed through characteristic monotonicity, portfolio diversification, cumulative returns, and consistency between daily and monthly frequencies.8. **Vietnamese Market Adaptation**: While following the original Fama-French methodology, we adapt for Vietnamese market characteristics including VAS accounting standards, reporting timelines, and currency units.