Chương này giới thiệu một trong những khái niệm cơ bản nhất của kinh tế học tài chính: mức độ phơi nhiễm của một cổ phiếu riêng lẻ trước rủi ro thị trường mang tính hệ thống. Theo mô hình định giá tài sản vốn (CAPM) do Sharpe (1964), Lintner (1965) và Mossin (1966) phát triển, sự khác biệt theo chiều ngang trong lợi nhuận kỳ vọng của các tài sản phải được quyết định bởi hiệp phương sai giữa lợi nhuận vượt mức của tài sản và lợi nhuận vượt mức của danh mục thị trường. Hệ số hồi quy nắm bắt mối quan hệ này (thường được gọi là beta thị trường) là nền tảng của lý thuyết danh mục đầu tư hiện đại và vẫn được sử dụng rộng rãi trong thực tiễn để ước lượng chi phí vốn, phân tích đóng góp hiệu quả đầu tư và quản trị rủi ro.

Trong chương này, chúng ta xây dựng một khung phân tích hoàn chỉnh để ước lượng beta thị trường cho các cổ phiếu Việt Nam. Chúng ta bắt đầu bằng một tổng quan khái niệm về CAPM và việc triển khai mô hình này trong thực nghiệm. Sau đó, chúng ta minh họa việc ước lượng beta bằng hồi quy bình phương nhỏ nhất thông thường, trước hết cho từng cổ phiếu riêng lẻ, rồi mở rộng ra toàn thị trường bằng ước lượng cửa sổ trượt. Để đáp ứng yêu cầu tính toán khi ước lượng beta cho hàng trăm cổ phiếu qua nhiều kỳ, chúng ta giới thiệu các kỹ thuật song song hóa giúp giảm đáng kể thời gian xử lý. Cuối cùng, chúng ta so sánh các ước lượng beta thu được từ lợi nhuận tháng với lợi nhuận ngày, và xem xét beta thay đổi như thế nào giữa các ngành và theo thời gian trên thị trường Việt Nam.

Chương này vận dụng một số khái niệm tính toán quan trọng vượt ra ngoài phạm vi ước lượng beta. Ước lượng cửa sổ trượt là kỹ thuật có thể áp dụng cho mọi tham số biến thiên theo thời gian, còn song song hóa là giải pháp chung cho các tác vụ đòi hỏi nhiều tính toán nhưng có thể chia thành các tác vụ con độc lập.

15.1 Nền tảng lý thuyết

15.1.1 Mô hình định giá tài sản vốn

CAPM cung cấp một khung lý thuyết liên kết lợi nhuận kỳ vọng với rủi ro hệ thống. Theo các giả định của mô hình, bao gồm nhà đầu tư tối ưu hóa trung bình–phương sai, kỳ vọng đồng nhất và thị trường không có ma sát, lợi nhuận vượt mức kỳ vọng của bất kỳ tài sản \(i\) nào tỷ lệ thuận với hiệp phương sai của nó với danh mục thị trường:

\[ E[r_i - r_f] = \beta_i \cdot E[r_m - r_f] \]

trong đó \(r_i\) là lợi nhuận của tài sản \(i\), \(r_f\) là lãi suất phi rủi ro, \(r_m\) là lợi nhuận của danh mục thị trường, và \(\beta_i\) được định nghĩa như sau:

\[ \beta_i = \frac{\text{Cov}(r_i, r_m)}{\text{Var}(r_m)} \]

Beta thị trường \(\beta_i\) đo độ nhạy của lợi nhuận tài sản \(i\) trước các biến động của thị trường. Beta lớn hơn một cho thấy tài sản khuếch đại các biến động của thị trường, trong khi beta nhỏ hơn một cho thấy độ nhạy thấp hơn. Beta bằng không hàm ý không có phơi nhiễm rủi ro hệ thống, chỉ còn lại rủi ro đặc thù có thể được triệt tiêu thông qua đa dạng hóa.

15.1.2 Triển khai trong thực nghiệm

Trong thực tế, chúng ta ước lượng beta bằng cách hồi quy lợi nhuận vượt mức của cổ phiếu theo lợi nhuận vượt mức của thị trường:

\[ r_{i,t} - r_{f,t} = \alpha_i + \beta_i(r_{m,t} - r_{f,t}) + \varepsilon_{i,t} \tag{15.1}\]

trong đó \(\alpha_i\) biểu diễn lợi nhuận bất thường (alpha của Jensen), \(\beta_i\) là beta thị trường cần ước lượng, và \(\varepsilon_{i,t}\) là số hạng sai số đặc thù. Theo CAPM, \(\alpha_i\) phải bằng không đối với mọi tài sản; bất kỳ alpha khác không nào cũng là sự lệch khỏi dự báo của mô hình.

Một số cân nhắc thực tiễn ảnh hưởng đến việc ước lượng beta:

  1. Cửa sổ ước lượng: Cửa sổ dài hơn cho nhiều quan sát hơn và do đó ước lượng chính xác hơn, nhưng có thể chứa thông tin lỗi thời nếu beta thay đổi theo thời gian. Lựa chọn phổ biến nằm trong khoảng 36 đến 60 tháng đối với dữ liệu tháng.

  2. Tần suất lợi nhuận: Lợi nhuận tháng giảm nhiễu nhưng cho ít quan sát hơn. Lợi nhuận ngày cho nhiều điểm dữ liệu hơn nhưng có thể gây ra các hiệu ứng vi cấu trúc và thiên lệch do giao dịch không đồng bộ.

  3. Đại diện thị trường: Danh mục thị trường lý thuyết bao gồm mọi tài sản, nhưng trong thực tế chúng ta dùng một chỉ số cổ phiếu rộng. Đối với Việt Nam, chúng ta dùng lợi nhuận thị trường trọng số theo vốn hóa được xây dựng từ tập hợp cổ phiếu của chúng ta.

  4. Số quan sát tối thiểu: Yêu cầu một số quan sát tối thiểu (ví dụ 48 trên 60 tháng) giúp tránh các ước lượng thiếu tin cậy do dữ liệu thưa.

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

