Chương này trình bày việc tái lập các danh mục nhân tố Fama-French cho thị trường chứng khoán Việt Nam. Các mô hình nhân tố Fama-French là nền tảng của định giá tài sản thực nghiệm, bắt nguồn từ công trình kinh điển của Fama và French (1992) và sau đó được mở rộng trong Fama và French (2015). Các mô hình này đã thay đổi cách giới học thuật và nhà thực hành hiểu về sự khác biệt theo chiều ngang của lợi nhuận kỳ vọng cổ phiếu, vượt ra ngoài mô hình định giá tài sản vốn một nhân tố để đưa vào nhiều nguồn rủi ro hệ thống.

Chúng ta xây dựng cả mô hình ba nhân tố và năm nhân tố ở tần suất tháng và ngày. Các nhân tố tháng là nền tảng cho hầu hết các kiểm định định giá tài sản và phân tích danh mục, trong khi các nhân tố ngày cho phép các ứng dụng tần suất cao hơn, bao gồm nghiên cứu sự kiện ngắn hạn, nghiên cứu vi cấu trúc thị trường và ước lượng beta ngày. Bằng cách xây dựng nhân tố ở cả hai tần suất, chúng ta tạo ra một bộ công cụ hoàn chỉnh cho nghiên cứu tài chính thực nghiệm trên thị trường Việt Nam.

Chương được triển khai như sau. Trước hết, chúng ta bàn về động cơ lý thuyết của từng nhân tố và trực giác kinh tế đằng sau phương pháp Fama-French. Sau đó, chúng ta chuẩn bị dữ liệu cần thiết, hợp nhất lợi nhuận cổ phiếu với các đặc điểm kế toán. Tiếp theo, chúng ta triển khai các quy trình phân loại danh mục làm cơ sở cho việc xây dựng nhân tố, bám sát các quy trình Fama-French gốc đồng thời điều chỉnh cho phù hợp với đặc điểm thị trường Việt Nam. Chúng ta xây dựng mô hình ba nhân tố (thị trường, quy mô và giá trị) trước khi mở rộng sang mô hình năm nhân tố (bổ sung khả năng sinh lời và đầu tư). Cuối cùng, chúng ta xây dựng các nhân tố ngày và kiểm chứng các nhân tố đã tái lập thông qua nhiều kiểm tra chẩn đoán.

17.1 Cơ sở lý thuyết

17.1.1 Quá trình phát triển từ CAPM đến các mô hình đa nhân tố

Mô hình định giá tài sản vốn của Sharpe (1964) cho rằng một nhân tố duy nhất, danh mục thị trường, phải giải thích toàn bộ sự khác biệt theo chiều ngang của lợi nhuận kỳ vọng. Tuy nhiên, nhiều thập kỷ nghiên cứu thực nghiệm đã ghi nhận những quy luật dai dẳng mà CAPM không giải thích được. Fama và French (1992) đã chứng minh rằng hai đặc điểm của doanh nghiệp, quy mô và tỷ số giá trị sổ sách trên giá trị thị trường, nắm bắt một phần đáng kể biến thiên của lợi nhuận trung bình mà beta thị trường không giải thích được.

Các doanh nghiệp nhỏ có xu hướng kiếm được lợi nhuận cao hơn các doanh nghiệp lớn, một quy luật được gọi là hiệu ứng quy mô. Tương tự, các doanh nghiệp có tỷ số giá trị sổ sách trên giá trị thị trường cao (cổ phiếu giá trị) có xu hướng vượt trội các doanh nghiệp có tỷ số này thấp (cổ phiếu tăng trưởng), được gọi là phần bù giá trị. Mô hình ba nhân tố chính thức hóa các quan sát này bằng cách xây dựng các danh mục nhân tố có thể giao dịch được:

\[ 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} \tag{17.1}\]

trong đó:

  • \(r_{i,t} - r_{f,t}\) là lợi nhuận vượt mức của tài sản \(i\)
  • \(r_{m,t} - r_{f,t}\) là lợi nhuận vượt mức của thị trường
  • \(SMB_t\) (Small Minus Big) là nhân tố quy mô
  • \(HML_t\) (High Minus Low) là nhân tố giá trị

17.1.2 Mở rộng sang năm nhân tố

Fama và French (2015) mở rộng mô hình để bao gồm hai nhân tố bổ sung, được gợi ý từ mô hình chiết khấu cổ tức. Các doanh nghiệp có khả năng sinh lời cao hơn phải có lợi nhuận kỳ vọng cao hơn (các yếu tố khác không đổi), và các doanh nghiệp có chính sách đầu tư mạnh tay phải có lợi nhuận kỳ vọng thấp hơn:

\[ 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} \tag{17.2}\]