Chúng ta bắt đầu bằng việc nạp các gói Python cần thiết. Các gói cốt lõi đảm nhiệm xử lý dữ liệu, mô hình thống kê và các thao tác với cơ sở dữ liệu. Chúng ta cũng nhập các công cụ song song hóa, vốn sẽ rất cần thiết khi mở rộng việc ước lượng ra toàn thị trường.

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

from plotnine import *
from mizani.formatters import percent_format, comma_format
from joblib import Parallel, delayed, cpu_count
from dateutil.relativedelta import relativedelta

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ý từ các chương trước.

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

15.3 Nạp và chuẩn bị dữ liệu

15.3.1 Dữ liệu 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 đã được chuẩn bị trong chương Datacore. Dữ liệu bao gồm lợi nhuận vượt mức (lợi nhuận trừ đi lãi suất phi rủi ro) của tất cả cổ phiếu niêm yết tại Việt Nam.

Hiện mã
prices_monthly = pd.read_sql_query(
    sql="""
        SELECT symbol, date, ret_excess 
        FROM prices_monthly
    """,
    con=tidy_finance,
    parse_dates={"date"}
)

# Add year for merging with fundamentals
prices_monthly["year"] = prices_monthly["date"].dt.year

print(f"Loaded {len(prices_monthly):,} monthly observations")
print(f"Covering {prices_monthly['symbol'].nunique():,} unique stocks")
print(f"Date range: {prices_monthly['date'].min():%Y-%m} to {prices_monthly['date'].max():%Y-%m}")
Loaded 209,495 monthly observations
Covering 1,837 unique stocks
Date range: 2010-01 to 2025-05
Hiện mã
prices_daily = pd.read_sql_query(
    sql="""
        SELECT symbol, date, ret_excess 
        FROM prices_daily
    """,
    con=tidy_finance,
    parse_dates={"date"}
)

15.3.2 Thông tin doanh nghiệp

Chúng ta nạp thông tin doanh nghiệp để phục vụ phân tích các ước lượng beta ở cấp độ ngành.

Hiện mã
comp_vn = pd.read_sql_query(
    sql="""
        SELECT symbol, datadate, icb_name_vi 
        FROM comp_vn
    """,
    con=tidy_finance,
    parse_dates={"datadate"}
)

# Extract year for merging
comp_vn["year"] = comp_vn["datadate"].dt.year

print(f"Company data: {comp_vn['symbol'].nunique():,} firms")
Company data: 1,502 firms

15.3.3 Lợi nhuận vượt mức của thị trường

Làm đại diện cho danh mục thị trường, chúng ta dùng lợi nhuận vượt mức của thị trường theo trọng số vốn hóa. Nếu bạn đã xây dựng các nhân tố Fama-French ở một chương trước, hãy nạp chúng tại đây. Nếu không, chúng ta có thể xây dựng một lợi nhuận thị trường đơn giản từ dữ liệu cổ phiếu của mình.

Hiện mã
# Option 1: Load pre-computed market factor
factors_ff3_monthly = pd.read_sql_query(
    sql="SELECT date, mkt_excess FROM factors_ff3_monthly",
    con=tidy_finance,
    parse_dates={"date"}
)

# Option 2: Construct market return from stock data (if factors not available)
# This computes the value-weighted average return across all stocks
def compute_market_return(prices_df):
    """
    Compute value-weighted market return from individual stock returns.
    
    Parameters
    ----------
    prices_df : pd.DataFrame
        Stock returns with mktcap_lag for weighting
        
    Returns
    -------
    pd.DataFrame
        Monthly market excess returns
    """
    market_return = (prices_df
        .groupby("date")
        .apply(lambda x: np.average(x["ret_excess"], weights=x["mktcap_lag"]))
        .reset_index(name="mkt_excess")
    )
    return market_return

15.3.4 Hợp nhất các bộ dữ liệu

Chúng ta kết hợp lợi nhuận cổ phiếu với lợi nhuận thị trường và thông tin doanh nghiệp để tạo ra bộ dữ liệu phục vụ ước lượng.

Hiện mã
# Merge stock returns with market returns
prices_monthly = prices_monthly.merge(
    factors_ff3_monthly, 
    on="date", 
    how="left"
)

# Merge with company information for industry classification
prices_monthly = prices_monthly.merge(
    comp_vn[["symbol", "year", "icb_name_vi"]], 
    on=["symbol", "year"], 
    how="left"
)

# Remove observations with missing data
prices_monthly = prices_monthly.dropna(subset=["ret_excess", "mkt_excess"])

print(f"Final estimation sample: {len(prices_monthly):,} observations")
Final estimation sample: 169,983 observations

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

Các mức lợi nhuận cực đoan có thể ảnh hưởng quá mức đến kết quả ước lượng hồi quy. Chúng ta áp dụng winsorization (cắt đuôi giá trị cực đoan) để hạn chế tác động của các giá trị ngoại lai trong khi vẫn giữ nguyên phân phối chung của lợi nhuận. Winsorization ở mức 1% thay các giá trị thấp hơn phân vị thứ 1 bằng giá trị phân vị thứ 1, và các giá trị cao hơn phân vị thứ 99 bằng giá trị phân vị thứ 99.

Hiện mã
def winsorize_returns(df, columns, limits=(0.01, 0.01)):
    """
    Apply winsorization to return columns to limit outlier influence.
    
    Parameters
    ----------
    df : pd.DataFrame
        DataFrame containing return columns
    columns : list
        Column names to winsorize
    limits : tuple
        Lower and upper percentile limits for winsorization
        
    Returns
    -------
    pd.DataFrame
        DataFrame with winsorized columns
    """
    df = df.copy()
    for col in columns:
        df[col] = winsorize(df[col], limits=limits)
    return df

prices_monthly = winsorize_returns(
    prices_monthly, 
    columns=["ret_excess", "mkt_excess"],
    limits=(0.01, 0.01)
)