trong đó:

  • \(RMW_t\) (Robust Minus Weak) là nhân tố khả năng sinh lời
  • \(CMA_t\) (Conservative Minus Aggressive) là nhân tố đầu tư

17.1.3 Phương pháp xây dựng nhân tố

Phương pháp Fama-French xây dựng các nhân tố thông qua các danh mục phân loại kép:

  1. Phân loại theo quy mô: Cổ phiếu được chia thành nhóm Nhỏ và Lớn dựa trên vốn hóa thị trường trung vị.

  2. Phân loại theo đặc điểm: Trong mỗi nhóm quy mô, cổ phiếu được phân loại thành ba nhóm theo tam phân vị dựa trên tỷ số giá trị sổ sách trên giá trị thị trường (cho HML), khả năng sinh lời hoạt động (cho RMW) hoặc đầu tư (cho CMA).

  3. Lợi nhuận nhân tố: Các nhân tố được tính bằng chênh lệch giữa lợi nhuận trung bình của các danh mục có giá trị đặc điểm cao so với thấp, lấy trung bình qua các nhóm quy mô để loại bỏ hiệu ứng quy mô.

  4. Thời điểm: Các danh mục được hình thành vào cuối tháng Sáu mỗi năm, dùng dữ liệu kế toán của năm tài chính trước đó, bảo đảm toàn bộ thông tin đã được công bố công khai tại thời điểm hình thành.

17.2 Thiết lập môi trường

Chúng ta nạp các gói Python cần thiết để xử lý dữ liệu, phân tích thống kê và trực quan hóa.

Hiện mã
import pandas as pd
import numpy as np
import sqlite3
import statsmodels.formula.api as smf
from scipy.stats.mstats import winsorize
import matplotlib.pyplot as plt

from plotnine import *
from mizani.formatters import percent_format, comma_format

Chúng ta kết nối với cơ sở dữ liệu SQLite chứa dữ liệu tài chính Việt Nam đã xử lý.

Hiện mã
tidy_finance = sqlite3.connect(database="data/tidy_finance_python.sqlite")

17.3 Chuẩn bị dữ liệu

17.3.1 Nạp lợi nhuận cổ phiếu

Chúng ta nạp dữ liệu lợi nhuận cổ phiếu theo tháng, bao gồm lợi nhuận vượt mức, vốn hóa thị trường và lãi suất phi rủi ro. Các biến này là thiết yếu để tính lợi nhuận danh mục theo trọng số vốn hóa và phần bù nhân tố.

Hiện mã
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}")
Monthly returns: 165,499 observations
Unique stocks: 1,457
Date range: 2010-02 to 2023-12

17.3.2 Nạp dữ liệu cơ bản của doanh nghiệp

Chúng ta nạp dữ liệu cơ bản của doanh nghiệp gồm vốn chủ sở hữu sổ sách, khả năng sinh lời hoạt động và đầu tư, tức là các đặc điểm cần thiết để xây dựng các nhân tố Fama-French.

Hiện mã
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():,}")
Fundamentals: 18,108 firm-year observations
Unique firms: 1,496

17.3.3 Xây dựng các biến phân loại

Theo quy ước Fama-French, chúng ta xây dựng các biến phân loại với sự chú ý cẩn thận đến thời điểm. Các nguyên tắc chính là:

  1. Quy mô (vốn hóa tháng Sáu): Chúng ta dùng vốn hóa thị trường vào cuối tháng Sáu của năm \(t\) để phân loại cổ phiếu vào các nhóm quy mô. Điều này bảo đảm chúng ta nắm bắt quy mô của doanh nghiệp vào đúng thời điểm hình thành danh mục.

  2. Tỷ số giá trị sổ sách trên giá trị thị trường: Chúng ta dùng vốn chủ sở hữu sổ sách của năm tài chính \(t-1\) chia cho vốn hóa thị trường vào cuối tháng Mười Hai \(t-1\). Điều này tạo ra độ trễ sáu tháng giữa dữ liệu kế toán và thời điểm hình thành danh mục, bảo đảm thông tin đã được công bố công khai.

  3. Ngày hình thành danh mục: Các danh mục được hình thành vào ngày 1 tháng Bảy và được nắm giữ mười hai tháng cho đến tháng Sáu năm sau.

Hiện mã
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_variables

sorting_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 Kiểm chứng các biến phân loại

Trước khi tiếp tục, chúng ta kiểm chứng rằng các biến phân loại có phân phối hợp lý. Tỷ số giá trị sổ sách trên giá trị thị trường nên tập trung quanh 1,0 đối với một thị trường điển hình, mặc dù các thị trường mới nổi có thể khác.

Hiện mã
print("Sorting Variable Summary Statistics:")
print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))

# Check for extreme values that might indicate data issues
print(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}")
Sorting Variable Summary Statistics:
              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

B/M Median: 1.1849
B/M 1st percentile: 0.1710
B/M 99th percentile: 8.0262

17.3.5 Xử lý các giá trị ngoại lai

Các giá trị cực đoan trong đặc điểm dùng để phân loại có thể làm méo việc gán danh mục và lợi nhuận nhân tố. Chúng ta áp dụng winsorization (cắt đuôi giá trị cực đoan) để hạn chế ảnh hưởng của các giá trị ngoại lai trong khi vẫn giữ nguyên thứ hạng chung của các cổ phiếu.

Hiện mã
# Check BEFORE winsorization
print("BEFORE Winsorization:")
print(sorting_variables[["size", "bm", "op", "inv"]].describe().round(4))

# Apply winsorization
def 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 df

sorting_variables = winsorize_characteristics(
    sorting_variables,
    columns=["bm", "op", "inv"],  # Don't winsorize size
    limits=(0.01, 0.99)
)

# Check AFTER winsorization
print("\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 Các hàm gán danh mục

17.4.1 Hàm gán danh mục

Chúng ta tạo một hàm linh hoạt để gán cổ phiếu vào các danh mục dựa trên các điểm cắt phân vị. Hàm này xử lý cả phân loại độc lập (các điểm cắt được tính trên tất cả cổ phiếu) và phân loại phụ thuộc (các điểm cắt được tính trong từng nhóm con).

Hiện mã
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()
    
    if len(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 1
    if len(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
Hiện mã
# Check the distribution of characteristics BEFORE portfolio assignment
print("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 date
test_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 Gán danh mục cho mô hình ba nhân tố

Đối với mô hình ba nhân tố, chúng ta thực hiện phân loại kép độc lập theo quy mô và tỷ số giá trị sổ sách trên giá trị thị trường. Quy mô được chia tại trung vị (2 nhóm), còn tỷ số giá trị sổ sách trên giá trị thị trường được chia tại phân vị thứ 30 và 70 (3 nhóm), tạo ra 6 danh mục.

Hiện mã
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 portfolios
portfolios_ff3 = assign_ff3_portfolios(sorting_variables)

# Validate
print("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))
FF3 Book-to-Market by Portfolio (should be INCREASING):
portfolio_bm
1    0.5836
2    1.1891
3    2.4552
Name: bm, dtype: float64
Three-Factor Portfolio Assignments:
  symbol sorting_date portfolio_size portfolio_bm
0    A32   2019-07-01              1            2
1    A32   2020-07-01              1            2
2    A32   2021-07-01              1            2
3    A32   2022-07-01              1            3
4    A32   2023-07-01              1            2
5    AAA   2011-07-01              2            2
6    AAA   2012-07-01              2            3
7    AAA   2013-07-01              2            3
8    AAA   2014-07-01              2            2
9    AAA   2015-07-01              2            3

17.4.3 Kiểm chứng việc gán danh mục

Chúng ta kiểm tra rằng việc gán danh mục tạo ra lưới 2×3 như kỳ vọng với số lượng cổ phiếu hợp lý trong mỗi ô.

Hiện mã
# Check portfolio distribution for most recent year
latest_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 monotonicity
print("\nBook-to-Market by Portfolio (should be increasing):")
print(portfolios_ff3.groupby("portfolio_bm", observed=True)["bm"].median().round(4))
Portfolio Counts for 2023-07:
portfolio_bm      1    2    3
portfolio_size               
1               113  271  263
2               275  246  125

Book-to-Market by Portfolio (should be increasing):
portfolio_bm
1    0.5836
2    1.1891
3    2.4552
Name: bm, dtype: float64

Chúng ta kiểm tra rằng, đối với một cổ phiếu, việc gán danh mục không đổi từ tháng Bảy của năm này đến tháng Sáu của năm sau.

Hiện mã
# Trace a single symbol (e.g., 'A32') across a formation window
persistence_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))

Temporal Persistence Check (Symbol A32):
  symbol sorting_date portfolio_size portfolio_bm
3    A32   2022-07-01              1            3
4    A32   2023-07-01              1            2

17.5 Mô hình ba nhân tố Fama-French (tháng)

17.5.1 Hợp nhất danh mục với lợi nhuận

Chúng ta hợp nhất việc gán danh mục với lợi nhuận tháng. Điểm mấu chốt là các danh mục hình thành vào tháng Bảy của năm \(t\) được nắm giữ đến hết tháng Sáu của năm \(t+1\). Chúng ta triển khai điều này bằng cách tính một biến sorting_date cho mỗi quan sát lợi nhuận tháng.

Hiện mã
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 portfolios

portfolios_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 Tính lợi nhuận danh mục theo trọng số vốn hóa

Chúng ta tính lợi nhuận theo trọng số vốn hóa cho từng danh mục trong sáu danh mục. Trọng số vốn hóa dùng vốn hóa thị trường trễ một kỳ để tránh thiên lệch nhìn trước.

Hiện mã
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 returns
portfolio_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))
Portfolio Returns Summary:
                             count    mean     std     min     25%     50%  \
portfolio_size portfolio_bm                                                  
1              1             150.0 -0.0052  0.0379 -0.1268 -0.0262 -0.0058   
               2             150.0 -0.0025  0.0414 -0.1080 -0.0236 -0.0053   
               3             150.0  0.0039  0.0601 -0.1612 -0.0269 -0.0004   
2              1             150.0 -0.0124  0.0594 -0.2222 -0.0449 -0.0107   
               2             150.0 -0.0021  0.0671 -0.1701 -0.0403 -0.0046   
               3             150.0  0.0024  0.0879 -0.2359 -0.0527 -0.0012   

                                75%     max  
portfolio_size portfolio_bm                  
1              1             0.0161  0.0849  
               2             0.0180  0.1195  
               3             0.0314  0.2015  
2              1             0.0210  0.1741  
               2             0.0317  0.1770  
               3             0.0437  0.2124  

17.5.3 Xây dựng các nhân tố SMB và HML

Bây giờ chúng ta xây dựng các nhân tố SMB và HML từ lợi nhuận các danh mục.

SMB (Small Minus Big): Lợi nhuận trung bình của ba danh mục nhỏ trừ lợi nhuận trung bình của ba danh mục lớn.

HML (High Minus Low): Lợi nhuận trung bình của hai danh mục B/M cao trừ lợi nhuận trung bình của hai danh mục B/M thấp.

Hiện mã
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 factors

factors_smb_hml = construct_ff3_factors(portfolio_returns_ff3)

print("SMB and HML Factors:")
print(factors_smb_hml.head(10))
SMB and HML Factors:
        date       smb       hml
0 2011-07-31 -0.007768  0.002754
1 2011-08-31 -0.067309  0.011474
2 2011-09-30  0.014884  0.022854
3 2011-10-31 -0.003743  0.001631
4 2011-11-30  0.063234  0.009103
5 2011-12-31  0.014571  0.015280
6 2012-01-31 -0.026080  0.009672
7 2012-02-29 -0.035721  0.005474
8 2012-03-31 -0.002344  0.032477
9 2012-04-30 -0.033391  0.074191

17.5.4 Tính nhân tố thị trường

Nhân tố thị trường là lợi nhuận theo trọng số vốn hóa của tất cả cổ phiếu trừ lãi suất phi rủi ro. Chúng ta tính nhân tố này độc lập với các danh mục đã phân loại.

Hiện mã
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_factor

market_factor = compute_market_factor(prices_monthly)

print("Market Factor Summary:")
print(market_factor["mkt_excess"].describe().round(4))
Market Factor Summary:
count    167.0000
mean      -0.0123
std        0.0595
min       -0.2149
25%       -0.0394
50%       -0.0106
75%        0.0200
max        0.1677
Name: mkt_excess, dtype: float64

17.5.5 Kết hợp ba nhân tố

Chúng ta kết hợp SMB, HML và nhân tố thị trường thành bộ dữ liệu ba nhân tố hoàn chỉnh.

Hiện mã
factors_ff3_monthly = (factors_smb_hml
    .merge(market_factor[["date", "mkt_excess"]], on="date", how="inner")
)

# Add risk-free rate for completeness
rf_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))
Fama-French Three Factors (Monthly):
        date       smb       hml  mkt_excess  risk_free