print("Return distributions after winsorization:")
print(prices_monthly[["ret_excess", "mkt_excess"]].describe().round(4))
Return distributions after winsorization:
        ret_excess   mkt_excess
count  169983.0000  169983.0000
mean        0.0011      -0.0102
std         0.1548       0.0579
min        -0.4078      -0.1794
25%        -0.0700      -0.0384
50%        -0.0033      -0.0084
75%         0.0531       0.0219
max         0.6117       0.1221

15.4 Ước lượng beta cho từng cổ phiếu

15.4.1 Ví dụ với một cổ phiếu

Trước khi mở rộng ra toàn thị trường, chúng ta minh họa việc ước lượng beta cho một cổ phiếu nổi tiếng của Việt Nam. Chúng ta dùng Vingroup (VIC), một trong những tập đoàn lớn nhất Việt Nam với mức độ hiện diện đáng kể trong các lĩnh vực bất động sản, bán lẻ và ô tô.

Hiện mã
# Filter data for Vingroup
vic_data = prices_monthly.query("symbol == 'VIC'").copy()

print(f"VIC observations: {len(vic_data)}")
print(f"Date range: {vic_data['date'].min():%Y-%m} to {vic_data['date'].max():%Y-%m}")
VIC observations: 150
Date range: 2011-07 to 2023-12

Chúng ta ước lượng hồi quy CAPM bằng phương pháp bình phương nhỏ nhất thông thường thông qua gói statsmodels. Giao diện công thức (formula) cung cấp một cách thuận tiện để chỉ định các mô hình hồi quy.

Hiện mã
# Estimate CAPM for Vingroup
model_vic = smf.ols(
    formula="ret_excess ~ mkt_excess",
    data=vic_data
).fit()