0 2011-07-31 -0.007768  0.002754   -0.078748   0.003333
1 2011-08-31 -0.067309  0.011474    0.029906   0.003333
2 2011-09-30  0.014884  0.022854   -0.002173   0.003333
3 2011-10-31 -0.003743  0.001631   -0.014005   0.003333
4 2011-11-30  0.063234  0.009103   -0.179410   0.003333
5 2011-12-31  0.014571  0.015280   -0.094802   0.003333
6 2012-01-31 -0.026080  0.009672    0.081273   0.003333
7 2012-02-29 -0.035721  0.005474    0.069655   0.003333
8 2012-03-31 -0.002344  0.032477    0.029005   0.003333
9 2012-04-30 -0.033391  0.074191    0.048791   0.003333

Factor Summary Statistics:
       mkt_excess       smb       hml
count    150.0000  150.0000  150.0000
mean      -0.0101    0.0027    0.0120
std        0.0586    0.0420    0.0535
min       -0.2149   -0.1599   -0.1284
25%       -0.0380   -0.0175   -0.0160
50%       -0.0095    0.0070    0.0043
75%        0.0214    0.0261    0.0340
max        0.1677    0.1175    0.1618

17.5.6 Lưu dữ liệu ba nhân tố

Hiện mã
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 Mô hình năm nhân tố Fama-French (tháng)

17.6.1 Gán danh mục với phân loại phụ thuộc

Đối với mô hình năm nhân tố, chúng ta dùng phân loại phụ thuộc: quy mô được phân loại độc lập, nhưng khả năng sinh lời và đầu tư được phân loại trong từng nhóm quy mô. Cách này kiểm soát tương quan giữa quy mô và các đặc điểm đó.

Hiện mã
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

# Run
portfolios_ff5 = assign_ff5_portfolios(sorting_variables)

17.6.2 Kiểm chứng các danh mục năm nhân tố

Hiện mã
# Check characteristic monotonicity for each dimension
print("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 counts
print("\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())
Profitability by Portfolio (should be increasing):
portfolio_op
1   -0.0053
2    0.1366
3    0.4098
Name: op, dtype: float64

Investment by Portfolio (should be increasing):
portfolio_inv
1   -0.1012
2    0.0329
3    0.2568
Name: inv, dtype: float64

Stocks per Size/Profitability Bin:
portfolio_op       1     2     3
portfolio_size                  
1               1812  2403  1811
2               1811  2401  1808
sorting_date
2011-07-01     556
2012-07-01     632
2013-07-01     643
2014-07-01     650
2015-07-01     669
2016-07-01     737
2017-07-01     842
2018-07-01    1073
2019-07-01    1183
2020-07-01    1224
2021-07-01    1258
2022-07-01    1286
2023-07-01    1293
Name: symbol, dtype: int64

17.6.3 Hợp nhất và tính lợi nhuận danh mục

Hiện mã
# Merge with returns
portfolios_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):,}")
Five-factor merged observations: 136,444

17.6.4 Xây dựng cả năm nhân tố

Chúng ta xây dựng từng nhân tố từ các phép phân loại danh mục tương ứng.

Hiện mã
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 factors

factors_ff5 = construct_ff5_factors(portfolios_monthly_ff5)

# Add market factor
factors_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))
Fama-French Five Factors (Monthly):
        date       smb       hml       rmw       cma  mkt_excess  risk_free
0 2011-07-31 -0.015907 -0.002812  0.060525  0.045291   -0.078748   0.003333
1 2011-08-31 -0.061842  0.006189 -0.022700 -0.023177    0.029906   0.003333
2 2011-09-30  0.014387  0.024301 -0.006005  0.003588   -0.002173   0.003333
3 2011-10-31 -0.006958 -0.006940  0.026694  0.003649   -0.014005   0.003333
4 2011-11-30  0.074369  0.015617 -0.058766  0.044214   -0.179410   0.003333
5 2011-12-31  0.006687  0.022494  0.062655  0.052444   -0.094802   0.003333
6 2012-01-31 -0.016254  0.010513 -0.042191 -0.067170    0.081273   0.003333
7 2012-02-29 -0.026606  0.024465 -0.030849 -0.036383    0.069655   0.003333
8 2012-03-31  0.005096  0.050930 -0.018441  0.043488    0.029005   0.003333
9 2012-04-30  0.000712  0.058214 -0.061434  0.009233    0.048791   0.003333

Factor Summary Statistics:
       mkt_excess       smb       hml       rmw       cma
count    150.0000  150.0000  150.0000  150.0000  150.0000
mean      -0.0101    0.0077    0.0115   -0.0047    0.0083
std        0.0586    0.0419    0.0518    0.0477    0.0335
min       -0.2149   -0.1522   -0.1283   -0.2126   -0.0814
25%       -0.0380   -0.0137   -0.0126   -0.0308   -0.0131
50%       -0.0095    0.0104    0.0046    0.0010    0.0067
75%        0.0214    0.0316    0.0323    0.0178    0.0289
max        0.1677    0.1284    0.1510    0.1297    0.1331

17.6.5 Tương quan giữa các nhân tố

Chúng ta xem xét tương quan giữa các nhân tố; nhìn chung chúng nên thấp để các nhân tố nắm bắt những nguồn rủi ro riêng biệt.

Hiện mã
print("Factor Correlation Matrix:")
correlation_matrix = factors_ff5_monthly[["mkt_excess", "smb", "hml", "rmw", "cma"]].corr()
print(correlation_matrix.round(3))
Factor Correlation Matrix:
            mkt_excess    smb    hml    rmw    cma
mkt_excess       1.000 -0.712  0.230 -0.006 -0.104
smb             -0.712  1.000  0.256 -0.373  0.246
hml              0.230  0.256  1.000 -0.694  0.479
rmw             -0.006 -0.373 -0.694  1.000 -0.352
cma             -0.104  0.246  0.479 -0.352  1.000

17.6.6 Lưu dữ liệu năm nhân tố