# Display regression results
print(model_vic.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             ret_excess   R-squared:                       0.153
Model:                            OLS   Adj. R-squared:                  0.147
Method:                 Least Squares   F-statistic:                     26.67
Date:                Sat, 14 Feb 2026   Prob (F-statistic):           7.66e-07
Time:                        07:51:19   Log-Likelihood:                 131.96
No. Observations:                 150   AIC:                            -259.9
Df Residuals:                     148   BIC:                            -253.9
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     -0.0075      0.008     -0.895      0.372      -0.024       0.009
mkt_excess     0.7503      0.145      5.164      0.000       0.463       1.037
==============================================================================
Omnibus:                       39.111   Durbin-Watson:                   2.039
Prob(Omnibus):                  0.000   Jarque-Bera (JB):              107.620
Skew:                          -1.015   Prob(JB):                     4.27e-24
Kurtosis:                       6.619   Cond. No.                         17.6
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Kết quả hồi quy cung cấp một số thông tin quan trọng:

  • Beta (hệ số của mkt_excess): Độ nhạy thị trường ước lượng được. Beta lớn hơn 1 cho thấy VIC khuếch đại các biến động của thị trường.
  • Alpha (Intercept): Lợi nhuận bất thường không được giải thích bởi mức độ phơi nhiễm thị trường. Theo CAPM, giá trị này phải bằng không.
  • R-squared: Tỷ lệ biến thiên của lợi nhuận được giải thích bởi các biến động của thị trường.
  • Thống kê t: Kiểm định xem các hệ số có khác không một cách có ý nghĩa hay không.
Hiện mã
# Extract key estimates
coefficients = model_vic.summary2().tables[1]

print("\nKey estimates for Vingroup (VIC):")
print(f"  Beta:  {coefficients.loc['mkt_excess', 'Coef.']:.3f}")
print(f"  Alpha: {coefficients.loc['Intercept', 'Coef.']:.4f}")
print(f"  R²:    {model_vic.rsquared:.3f}")

Key estimates for Vingroup (VIC):
  Beta:  0.750
  Alpha: -0.0075
  R²:    0.153

15.4.2 Hàm ước lượng CAPM

Chúng ta tạo một hàm có thể tái sử dụng, ước lượng CAPM và trả về kết quả theo định dạng chuẩn hóa. Hàm này bao gồm yêu cầu về số quan sát tối thiểu để tránh các ước lượng thiếu tin cậy do dữ liệu thưa.

Hiện mã
def estimate_capm(data, min_obs=48):
    """
    Estimate CAPM regression and return coefficients.
    
    This function regresses excess stock returns on excess market returns
    and extracts the coefficient estimates along with t-statistics.
    
    Parameters
    ----------
    data : pd.DataFrame
        DataFrame with 'ret_excess' and 'mkt_excess' columns
    min_obs : int
        Minimum number of observations required for estimation
        
    Returns
    -------
    pd.DataFrame
        DataFrame with coefficient estimates and t-statistics,
        or empty DataFrame if insufficient observations
    """
    if len(data) < min_obs:
        return pd.DataFrame()
    
    try:
        # Estimate OLS regression
        model = smf.ols(
            formula="ret_excess ~ mkt_excess", 
            data=data
        ).fit()
        
        # Extract coefficient table
        coef_table = model.summary2().tables[1]
        
        # Format results
        results = pd.DataFrame({
            "coefficient": ["alpha", "beta"],
            "estimate": [
                coef_table.loc["Intercept", "Coef."],
                coef_table.loc["mkt_excess", "Coef."]
            ],
            "t_statistic": [
                coef_table.loc["Intercept", "t"],
                coef_table.loc["mkt_excess", "t"]
            ],
            "r_squared": model.rsquared
        })
        
        return results
        
    except Exception as e:
        # Return empty DataFrame if estimation fails
        return pd.DataFrame()

15.5 Ước lượng cửa sổ trượt

15.5.1 Động cơ của cửa sổ trượt

Beta của cổ phiếu không cố định theo thời gian. Cơ cấu kinh doanh, đòn bẩy và môi trường hoạt động của doanh nghiệp thay đổi, khiến mức độ phơi nhiễm rủi ro hệ thống của nó cũng thay đổi. Để nắm bắt sự biến thiên theo thời gian này, chúng ta dùng ước lượng cửa sổ trượt: tại mỗi thời điểm, chúng ta ước lượng beta chỉ bằng dữ liệu từ một giai đoạn nhìn lại cố định (ví dụ 60 tháng gần nhất).

Ước lượng cửa sổ trượt đòi hỏi một sự đánh đổi:

  • Cửa sổ dài hơn cho nhiều quan sát hơn và do đó ước lượng chính xác hơn, nhưng có thể chứa thông tin cũ.
  • Cửa sổ ngắn hơn phản ứng nhanh hơn với các thay đổi nhưng cho ước lượng nhiễu hơn.

Một lựa chọn phổ biến trong nghiên cứu học thuật là 60 tháng (5 năm) dữ liệu tháng, đòi hỏi ít nhất 48 quan sát hợp lệ để ước lượng.

15.5.2 Triển khai cửa sổ trượt

Hàm sau triển khai ước lượng CAPM theo cửa sổ trượt. Với mỗi tháng trong mẫu, hàm nhìn lại trong cửa sổ đã chỉ định và ước lượng beta bằng toàn bộ dữ liệu sẵn có trong cửa sổ đó.

Hiện mã
def roll_capm_estimation(data, look_back=60, min_obs=48):
    """
    Perform rolling-window CAPM estimation.
    
    This function slides a window across time, estimating the CAPM
    regression at each point using the most recent 'look_back' months
    of data.
    
    Parameters
    ----------
    data : pd.DataFrame
        DataFrame with 'date', 'ret_excess', and 'mkt_excess' columns
    look_back : int
        Number of months in the estimation window
    min_obs : int
        Minimum observations required within each window
        
    Returns
    -------
    pd.DataFrame
        Time series of coefficient estimates with dates
    """
    # Ensure data is sorted by date
    data = data.sort_values("date").copy()
    
    # Get unique dates
    dates = data["date"].drop_duplicates().sort_values()
    
    # Container for results
    results = []
    
    # Slide window across dates
    for i in range(look_back - 1, len(dates)):
        # Define window boundaries
        end_date = dates.iloc[i]
        start_date = end_date - relativedelta(months=look_back - 1)
        
        # Extract data within window
        window_data = data.query("date >= @start_date and date <= @end_date")
        
        # Estimate CAPM for this window
        window_results = estimate_capm(window_data, min_obs=min_obs)
        
        if not window_results.empty:
            window_results["date"] = end_date
            results.append(window_results)
    
    # Combine all results
    if results:
        return pd.concat(results, ignore_index=True)
    else:
        return pd.DataFrame()

15.5.3 Ví dụ: beta trượt của một số cổ phiếu được chọn

Chúng ta minh họa ước lượng cửa sổ trượt cho một số cổ phiếu nổi tiếng của Việt Nam thuộc các ngành khác nhau.

Hiện mã
# Define example stocks
examples = pd.DataFrame({
    "symbol": ["FPT", "VNM", "VIC", "HPG", "VCB"],
    "company": [
        "FPT Corporation",      # Technology
        "Vinamilk",             # Consumer goods
        "Vingroup",             # Real estate/conglomerate
        "Hoa Phat Group",       # Steel/materials
        "Vietcombank"           # Banking
    ]
})

# Check data availability for each example
data_availability = (prices_monthly
    .query("symbol in @examples['symbol']")
    .groupby("symbol")
    .agg(
        n_obs=("date", "count"),
        first_date=("date", "min"),
        last_date=("date", "max")
    )
    .reset_index()
)

print("Data availability for example stocks:")
print(data_availability)
Data availability for example stocks:
  symbol  n_obs first_date  last_date
0    FPT    150 2011-07-31 2023-12-31
1    HPG    150 2011-07-31 2023-12-31
2    VCB    150 2011-07-31 2023-12-31
3    VIC    150 2011-07-31 2023-12-31
4    VNM    150 2011-07-31 2023-12-31
Hiện mã
# Estimate rolling betas for example stocks
example_data = prices_monthly.query("symbol in @examples['symbol']")

capm_examples = (example_data
    .groupby("symbol", group_keys=True)
    .apply(lambda x: roll_capm_estimation(x), include_groups=False)
    .reset_index()
    .drop(columns="level_1", errors="ignore")
)

# Filter to beta estimates only
beta_examples = (capm_examples
    .query("coefficient == 'beta'")
    .merge(examples, on="symbol")
)

print(f"Rolling beta estimates: {len(beta_examples):,} observations")
Rolling beta estimates: 455 observations

15.5.4 Trực quan hóa beta trượt

Hình 22.7 thể hiện chuỗi thời gian các ước lượng beta của các cổ phiếu ví dụ. Hình cho thấy mức độ phơi nhiễm rủi ro hệ thống biến đổi khác nhau như thế nào giữa các ngành.

Hiện mã
rolling_beta_figure = (
    ggplot(
        beta_examples,
        aes(x="date", y="estimate", color="company")
    )
    + geom_line(size=0.8)
    + geom_hline(yintercept=1, linetype="dashed", color="gray", alpha=0.7)
    + labs(
        x="",
        y="Beta",
        color="",
        title="Rolling Beta Estimates (60-Month Window)"
    )
    + scale_x_datetime(date_breaks="2 years", date_labels="%Y")
    + theme_minimal()
    + theme(legend_position="bottom")
)
rolling_beta_figure.show()
Biểu đồ đường thể hiện chuỗi thời gian các ước lượng beta của năm cổ phiếu Việt Nam thuộc các ngành khác nhau.
Hình 15.1: Ước lượng beta trượt theo tháng của một số cổ phiếu Việt Nam được chọn, dùng cửa sổ ước lượng 60 tháng. Các ngành khác nhau thể hiện những mô hình độ nhạy thị trường khác biệt theo thời gian.

Một số quy luật xuất hiện từ hình:

  1. Khác biệt giữa các ngành: Cổ phiếu công nghệ và ngân hàng có thể thể hiện mô hình beta khác với doanh nghiệp bất động sản hay hàng tiêu dùng.

  2. Biến thiên theo thời gian: Beta không cố định. Chúng phản ứng với những thay đổi của điều kiện kinh doanh, đòn bẩy và trạng thái thị trường.

  3. Giai đoạn khủng hoảng: Các giai đoạn thị trường căng thẳng (ví dụ khủng hoảng tài chính 2008, COVID-19 năm 2020) thường làm các ước lượng beta thay đổi khi tương quan giữa các cổ phiếu tăng lên.

15.6 Ước lượng song song cho toàn thị trường

15.6.1 Thách thức về tính toán

Ước lượng beta trượt cho tất cả cổ phiếu trong cơ sở dữ liệu của chúng ta đòi hỏi nhiều tính toán. Với hàng trăm cổ phiếu, mỗi cổ phiếu cần ước lượng trượt qua nhiều kỳ, việc xử lý tuần tự sẽ mất khá nhiều thời gian. May mắn là việc ước lượng beta của các cổ phiếu khác nhau độc lập với nhau (tức là ước lượng của cổ phiếu A không phụ thuộc vào ước lượng của cổ phiếu B). Tính độc lập này khiến bài toán rất phù hợp để song song hóa.

15.6.2 Thiết lập xử lý song song

Chúng ta dùng thư viện joblib để phân phối tính toán trên nhiều nhân CPU. Lớp Parallel quản lý các tiến trình làm việc, còn delayed bọc các lời gọi hàm để thực thi trì hoãn.

Hiện mã
# Determine available cores (reserve one for system operations)
n_cores = max(1, cpu_count() - 1)
print(f"Available cores for parallel processing: {n_cores}")
Available cores for parallel processing: 3

15.6.3 Ước lượng beta song song

Đoạn mã sau ước lượng beta trượt cho tất cả cổ phiếu một cách song song. Mỗi cổ phiếu được xử lý độc lập bởi một tiến trình làm việc riêng.

Hiện mã
def estimate_all_betas_parallel(data, n_cores, look_back=60, min_obs=48):
    """
    Estimate rolling betas for all stocks using parallel processing.
    
    Parameters
    ----------
    data : pd.DataFrame
        Full dataset with all stocks
    n_cores : int
        Number of CPU cores to use
    look_back : int
        Months in estimation window
    min_obs : int
        Minimum observations required
        
    Returns
    -------
    pd.DataFrame
        Beta estimates for all stocks and dates
    """
    # Group data by stock
    grouped = data.groupby("symbol", group_keys=False)
    
    # Define worker function
    def process_stock(name, group):
        result = roll_capm_estimation(group, look_back=look_back, min_obs=min_obs)
        if not result.empty:
            result["symbol"] = name
        return result
    
    # Execute in parallel
    results = Parallel(n_jobs=n_cores, verbose=1)(
        delayed(process_stock)(name, group) 
        for name, group in grouped
    )
    
    # Combine results
    results = [r for r in results if not r.empty]
    if results:
        return pd.concat(results, ignore_index=True)
    else:
        return pd.DataFrame()
Hiện mã
# Estimate betas for all stocks
print("Estimating rolling betas for all stocks...")
capm_monthly = estimate_all_betas_parallel(
    prices_monthly, 
    n_cores=n_cores,
    look_back=60,
    min_obs=48
)

print(f"\nCompleted: {len(capm_monthly):,} coefficient estimates")
print(f"Unique stocks: {capm_monthly['symbol'].nunique():,}")

15.6.4 Lưu kết quả

Chúng ta lưu các ước lượng CAPM vào cơ sở dữ liệu để dùng trong các chương sau.

Hiện mã
capm_monthly.to_sql(
    name="capm_monthly",
    con=tidy_finance,
    if_exists="replace",
    index=False
)

print("CAPM estimates saved to database.")

Cho các phân tích tiếp theo, chúng ta nạp các ước lượng đã được tính sẵn:

Hiện mã
capm_monthly = pd.read_sql_query(
    sql="SELECT * FROM capm_monthly",
    con=tidy_finance,
    parse_dates={"date"}
)

print(f"Loaded {len(capm_monthly):,} CAPM estimates")
Loaded 161,580 CAPM estimates

15.7 Ước lượng beta bằng lợi nhuận ngày

Mặc dù lợi nhuận tháng là chuẩn mực trong nghiên cứu học thuật, một số ứng dụng được lợi từ dữ liệu có tần suất cao hơn:

  • Cửa sổ ước lượng ngắn hơn: Dữ liệu ngày cho phép ước lượng có ý nghĩa trong các giai đoạn ngắn hơn (ví dụ 3 tháng thay vì 5 năm).
  • Ước lượng phản ứng nhanh hơn: Beta ngày nắm bắt các thay đổi nhanh hơn.
  • Nghiên cứu sự kiện: Beta tần suất cao hữu ích để phân tích phản ứng của thị trường trước các sự kiện cụ thể.

Tuy nhiên, dữ liệu ngày kéo theo những thách thức bổ sung:

  • Nhiễu vi cấu trúc: Hiệu ứng nảy giá mua–bán và các ma sát giao dịch khác làm tăng nhiễu cho lợi nhuận.
  • Giao dịch không đồng bộ: Các cổ phiếu kém thanh khoản có thể không giao dịch mỗi ngày, làm ước lượng beta bị thiên lệch xuống.
  • Gánh nặng tính toán: Dữ liệu ngày lớn hơn dữ liệu tháng khoảng 21 lần.

15.7.1 Xử lý theo lô cho dữ liệu ngày

Do quy mô lớn của dữ liệu ngày, chúng ta xử lý cổ phiếu theo từng lô để kiểm soát giới hạn bộ nhớ. Cách tiếp cận này nạp và xử lý một tập con các cổ phiếu, lưu kết quả, rồi chuyển sang lô tiếp theo.

Hiện mã
def compute_market_return_daily(tidy_finance):
    """
    Compute daily value-weighted market excess return from stock data.
    """
    # Load daily prices with market cap for weighting
    prices_daily_full = pd.read_sql_query(
        sql="""
            SELECT p.symbol, p.date, p.ret_excess, m.mktcap_lag
            FROM prices_daily p
            LEFT JOIN prices_monthly m ON p.symbol = m.symbol 
                AND strftime('%Y-%m', p.date) = strftime('%Y-%m', m.date)
        """,
        con=tidy_finance,
        parse_dates={"date"}
    )
    
    # Compute value-weighted market return each day
    mkt_daily = (prices_daily_full
        .dropna(subset=["ret_excess", "mktcap_lag"])
        .groupby("date")
        .apply(lambda x: np.average(x["ret_excess"], weights=x["mktcap_lag"]))
        .reset_index(name="mkt_excess")
    )
    
    return mkt_daily


def roll_capm_estimation_daily(data, look_back_days=1260, min_obs=1000):
    """
    Perform rolling-window CAPM estimation using daily data.
    
    Parameters
    ----------
    data : pd.DataFrame
        DataFrame with 'date', 'ret_excess', and 'mkt_excess' columns
    look_back_days : int
        Number of trading days in the estimation window
    min_obs : int
        Minimum daily observations required within each window
        
    Returns
    -------
    pd.DataFrame
        Time series of coefficient estimates with dates
    """
    data = data.sort_values("date").copy()
    dates = data["date"].drop_duplicates().sort_values().reset_index(drop=True)
    
    results = []
    
    for i in range(look_back_days - 1, len(dates)):
        end_date = dates.iloc[i]
        start_idx = max(0, i - look_back_days + 1)
        start_date = dates.iloc[start_idx]
        
        window_data = data.query("date >= @start_date and date <= @end_date")
        window_results = estimate_capm(window_data, min_obs=min_obs)
        
        if not window_results.empty:
            window_results["date"] = end_date
            results.append(window_results)
    
    if results:
        return pd.concat(results, ignore_index=True)
    else:
        return pd.DataFrame()


def estimate_daily_betas_batch(symbols, tidy_finance, n_cores, batch_size=500, 
                                look_back_days=1260, min_obs=1000):
    """
    Estimate rolling betas from daily data using batch processing.
    """
    # First, compute or load market return
    print("Computing daily market excess returns...")
    mkt_daily = compute_market_return_daily(tidy_finance)
    print(f"Market returns: {len(mkt_daily)} days")
    
    n_batches = int(np.ceil(len(symbols) / batch_size))
    all_results = []
    
    for j in range(n_batches):
        batch_start = j * batch_size
        batch_end = min((j + 1) * batch_size, len(symbols))
        batch_symbols = symbols[batch_start:batch_end]
        
        symbol_list = ", ".join(f"'{s}'" for s in batch_symbols)
        
        query = f"""
            SELECT symbol, date, ret_excess
            FROM prices_daily
            WHERE symbol IN ({symbol_list})
        """
        
        prices_daily_batch = pd.read_sql_query(
            sql=query,
            con=tidy_finance,
            parse_dates={"date"}
        )
        
        # Merge with market excess return
        prices_daily_batch = prices_daily_batch.merge(
            mkt_daily, 
            on="date", 
            how="inner"
        )
        
        # Group by symbol and estimate betas
        grouped = prices_daily_batch.groupby("symbol", group_keys=False)
        
        # Parallel estimation
        batch_results = Parallel(n_jobs=n_cores)(
            delayed(lambda name, group: 
                roll_capm_estimation_daily(group, look_back_days=look_back_days, min_obs=min_obs)
                .assign(symbol=name)
            )(name, group)
            for name, group in grouped
        )
        
        batch_results = [r for r in batch_results if r is not None and not r.empty]
        
        if batch_results:
            all_results.append(pd.concat(batch_results, ignore_index=True))
        
        print(f"Batch {j+1}/{n_batches} complete")
    
    if all_results:
        return pd.concat(all_results, ignore_index=True)
    else:
        return pd.DataFrame()
Hiện mã
symbols = prices_monthly["symbol"].unique().tolist()

capm_daily = estimate_daily_betas_batch(
    symbols=symbols,
    tidy_finance=tidy_finance,
    n_cores=n_cores,
    batch_size=500,
    look_back_days=1260,  # ~5 years of trading days
    min_obs=1000
)

print(f"Daily beta estimates: {len(capm_daily):,}")
Hiện mã
capm_daily.to_sql(
    name="capm_daily",
    con=tidy_finance,
    if_exists="replace",
    index=False
)

print("CAPM estimates saved to database.")

Cho các phân tích tiếp theo, chúng ta nạp các ước lượng đã được tính sẵn:

Hiện mã
capm_daily = pd.read_sql_query(
    sql="SELECT * FROM capm_daily",
    con=tidy_finance,
    parse_dates={"date"}
)

print(f"Loaded {len(capm_daily):,} CAPM estimates")
Loaded 3,394,490 CAPM estimates

15.8 Phân tích các ước lượng beta

15.8.1 Trích xuất các ước lượng beta

Chúng ta trích xuất các hệ số beta ước lượng từ kết quả CAPM để phân tích.

Hiện mã
# Extract monthly betas
beta_monthly = (capm_monthly
    .query("coefficient == 'beta'")
    .rename(columns={"estimate": "beta"})
    [["symbol", "date", "beta"]]
    .assign(frequency="monthly")
)

# Save to database
beta_monthly.to_sql(
    name="beta_monthly",
    con=tidy_finance,
    if_exists="replace",
    index=False
)

print(f"Monthly betas: {len(beta_monthly):,} observations")
print(f"Unique stocks: {beta_monthly['symbol'].nunique():,}")
Monthly betas: 80,790 observations
Unique stocks: 1,383
Hiện mã
# Load pre-computed betas
beta_monthly = pd.read_sql_query(
    sql="SELECT * FROM beta_monthly",
    con=tidy_finance,
    parse_dates={"date"}
)

15.8.2 Thống kê mô tả

Chúng ta xem xét phân phối của các ước lượng beta để kiểm tra tính hợp lý của chúng.

Hiện mã
print("Beta Summary Statistics:")
print(beta_monthly["beta"].describe().round(3))

# Additional diagnostics
print(f"\nStocks with negative average beta: {(beta_monthly.groupby('symbol')['beta'].mean() < 0).sum()}")
print(f"Stocks with beta > 2: {(beta_monthly.groupby('symbol')['beta'].mean() > 2).sum()}")
Beta Summary Statistics:
count    80790.000
mean         0.501
std          0.539
min         -1.345
25%          0.130
50%          0.447
75%          0.832
max          2.678
Name: beta, dtype: float64

Stocks with negative average beta: 177
Stocks with beta > 2: 5

15.8.3 Phân phối beta giữa các ngành

Các ngành khác nhau có mức độ phơi nhiễm khác nhau trước rủi ro thị trường hệ thống, tùy theo mô hình kinh doanh, đòn bẩy hoạt động và đòn bẩy tài chính. Hình 15.2 thể hiện phân phối beta trung bình của từng doanh nghiệp giữa các ngành ở Việt Nam.

Hiện mã
# Merge betas with industry information
beta_with_industry = (beta_monthly
    .merge(
        prices_monthly[["symbol", "date", "icb_name_vi"]].drop_duplicates(),
        on=["symbol", "date"],
        how="left"
    )
    .dropna(subset=["icb_name_vi"])
)

# Compute firm-level average beta by industry
beta_by_industry = (beta_with_industry
    .groupby(["icb_name_vi", "symbol"])["beta"]
    .mean()
    .reset_index()
)

# Order industries by median beta
industry_order = (beta_by_industry
    .groupby("icb_name_vi")["beta"]
    .median()
    .sort_values()
    .index.tolist()
)

# Select top 10 industries by number of firms for clearer visualization
top_industries = (beta_by_industry
    .groupby("icb_name_vi")
    .size()
    .nlargest(10)
    .index.tolist()
)

beta_by_industry_filtered = beta_by_industry.query("icb_name_vi in @top_industries")
Hiện mã
beta_industry_figure = (
    ggplot(
        beta_by_industry_filtered,
        aes(x="icb_name_vi", y="beta")
    )
    + geom_boxplot(fill="steelblue", alpha=0.7)
    + geom_hline(yintercept=1, linetype="dashed", color="red", alpha=0.7)
    + coord_flip()
    + labs(
        x="",
        y="Beta",
        title="Beta Distribution by Industry"
    )
    + theme_minimal()
)
beta_industry_figure.show()
Biểu đồ hộp thể hiện phân phối beta theo ngành, sắp xếp theo beta trung vị.
Hình 15.2: Phân phối beta trung bình cấp doanh nghiệp giữa các ngành ở Việt Nam. Biểu đồ hộp thể hiện trung vị, khoảng tứ phân vị và các giá trị ngoại lai của từng ngành.

15.8.4 Biến thiên theo thời gian của phân phối beta theo chiều ngang

Beta không chỉ khác nhau giữa các cổ phiếu mà còn thay đổi theo thời gian. Hình 15.3 cho thấy phân phối beta theo chiều ngang đã tiến triển như thế nào trên thị trường Việt Nam.

Hiện mã
# Compute monthly quantiles
beta_quantiles = (beta_monthly
    .groupby("date")["beta"]
    .quantile(q=np.arange(0.1, 1.0, 0.1))
    .reset_index()
    .rename(columns={"level_1": "quantile"})
    .assign(quantile=lambda x: (x["quantile"] * 100).astype(int).astype(str) + "%")
)

beta_quantiles_figure = (
    ggplot(
        beta_quantiles,
        aes(x="date", y="beta", color="quantile")
    )
    + geom_line(alpha=0.8)
    + geom_hline(yintercept=1, linetype="dashed", color="gray")
    + labs(
        x="",
        y="Beta",
        color="Quantile",
        title="Cross-Sectional Distribution of Betas Over Time"
    )
    + scale_x_datetime(date_breaks="2 years", date_labels="%Y")
    + theme_minimal()
)
beta_quantiles_figure.show()
Biểu đồ đường thể hiện chuỗi thời gian các thập phân vị beta, minh họa phân phối beta đã thay đổi như thế nào theo thời gian.
Hình 15.3: Các phân vị theo tháng của ước lượng beta theo thời gian. Mỗi đường biểu diễn một thập phân vị của phân phối beta theo chiều ngang.

Hình cho thấy một số quy luật đáng chú ý:

  1. Dịch chuyển mức: Toàn bộ phân phối beta có thể dịch chuyển theo thời gian, phản ánh sự thay đổi của tương quan trên toàn thị trường.

  2. Thay đổi độ phân tán: Trong giai đoạn thị trường căng thẳng, khoảng cách giữa các cổ phiếu beta cao và beta thấp có thể thay đổi khi các tương quan dịch chuyển.

  3. Xu hướng: Một số giai đoạn cho thấy hành vi có xu hướng của beta, có thể phản ánh những thay đổi cấu trúc trong nền kinh tế.

15.8.5 Phân tích độ phủ

Chúng ta kiểm tra rằng quy trình ước lượng cho độ phủ hợp lý trên toàn mẫu. Hình 15.4 thể hiện tỷ lệ cổ phiếu có sẵn ước lượng beta theo thời gian.

Hiện mã
# Count stocks with and without betas
coverage = (prices_monthly
    .groupby("date")["symbol"]
    .nunique()
    .reset_index(name="total_stocks")
    .merge(
        beta_monthly.groupby("date")["symbol"].nunique().reset_index(name="with_beta"),
        on="date",
        how="left"
    )
    .fillna(0)
    .assign(coverage=lambda x: x["with_beta"] / x["total_stocks"])
)

coverage_figure = (
    ggplot(coverage, aes(x="date", y="coverage"))
    + geom_line(color="steelblue", size=1)
    + labs(
        x="",
        y="Share with Beta Estimate",
        title="Beta Estimation Coverage Over Time"
    )
    + scale_y_continuous(labels=percent_format(), limits=(0, 1))
    + scale_x_datetime(date_breaks="2 years", date_labels="%Y")
    + theme_minimal()
)
coverage_figure.show()
Biểu đồ đường thể hiện phần trăm cổ phiếu có sẵn ước lượng beta mỗi tháng.
Hình 15.4: Tỷ lệ cổ phiếu có sẵn ước lượng beta theo thời gian. Độ phủ tăng lên khi ngày càng nhiều cổ phiếu tích lũy đủ lịch sử lợi nhuận.

Độ phủ thấp hơn ở những năm đầu vì cổ phiếu cần có đủ lịch sử lợi nhuận (ít nhất 48 tháng) trước khi beta của chúng có thể được ước lượng. Khi thị trường trưởng thành và các cổ phiếu tích lũy lịch sử dài hơn, độ phủ tiến gần 100%.

15.9 So sánh ước lượng beta tháng và ngày

Khi cả ước lượng beta tháng và ngày đều sẵn có, chúng ta có thể so sánh chúng để hiểu tần suất ước lượng ảnh hưởng đến kết quả như thế nào.

Hiện mã
# Combine monthly and daily estimates
beta_daily = (capm_daily
    .query("coefficient == 'beta'")
    .rename(columns={"estimate": "beta"})
    [["symbol", "date", "beta"]]
    .assign(frequency="daily")
)

beta_combined = pd.concat([beta_monthly, beta_daily], ignore_index=True)
Hiện mã
# Filter to example stocks
beta_comparison = (beta_combined
    .merge(examples, on="symbol")
    .query("symbol in ['VIC', 'FPT']")  # Select two for clarity
)

comparison_figure = (
    ggplot(
        beta_comparison,
        aes(x="date", y="beta", color="frequency", linetype="frequency")
    )
    + geom_line(size=0.8)
    + facet_wrap("~company", ncol=1)
    + labs(
        x="",
        y="Beta",
        color="Data Frequency",
        linetype="Data Frequency",
        title="Monthly vs Daily Beta Estimates"
    )
    + scale_x_datetime(date_breaks="2 years", date_labels="%Y")
    + theme_minimal()
    + theme(legend_position="bottom")
)
comparison_figure.show()
Biểu đồ đường so sánh ước lượng beta tháng và ngày theo thời gian cho các cổ phiếu ví dụ.
Hình 15.5: So sánh ước lượng beta dùng lợi nhuận tháng với lợi nhuận ngày cho một số cổ phiếu được chọn. Ước lượng ngày mượt hơn do có nhiều quan sát hơn trong mỗi cửa sổ ước lượng.

Phép so sánh cho thấy các ước lượng dựa trên dữ liệu ngày nhìn chung mượt hơn nhờ số quan sát trong mỗi cửa sổ lớn hơn. Tuy nhiên, mức và xu hướng của các ước lượng tương tự nhau giữa hai tần suất, qua đó xác nhận rằng cả hai cách tiếp cận đều nắm bắt cùng một mức phơi nhiễm rủi ro hệ thống nền tảng.

Hiện mã
# Correlation between monthly and daily estimates
correlation_data = (beta_combined
    .pivot_table(index=["symbol", "date"], columns="frequency", values="beta")
    .dropna()
)

print(f"Correlation between monthly and daily betas: {correlation_data.corr().iloc[0,1]:.3f}")
Correlation between monthly and daily betas: 0.745
Bảng 15.1: Các lý do lý thuyết dẫn đến tương quan không hoàn hảo
Yếu tố Tác động
Giao dịch không đồng bộ Beta ngày có thể bị thiên lệch xuống đối với cổ phiếu kém thanh khoản
Nhiễu vi cấu trúc Hiệu ứng nảy giá mua–bán làm tăng nhiễu cho ước lượng ngày
Cửa sổ hiệu dụng khác nhau Cùng một giai đoạn lịch nhưng dữ liệu ngày có số quan sát nhiều hơn ~20 lần
Tốc độ hồi quy về trung bình Dữ liệu ngày nắm bắt các động thái rủi ro biến đổi nhanh hơn

Bảng 15.1 cho thấy một số lý do khiến chúng ta có thể quan sát thấy tương quan không hoàn hảo.

15.10 Những điểm chính

  1. Beta CAPM đo độ nhạy của một cổ phiếu trước rủi ro thị trường hệ thống và là nền tảng của lý thuyết danh mục đầu tư hiện đại, ước lượng chi phí vốn và quản trị rủi ro.

  2. Ước lượng cửa sổ trượt nắm bắt sự biến thiên theo thời gian của beta, phản ánh những thay đổi trong mô hình kinh doanh, đòn bẩy của doanh nghiệp và điều kiện thị trường.

  3. Song song hóa giảm đáng kể thời gian tính toán cho các tác vụ ước lượng quy mô lớn bằng cách phân chia công việc trên nhiều nhân CPU.

  4. Các lựa chọn ước lượng đều quan trọng: Độ dài cửa sổ, tần suất lợi nhuận và yêu cầu về số quan sát tối thiểu đều ảnh hưởng đến các ước lượng beta. Nhà nghiên cứu nên chọn các tham số phù hợp với ứng dụng cụ thể của mình.

  5. Quy luật theo ngành: Cổ phiếu Việt Nam cho thấy sự khác biệt có hệ thống về độ nhạy thị trường giữa các ngành, trong đó các ngành chu kỳ có beta cao hơn các ngành phòng thủ.

  6. Biến thiên theo thời gian: Phân phối beta theo chiều ngang ở Việt Nam đã tiến triển theo thời gian, với những dịch chuyển đáng chú ý trong các giai đoạn thị trường căng thẳng.

  7. So sánh tần suất: Các ước lượng beta tháng và ngày có tương quan dương nhưng không giống hệt nhau. Ước lượng ngày mượt hơn trong khi ước lượng tháng có thể nắm bắt tốt hơn các biến thiên tần suất thấp.

  8. Kiểm tra chất lượng dữ liệu: Phân tích độ phủ và thống kê mô tả giúp phát hiện các vấn đề tiềm ẩn trong quy trình ước lượng trước khi dùng kết quả cho các phân tích tiếp theo.