Hiện mã
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 Các nhân tố Fama-French ngày

17.7.1 Động cơ của các nhân tố ngày

Các nhân tố ngày là thiết yếu cho một số ứng dụng:

  1. Ước lượng beta ngày: Các hồi quy CAPM dùng dữ liệu ngày đòi hỏi lợi nhuận vượt mức thị trường theo ngày.
  2. Nghiên cứu sự kiện: Đo lợi nhuận bất thường quanh các sự kiện doanh nghiệp đòi hỏi điều chỉnh nhân tố theo ngày.
  3. Nghiên cứu tần suất cao: Các nghiên cứu vi cấu trúc thị trường cần dữ liệu nhân tố theo ngày hoặc trong ngày.

Phương pháp xây dựng tương tự cách tiếp cận theo tháng, nhưng chúng ta tính lợi nhuận danh mục ở tần suất ngày trong khi vẫn giữ nguyên các ngày hình thành danh mục hằng năm.

17.7.2 Nạp lợi nhuận ngày

Hiện mã
# Load daily price data
prices_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}")
Daily returns: 3,462,157 observations
Date range: 2010-01-05 to 2023-12-29

17.7.3 Bổ sung vốn hóa để tính trọng số theo ngày

Để tính lợi nhuận ngày theo trọng số vốn hóa, chúng ta cần vốn hóa thị trường. Chúng ta dùng vốn hóa thị trường tháng gần nhất làm trọng số cho lợi nhuận ngày trong tháng đó.

Hiện mã
# Get monthly market cap to use as weights for daily returns
mktcap_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 merging
prices_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 Hợp nhất lợi nhuận ngày với các danh mục

Chúng ta dùng cùng việc gán danh mục (hình thành hằng năm vào tháng Bảy) cho lợi nhuận ngày.

Hiện mã
# Step 1: Ensure portfolios_ff3 has correct format
portfolios_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 data
prices_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 > 6 else pd.Timestamp(f"{x.year - 1}-07-01")
)

print("\nDaily sorting dates:")
print(prices_daily_with_sort["sorting_date"].unique()[:5])

# Step 3: Merge
portfolios_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 distribution
print("\nPortfolio distribution in daily data:")
print(portfolios_daily_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True).size().unstack(fill_value=0))
Portfolio sorting dates:
<DatetimeArray>
['2019-07-01 00:00:00', '2020-07-01 00:00:00', '2021-07-01 00:00:00',
 '2022-07-01 00:00:00', '2023-07-01 00:00:00']
Length: 5, dtype: datetime64[us]

Daily sorting dates:
<DatetimeArray>
['2018-07-01 00:00:00', '2019-07-01 00:00:00', '2020-07-01 00:00:00',
 '2021-07-01 00:00:00', '2022-07-01 00:00:00']
Length: 5, dtype: datetime64[us]

Merged daily observations: 2,843,570
Unique dates: 3,126

Portfolio distribution in daily data:
portfolio_bm         1       2       3
portfolio_size                        
1               218040  585561  617327
2               636152  552114  234376
Hiện mã
# Diagnostic: Check the daily portfolio merge
print("="*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 data
print("\nDaily portfolio distribution:")
print(portfolios_daily_ff3.groupby(["portfolio_size", "portfolio_bm"], observed=True).size().unstack(fill_value=0))

# Check a specific date
test_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))
==================================================
DIAGNOSTIC: Daily Portfolio Merge
==================================================

Daily prices rows: 3,443,815
Daily FF3 portfolios rows: 2,843,570
Match rate: 82.6%

Daily portfolio distribution:
portfolio_bm         1       2       3
portfolio_size                        
1               218040  585561  617327
2               636152  552114  234376

Sample date: 2023-06-28 00:00:00
portfolio_bm      1    2    3
portfolio_size               
1                93  232  312
2               291  280   67

17.7.5 Tính ba nhân tố ngày

Hiện mã
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 factors

factors_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))
Daily factor observations: 3,126
        date       smb       hml
0 2011-07-01  0.008587  0.000967
1 2011-07-04  0.005099 -0.001099
2 2011-07-05 -0.009088  0.010152
3 2011-07-06  0.004875 -0.003918
4 2011-07-07 -0.011239 -0.000584
5 2011-07-08  0.005636 -0.008003
6 2011-07-11  0.003940  0.006172
7 2011-07-12  0.003205  0.006543
8 2011-07-13 -0.000097 -0.001134
9 2011-07-14 -0.001248  0.001669

17.7.6 Tính nhân tố thị trường ngày

Hiện mã
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_daily

market_factor_daily = compute_daily_market_factor(prices_daily)

print(f"Daily market factor: {len(market_factor_daily):,} days")
Daily market factor: 3,474 days

17.7.7 Kết hợp ba nhân tố ngày

Hiện mã
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-free

print("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))
Daily Fama-French Three Factors:
        date       smb       hml  mkt_excess  risk_free
0 2011-07-01  0.008587  0.000967   -0.019862   0.000159
1 2011-07-04  0.005099 -0.001099   -0.000633   0.000159
2 2011-07-05 -0.009088  0.010152    0.013314   0.000159
3 2011-07-06  0.004875 -0.003918   -0.008045   0.000159
4 2011-07-07 -0.011239 -0.000584    0.003391   0.000159
5 2011-07-08  0.005636 -0.008003    0.000218   0.000159
6 2011-07-11  0.003940  0.006172   -0.013393   0.000159
7 2011-07-12  0.003205  0.006543   -0.017505   0.000159
8 2011-07-13 -0.000097 -0.001134    0.000767   0.000159
9 2011-07-14 -0.001248  0.001669   -0.000695   0.000159

Daily Factor Summary Statistics:
        mkt_excess          smb          hml
count  3126.000000  3126.000000  3126.000000
mean     -0.000479     0.000236     0.000594
std       0.011269     0.008488     0.008585
min      -0.070268    -0.032671    -0.039418
25%      -0.005074    -0.004882    -0.003941
50%       0.000350    -0.000106     0.000522
75%       0.005531     0.004307     0.005233
max       0.043386     0.042686     0.083889

17.7.8 Tính năm nhân tố ngày

Hiện mã
# Step 1: Clean portfolios
portfolios_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 prices
portfolios_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):,}")
FF5 Daily merged observations: 2,843,570
Hiện mã
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 factors
factors_daily_ff5 = compute_daily_ff5_factors(portfolios_daily_ff5)

# Add market factor
factors_ff5_daily = (factors_daily_ff5
    .merge(market_factor_daily, on="date", how="inner")
)
factors_ff5_daily["risk_free"] = 0.04 / 252

print("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))
Daily Fama-French Five Factors:
        date       smb       hml       rmw       cma  mkt_excess  risk_free
0 2011-07-01  0.006295  0.002515  0.013140  0.007680   -0.019862   0.000159
1 2011-07-04  0.002880 -0.002875  0.006560 -0.004886   -0.000633   0.000159
2 2011-07-05 -0.004260  0.009864 -0.012158 -0.004470    0.013314   0.000159
3 2011-07-06  0.001544 -0.009847  0.012977  0.006286   -0.008045   0.000159
4 2011-07-07 -0.009789 -0.003988 -0.000197 -0.006995    0.003391   0.000159
5 2011-07-08  0.001537 -0.006700  0.010841 -0.007661    0.000218   0.000159
6 2011-07-11  0.005396  0.004747  0.000655  0.013375   -0.013393   0.000159
7 2011-07-12  0.004759  0.007367  0.001989  0.014669   -0.017505   0.000159
8 2011-07-13 -0.000009  0.001110 -0.002052 -0.001633    0.000767   0.000159
9 2011-07-14 -0.001668  0.002916 -0.005427  0.005388   -0.000695   0.000159

Daily Five-Factor Summary Statistics:
        mkt_excess          smb          hml          rmw          cma
count  3126.000000  3126.000000  3126.000000  3126.000000  3126.000000
mean     -0.000479     0.000484     0.000549    -0.000136     0.000413
std       0.011269     0.008033     0.008312     0.008538     0.006756
min      -0.070268    -0.036283    -0.039155    -0.154013    -0.047698
25%      -0.005074    -0.004122    -0.003681    -0.004212    -0.003364
50%       0.000350     0.000105     0.000384     0.000030     0.000162
75%       0.005531     0.004358     0.004819     0.004036     0.003800
max       0.043386     0.060307     0.086269     0.102001     0.089907

17.7.9 Lưu các nhân tố ngày

Hiện mã
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.")
Daily factor data saved to database.

17.8 Kiểm chứng và chẩn đoán nhân tố

Hiện mã
# Verify all tables are in database
print("\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}")

==================================================
DATABASE SUMMARY
==================================================
factors_ff3_monthly: 150 observations
factors_ff5_monthly: 150 observations
factors_ff3_daily: 3,126 observations
factors_ff5_daily: 3,126 observations

==================================================
MONTHLY VS DAILY CONSISTENCY CHECK
==================================================
mkt_excess: Monthly-Daily correlation = 0.9980
smb: Monthly-Daily correlation = 0.9953
hml: Monthly-Daily correlation = 0.9936

17.8.1 Lợi nhuận nhân tố tích lũy

Chúng ta trực quan hóa hiệu quả tích lũy của từng nhân tố để đánh giá xem các nhân tố có tạo ra phần bù có ý nghĩa theo thời gian hay không.

Hiện mã
# Compute cumulative returns
factors_cumulative = (factors_ff5_monthly
    .set_index("date")
    [["mkt_excess", "smb", "hml", "rmw", "cma"]]
    .add(1)
    .cumprod()
)

# Plot
fig, 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()
Biểu đồ đường thể hiện lợi nhuận nhân tố tích lũy theo thời gian.
Hình 17.1: Lợi nhuận tích lũy của các nhân tố Fama-French cho thị trường Việt Nam. Hình thể hiện mức tăng trưởng của $1 đầu tư vào từng danh mục nhân tố.

17.8.2 Phần bù nhân tố trung bình

Chúng ta tính phần bù nhân tố trung bình quy năm và ý nghĩa thống kê của chúng.

Hiện mã
# Annualized average returns (monthly returns * 12)
factor_premiums = (factors_ff5_monthly
    [["mkt_excess", "smb", "hml", "rmw", "cma"]]
    .mean() * 12 * 100  # Annualized percentage
)

# Standard errors
factor_se = (factors_ff5_monthly
    [["mkt_excess", "smb", "hml", "rmw", "cma"]]
    .std() / np.sqrt(len(factors_ff5_monthly)) * np.sqrt(12) * 100
)

# T-statistics
factor_tstat = factor_premiums / factor_se

print("Annualized Factor Premiums (%):")
print(factor_premiums.round(2))

print("\nT-Statistics:")
print(factor_tstat.round(2))
Annualized Factor Premiums (%):
mkt_excess   -12.09
smb            9.19
hml           13.75
rmw           -5.69
cma            9.94
dtype: float64

T-Statistics:
mkt_excess    -7.30
smb            7.76
hml            9.38
rmw           -4.22
cma           10.49
dtype: float64

17.8.3 So sánh các nhân tố tháng và ngày

Chúng ta kiểm tra tính nhất quán giữa các nhân tố tháng và ngày bằng cách tính các tương quan.

Hiện mã
# Aggregate daily factors to monthly for comparison
factors_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 factors
comparison = (factors_ff5_monthly
    .assign(year_month=lambda x: x["date"].dt.to_period("M"))
    .merge(
        factors_daily_monthly,
        on="year_month",
        suffixes=("_monthly", "_daily")
    )
)

# Correlations
for 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}")
mkt_excess: Monthly-Daily correlation = 0.9980
smb: Monthly-Daily correlation = 0.9950
hml: Monthly-Daily correlation = 0.9948
rmw: Monthly-Daily correlation = 0.9929
cma: Monthly-Daily correlation = 0.9884

17.9 Những điểm chính

  1. Giải thích các mô hình nhân tố: Mô hình ba nhân tố Fama-French bổ sung các nhân tố quy mô (SMB) và giá trị (HML) vào CAPM, trong khi mô hình năm nhân tố bao gồm thêm các nhân tố khả năng sinh lời (RMW) và đầu tư (CMA).

  2. Phương pháp xây dựng: Các nhân tố được xây dựng thông qua các danh mục phân loại kép với sự chú ý cẩn thận đến thời điểm. Các danh mục được hình thành vào tháng Bảy, dùng dữ liệu kế toán của năm tài chính trước đó để bảo đảm thông tin đã được công bố công khai.

  3. Phân loại độc lập và phụ thuộc: Mô hình ba nhân tố dùng phân loại độc lập theo quy mô và tỷ số giá trị sổ sách trên giá trị thị trường, tạo ra lưới 2×3. Mô hình năm nhân tố dùng phân loại phụ thuộc, trong đó các đặc điểm được phân loại trong từng nhóm quy mô.

  4. Lợi nhuận theo trọng số vốn hóa: Lợi nhuận danh mục được tính theo trọng số vốn hóa với vốn hóa thị trường trễ một kỳ để tránh thiên lệch nhìn trước.

  5. Nhân tố ngày: Các nhân tố ngày dùng cùng việc gán danh mục hằng năm nhưng tính lợi nhuận ở tần suất ngày, cho phép các ứng dụng tần suất cao hơn như ước lượng beta ngày.

  6. Nhân tố thị trường: Nhân tố thị trường được tính độc lập bằng lợi nhuận theo trọng số vốn hóa của tất cả cổ phiếu trừ lãi suất phi rủi ro.

  7. Kiểm chứng: Chất lượng nhân tố có thể được đánh giá thông qua tính đơn điệu của đặc điểm, mức đa dạng hóa của danh mục, lợi nhuận tích lũy và tính nhất quán giữa tần suất ngày và tháng.

  8. Điều chỉnh cho thị trường Việt Nam: Trong khi bám theo phương pháp Fama-French gốc, chúng ta điều chỉnh cho phù hợp với đặc điểm thị trường Việt Nam, bao gồm chuẩn mực kế toán VAS, thời hạn báo cáo và đơn vị tiền tệ.