19  Nguyên tắc xây dựng nhân tố

Ghi chú

Trong chương này, chúng ta xây dựng một “động cơ” tạo nhân tố đa dụng cho thị trường cổ phiếu Việt Nam. Chúng ta xem xét mọi quyết định phương pháp luận trong quy trình, bao gồm xác định tập hợp cổ phiếu, tính các điểm cắt, hình thành danh mục, đặt trọng số, tái cân bằng và tính lợi nhuận nhân tố, đồng thời chỉ ra mỗi lựa chọn ảnh hưởng thế nào đến nhân tố thu được.

Các chương trước đã giới thiệu những mô hình định giá tài sản cụ thể, gồm CAPM, mô hình ba nhân tố Fama-French và động lượng. Mỗi chương đó coi nhân tố của mình là đã cho sẵn. Chương này đi vào “hậu trường” và trả lời câu hỏi kỹ thuật: chính xác thì xây dựng một nhân tố như thế nào? Câu hỏi này quan trọng vì những quyết định phương pháp luận tưởng như nhỏ, chẳng hạn đặt các điểm cắt ở đâu, dùng trọng số theo vốn hóa hay trọng số bằng nhau, xử lý dữ liệu kế toán bị thiếu ra sao, loại bỏ những cổ phiếu nào, có thể làm thay đổi độ lớn, ý nghĩa thống kê và thậm chí cả dấu của phần bù nhân tố.

Trong bối cảnh Hoa Kỳ, Fama và French (1993) đã thiết lập một quy trình chuẩn: phân loại độc lập các cổ phiếu theo quy mô và một đặc điểm, hình thành sáu danh mục trọng số theo vốn hóa từ các giao điểm 2×3, và định nghĩa nhân tố là lợi nhuận trung bình của hai danh mục có đặc điểm cao trừ đi lợi nhuận trung bình của hai danh mục có đặc điểm thấp. Quy trình này đã được lặp lại hàng nghìn lần. Nhưng nó được thiết kế cho thị trường Hoa Kỳ khoảng năm 1990, với thanh khoản sâu, mặt cắt ngang rộng và hạ tầng dữ liệu CRSP/Compustat. Áp dụng máy móc quy trình này cho Việt Nam, một thị trường có khoảng 700 cổ phiếu niêm yết, phi thanh khoản cực đoan ở nhóm tam phân vị dưới cùng, mức tập trung cao ở nhóm thập phân vị trên cùng và dữ liệu kế toán được công bố với độ trễ không cố định, đòi hỏi phải có sự điều chỉnh cẩn thận.

Hou, Xue, và Zhang (2020) đã lặp lại 452 hiện tượng bất thường từ tài liệu nghiên cứu của Hoa Kỳ và thấy rằng hơn một nửa không thể lặp lại, ngay cả với dữ liệu Hoa Kỳ khi thay đổi nhỏ về phương pháp. Cuộc khủng hoảng lặp lại kết quả trong định giá tài sản thực nghiệm khiến các nhà nghiên cứu nhất thiết phải hiểu và ghi chép mọi lựa chọn xây dựng nhân tố. Chương này cung cấp các công cụ để làm điều đó một cách minh bạch.

19.1 Quy trình xây dựng nhân tố

Mọi nhân tố có thể giao dịch đều theo cùng một quy trình logic:

  1. Xác định tập hợp cổ phiếu: Chọn các chứng khoán đủ điều kiện (ví dụ: cổ phiếu phổ thông có bộ lọc về thanh khoản và quy mô).
  2. Tính tín hiệu: Tính đặc điểm quan tâm (ví dụ: giá trị, động lượng, khả năng sinh lời).
  3. Đặt các điểm cắt: Xác định cách phân loại cổ phiếu (ví dụ: trung vị, ngũ phân vị, thập phân vị).
  4. Gán danh mục: Nhóm các cổ phiếu vào danh mục cao và thấp (hoặc nhiều danh mục) dựa trên tín hiệu.
  5. Tính lợi nhuận: Tính lợi nhuận danh mục (trọng số bằng nhau hoặc theo vốn hóa).
  6. Xây dựng nhân tố: Lấy Mua (cao) - Bán khống (thấp).
  7. Kiểm định: Kiểm tra hiệu quả, ý nghĩa thống kê và độ vững.

Mỗi bước đều có những lựa chọn tương tác với nhau. Một điểm cắt hoạt động tốt với tập hợp cổ phiếu có thanh khoản cao có thể không phù hợp với toàn bộ mặt cắt ngang. Một cách đặt trọng số làm giảm nhiễu ở Hoa Kỳ có thể làm nhiễu tăng lên ở Việt Nam. Chúng ta xem xét từng bước một cách có hệ thống.

19.2 Xây dựng dữ liệu

Import libraries and configure environment
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
from scipy import stats
from itertools import product
import warnings
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'figure.figsize': (12, 6),
    'figure.dpi': 150,
    'font.size': 11,
    'axes.spines.top': False,
    'axes.spines.right': False
})
Load returns, characteristics, and accounting data
from datacore import DataCoreClient

client = DataCoreClient()

# Monthly returns (survivorship-bias-free)
monthly = client.get_monthly_returns(
    exchanges=['HOSE', 'HNX'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    fields=[
        'ticker', 'month_end', 'monthly_return', 'market_cap',
        'shares_outstanding', 'volume_avg_20d', 'turnover_value_avg_20d',
        'n_zero_volume_days', 'exchange'
    ]
)

# Annual accounting data
accounting = client.get_fundamentals(
    exchanges=['HOSE', 'HNX'],
    start_date='2006-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    frequency='annual',
    fields=[
        'ticker', 'fiscal_year', 'filing_date',
        'total_assets', 'total_equity', 'book_equity',
        'net_income', 'revenue', 'gross_profit',
        'operating_profit', 'total_debt', 'retained_earnings',
        'dividends_paid', 'capex', 'depreciation',
        'shares_outstanding_fy'
    ]
)

# Daily prices for momentum and volatility signals
daily = client.get_daily_prices(
    exchanges=['HOSE', 'HNX'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    fields=['ticker', 'date', 'adjusted_close', 'volume', 'turnover_value']
)

monthly['month_end'] = pd.to_datetime(monthly['month_end'])
monthly = monthly.sort_values(['ticker', 'month_end'])

print(f"Monthly returns: {len(monthly):,} firm-months")
print(f"Accounting: {len(accounting):,} firm-years")
print(f"Unique tickers: {monthly['ticker'].nunique()}")

19.3 Bước 1: Xác định tập hợp cổ phiếu

Lựa chọn đầu tiên và có hệ quả lớn nhất là những cổ phiếu nào được đưa vào tập hợp xây dựng nhân tố. Cách xác định tập hợp này quyết định phần bù nhân tố mô tả tổng thể nào và liệu nó có thể thực hiện được hay không.

19.3.1 Vấn đề tập hợp cổ phiếu ở Việt Nam

Việt Nam đặt ra một thách thức đặc thù: mặt cắt ngang nhỏ (600-800 cổ phiếu trên HOSE và HNX cộng lại), và phân phối quy mô cực kỳ lệch. 10 cổ phiếu có vốn hóa lớn nhất chiếm khoảng 50% tổng vốn hóa trên HOSE. Nhóm tam phân vị dưới cùng gồm các cổ phiếu vốn hóa siêu nhỏ, thường giao dịch chưa đến 5 ngày mỗi tháng. Việc đưa các cổ phiếu này vào làm tăng phần bù nhân tố biểu kiến vì giá của chúng nhiễu và không được cập nhật kịp thời, nhưng loại bỏ chúng lại làm thu hẹp mặt cắt ngang vốn đã nhỏ.

Define universe filters and document their effects
def apply_universe_filters(df, filters='standard'):
    """
    Apply universe filters to the monthly return panel.
    
    Parameters
    ----------
    filters : str
        'none': all stocks
        'minimal': exclude zero market cap and extreme returns
        'standard': + minimum listing age + positive volume
        'strict': + minimum market cap + minimum turnover
    
    Returns
    -------
    Filtered DataFrame with 'in_universe' column
    """
    d = df.copy()
    d['in_universe'] = True
    
    # Always: remove missing returns and market cap
    d.loc[d['monthly_return'].isna(), 'in_universe'] = False
    d.loc[d['market_cap'].isna() | (d['market_cap'] <= 0), 'in_universe'] = False
    
    # Minimal: winsorize extreme returns (likely data errors)
    if filters in ['minimal', 'standard', 'strict']:
        d.loc[d['monthly_return'].abs() > 1.0, 'in_universe'] = False
    
    # Standard: listing age >= 6 months
    if filters in ['standard', 'strict']:
        d['listing_age'] = (
            d.groupby('ticker').cumcount() + 1
        )
        d.loc[d['listing_age'] < 6, 'in_universe'] = False
        
        # Require at least 10 positive-volume days in the month
        d.loc[d['n_zero_volume_days'] > 12, 'in_universe'] = False
    
    # Strict: minimum market cap (20th percentile of HOSE)
    if filters == 'strict':
        mcap_threshold = (
            d[d['exchange'] == 'HOSE']
            .groupby('month_end')['market_cap']
            .transform(lambda x: x.quantile(0.20))
        )
        # Apply HOSE threshold to all stocks
        d['mcap_threshold'] = (
            d.groupby('month_end')['market_cap']
            .transform(lambda x: x.quantile(0.20))
        )
        d.loc[d['market_cap'] < d['mcap_threshold'], 'in_universe'] = False
        
        # Minimum average daily turnover (VND 200 million)
        d.loc[d['turnover_value_avg_20d'] < 2e8, 'in_universe'] = False
    
    return d

# Apply all filter levels and compare
filter_summary = {}
for level in ['none', 'minimal', 'standard', 'strict']:
    filtered = apply_universe_filters(monthly, filters=level)
    in_univ = filtered[filtered['in_universe']]
    filter_summary[level] = {
        'Firm-months': len(in_univ),
        'Avg stocks/month': in_univ.groupby('month_end')['ticker'].nunique().mean(),
        'Avg MCap coverage (%)': (
            in_univ.groupby('month_end')['market_cap'].sum()
            / filtered.groupby('month_end')['market_cap'].sum()
        ).mean() * 100
    }

filter_df = pd.DataFrame(filter_summary).T
print("Universe Filter Effects:")
print(filter_df.round(1).to_string())
Plot universe size under different filters over time
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

colors_filter = {
    'none': '#BDC3C7', 'minimal': '#3498DB',
    'standard': '#2C5F8A', 'strict': '#C0392B'
}

for level in ['none', 'minimal', 'standard', 'strict']:
    filtered = apply_universe_filters(monthly, filters=level)
    counts = (
        filtered[filtered['in_universe']]
        .groupby('month_end')['ticker']
        .nunique()
    )
    axes[0].plot(counts.index, counts.values,
                 color=colors_filter[level], linewidth=1.5, label=level)

axes[0].set_ylabel('Number of Stocks')
axes[0].set_title('Panel A: Universe Size')
axes[0].legend()

# Panel B: Market cap coverage
for level in ['minimal', 'standard', 'strict']:
    filtered = apply_universe_filters(monthly, filters=level)
    total_mcap = filtered.groupby('month_end')['market_cap'].sum()
    filtered_mcap = (
        filtered[filtered['in_universe']]
        .groupby('month_end')['market_cap']
        .sum()
    )
    coverage = (filtered_mcap / total_mcap * 100).dropna()
    axes[1].plot(coverage.index, coverage.values,
                 color=colors_filter[level], linewidth=1.5, label=level)

axes[1].set_ylabel('Market Cap Coverage (%)')
axes[1].set_title('Panel B: Market Capitalization Coverage')
axes[1].legend()
axes[1].set_ylim([60, 102])

plt.tight_layout()
plt.show()
Hình 19.1

19.4 Bước 2: Xây dựng tín hiệu

19.4.1 Dữ liệu kế toán theo thời điểm công bố

Như đã thảo luận trong chương về dữ liệu thiếu, các tín hiệu kế toán phải được căn chỉnh theo ngày thông tin được công bố công khai để tránh thiên lệch nhìn trước. Chúng ta triển khai một phép ghép đa dụng theo thời điểm công bố (point-in-time):

Point-in-time alignment of accounting data with monthly returns
def pit_merge_accounting(monthly_df, accounting_df, lag_months=4):
    """
    Merge accounting data with monthly returns respecting
    the point-in-time availability constraint.
    
    Vietnamese annual reports are due within 90 days of fiscal
    year-end. We use a conservative 4-month lag.
    
    Parameters
    ----------
    lag_months : int
        Number of months after fiscal year-end before data
        are assumed to be publicly available.
    """
    acc = accounting_df.copy()
    
    # Accounting data becomes available lag_months after FY end
    # If filing_date is available, use it; otherwise use FY-end + lag
    if 'filing_date' in acc.columns:
        acc['filing_date'] = pd.to_datetime(acc['filing_date'])
        acc['available_date'] = acc['filing_date']
        # Fallback for missing filing dates
        acc['fy_end'] = pd.to_datetime(
            acc['fiscal_year'].astype(str) + '-12-31'
        )
        acc['available_date'] = acc['available_date'].fillna(
            acc['fy_end'] + pd.DateOffset(months=lag_months)
        )
    else:
        acc['available_date'] = pd.to_datetime(
            acc['fiscal_year'].astype(str) + '-12-31'
        ) + pd.DateOffset(months=lag_months)
    
    # For each firm-month, find the most recent available accounting data
    merged = monthly_df.copy()
    
    # Efficient approach: for June rebalancing, use FY t-1 data
    # which is available by April of year t (4-month lag)
    merged['year'] = merged['month_end'].dt.year
    merged['month'] = merged['month_end'].dt.month
    
    # Map: if month >= (lag_months + 1), use current year's FY-1 data
    # Otherwise, use FY-2 data
    merged['data_fy'] = np.where(
        merged['month'] >= lag_months + 1,
        merged['year'] - 1,
        merged['year'] - 2
    )
    
    # Merge
    acc_cols = [c for c in acc.columns if c not in
                ['filing_date', 'available_date', 'fy_end']]
    merged = merged.merge(
        acc[acc_cols].rename(columns={'fiscal_year': 'data_fy'}),
        on=['ticker', 'data_fy'],
        how='left'
    )
    
    return merged

# Apply point-in-time merge
panel = pit_merge_accounting(monthly, accounting, lag_months=4)

# Construct common signals
panel['log_mcap'] = np.log(panel['market_cap'].clip(lower=1))

# Book-to-market
panel['bm'] = panel['book_equity'] / panel['market_cap']
panel.loc[panel['bm'] <= 0, 'bm'] = np.nan  # Negative BE firms

# Gross profitability (Novy-Marx 2013)
panel['gp_at'] = panel['gross_profit'] / panel['total_assets']

# Operating profitability (Fama-French 2015)
panel['op'] = panel['operating_profit'] / panel['book_equity']

# Investment (asset growth)
panel['investment'] = (
    panel.groupby('ticker')['total_assets']
    .pct_change(periods=1)
)

# Leverage
panel['leverage'] = panel['total_debt'] / panel['total_assets']

print("Signal Coverage:")
for sig in ['bm', 'gp_at', 'op', 'investment', 'leverage']:
    pct = panel[sig].notna().mean()
    print(f"  {sig:<15}: {pct:.1%}")

19.4.2 Tín hiệu động lượng và biến động

Các tín hiệu dựa trên giá cần lịch sử lợi nhuận chứ không cần dữ liệu kế toán, do đó có yêu cầu về thời điểm khác nhau.

Construct momentum, reversal, and volatility signals
# Past returns for momentum signals
panel = panel.sort_values(['ticker', 'month_end'])

# Momentum: cumulative return from month t-12 to t-2 (skip most recent month)
panel['ret_12_2'] = (
    panel.groupby('ticker')['monthly_return']
    .transform(lambda x: x.shift(2).rolling(11).apply(
        lambda r: (1 + r).prod() - 1, raw=True))
)

# Short-term reversal: month t-1 return
panel['ret_1'] = panel.groupby('ticker')['monthly_return'].shift(1)

# Idiosyncratic volatility (from daily data, rolling 60 days)
daily['date'] = pd.to_datetime(daily['date'])
daily['daily_return'] = daily.groupby('ticker')['adjusted_close'].pct_change()

ivol = (
    daily.groupby('ticker')
    .apply(lambda g: g.set_index('date')['daily_return']
           .rolling(60, min_periods=40).std() * np.sqrt(252))
    .reset_index(name='ivol')
)
ivol['month_end'] = ivol['date'].dt.to_period('M').dt.to_timestamp('M')
ivol_monthly = (
    ivol.groupby(['ticker', 'month_end'])['ivol']
    .last()
    .reset_index()
)

panel = panel.merge(ivol_monthly, on=['ticker', 'month_end'], how='left')

print("Price Signal Coverage:")
for sig in ['ret_12_2', 'ret_1', 'ivol']:
    pct = panel[sig].notna().mean()
    print(f"  {sig:<15}: {pct:.1%}")

19.5 Bước 3: Tính các điểm cắt

19.5.1 Quyết định về điểm cắt

Các điểm cắt xác định cổ phiếu nào là “cao” so với “thấp” theo một đặc điểm cho trước. Hai lựa chọn then chốt là:

  1. Tập hợp tính điểm cắt: Nên tính các điểm cắt từ tất cả cổ phiếu hay từ một tập con (ví dụ: chỉ HOSE)?
  2. Số nhóm: 2×3 (chuẩn Fama-French), 5×5 (cho các phân loại mịn hơn), hay tam phân vị/ngũ phân vị độc lập?

Fama và French (1993) dùng các điểm cắt của NYSE cho các phân loại ở Hoa Kỳ vì điều này ngăn số lượng lớn cổ phiếu nhỏ của Nasdaq/AMEX chi phối phân phối của các điểm cắt. Tương tự ở Việt Nam là dùng các điểm cắt của HOSE, vì HOSE niêm yết các doanh nghiệp lớn hơn, thanh khoản tốt hơn còn HNX niêm yết các doanh nghiệp nhỏ hơn. Nếu dùng điểm cắt tính từ tất cả cổ phiếu, hầu hết cổ phiếu HOSE sẽ rơi vào các nhóm quy mô cao và hầu hết cổ phiếu HNX rơi vào các nhóm thấp, tạo ra kết quả khác biệt một cách cơ học.

Compare breakpoint choices and their effects on portfolio composition
def compute_breakpoints(df, signal_col, n_groups, bp_universe='hose',
                          exchange_col='exchange'):
    """
    Compute cross-sectional breakpoints for portfolio sorting.
    
    Parameters
    ----------
    bp_universe : str
        'all': use all stocks in universe
        'hose': use only HOSE stocks (analogous to NYSE breakpoints)
    n_groups : int
        Number of groups (2, 3, 5, or 10)
    
    Returns
    -------
    Series of breakpoints (quantiles)
    """
    signal = df[signal_col].dropna()
    
    if bp_universe == 'hose':
        mask = df[exchange_col] == 'HOSE'
        signal = df.loc[mask, signal_col].dropna()
    
    quantiles = np.linspace(0, 1, n_groups + 1)[1:-1]
    breakpoints = signal.quantile(quantiles)
    
    return breakpoints

# Example: compare HOSE vs all-stock breakpoints for book-to-market
example_month = panel[panel['month_end'] == '2023-06-30'].copy()
example_month = example_month[example_month['bm'].notna()]

bp_hose = compute_breakpoints(example_month, 'bm', 3, bp_universe='hose')
bp_all = compute_breakpoints(example_month, 'bm', 3, bp_universe='all')

print("BM Tercile Breakpoints (June 2023):")
print(f"  HOSE-only: {bp_hose.values.round(3)}")
print(f"  All stocks: {bp_all.values.round(3)}")
print(f"\n  Difference: HOSE breakpoints are "
      f"{'higher' if bp_hose.values[0] > bp_all.values[0] else 'lower'} "
      f"than all-stock breakpoints")
Visualize breakpoint effects over time
# Compute breakpoints for every month
bp_comparison = []
for month, group in panel.dropna(subset=['bm']).groupby('month_end'):
    bp_h = compute_breakpoints(group, 'bm', 3, bp_universe='hose')
    bp_a = compute_breakpoints(group, 'bm', 3, bp_universe='all')
    
    # Count stocks in each tercile under each rule
    for bp_name, bp_vals in [('HOSE', bp_h), ('All', bp_a)]:
        low = (group['bm'] <= bp_vals.iloc[0]).sum()
        mid = ((group['bm'] > bp_vals.iloc[0]) &
               (group['bm'] <= bp_vals.iloc[1])).sum()
        high = (group['bm'] > bp_vals.iloc[1]).sum()
        bp_comparison.append({
            'month_end': month, 'bp_rule': bp_name,
            'median_bp': bp_vals.iloc[0],
            'n_low': low, 'n_mid': mid, 'n_high': high
        })

bp_df = pd.DataFrame(bp_comparison)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Panel A: Median breakpoint over time
for rule, color in [('HOSE', '#2C5F8A'), ('All', '#C0392B')]:
    subset = bp_df[bp_df['bp_rule'] == rule]
    axes[0].plot(subset['month_end'], subset['median_bp'],
                 color=color, linewidth=1.5, label=f'{rule} breakpoints')
axes[0].set_ylabel('Lower Tercile Breakpoint (BM)')
axes[0].set_title('Panel A: Breakpoint Time Series')
axes[0].legend()

# Panel B: Number of stocks in high BM group
for rule, color in [('HOSE', '#2C5F8A'), ('All', '#C0392B')]:
    subset = bp_df[bp_df['bp_rule'] == rule]
    axes[1].plot(subset['month_end'], subset['n_high'],
                 color=color, linewidth=1.5, label=f'{rule} breakpoints')
axes[1].set_ylabel('Stocks in High BM Group')
axes[1].set_title('Panel B: High BM Portfolio Size')
axes[1].legend()

plt.tight_layout()
plt.show()
Hình 19.2

19.6 Bước 4: Hình thành danh mục

19.6.1 Động cơ nhân tố tổng quát

Chúng ta triển khai một hàm xây dựng nhân tố đa dụng, nhận một cột tín hiệu bất kỳ và tạo ra chuỗi lợi nhuận nhân tố mua–bán khống (long–short). Hàm này gói gọn mọi lựa chọn phương pháp luận thành các tham số, giúp dễ dàng kiểm tra độ nhạy.

General-purpose factor construction engine
def construct_factor(
    panel_df,
    signal_col,
    size_col='market_cap',
    return_col='monthly_return',
    date_col='month_end',
    exchange_col='exchange',
    formation_month=6,
    rebalance_freq='annual',
    n_signal_groups=3,
    n_size_groups=2,
    weighting='value',
    bp_universe='hose',
    independent_sorts=True,
    long_group='high',
    min_stocks_per_portfolio=5,
    signal_lag=0,
    universe_filter='standard'
):
    """
    Construct a tradeable factor following the Fama-French methodology.
    
    Parameters
    ----------
    signal_col : str
        Column with the sorting variable.
    formation_month : int
        Month of year for portfolio formation (6 = June for FF).
    rebalance_freq : str
        'annual' (FF standard), 'semi', 'quarterly', 'monthly'.
    n_signal_groups : int
        Number of signal groups (3 for FF standard, 5 for quintiles).
    n_size_groups : int
        Number of size groups (2 for FF standard).
    weighting : str
        'value' (VW) or 'equal' (EW).
    bp_universe : str
        'hose' or 'all' for breakpoint computation.
    independent_sorts : bool
        True for independent double sorts (FF standard).
    long_group : str
        'high' or 'low'—which signal group is the long leg.
    signal_lag : int
        Additional months to lag the signal beyond the
        standard point-in-time alignment.
    
    Returns
    -------
    Dictionary with 'factor_returns', 'portfolio_returns',
    'diagnostics'.
    """
    df = panel_df.copy()
    
    # Apply universe filter
    df = apply_universe_filters(df, filters=universe_filter)
    df = df[df['in_universe']].copy()
    
    # Lag the signal if requested
    if signal_lag > 0:
        df[signal_col] = (
            df.groupby('ticker')[signal_col].shift(signal_lag)
        )
    
    # Determine formation dates
    if rebalance_freq == 'annual':
        # Form portfolios in formation_month, hold for 12 months
        df['formation_date'] = df[date_col].apply(
            lambda d: pd.Timestamp(
                year=d.year if d.month >= formation_month else d.year - 1,
                month=formation_month, day=30
            )
        )
    elif rebalance_freq == 'monthly':
        df['formation_date'] = df[date_col] - pd.DateOffset(months=1)
    elif rebalance_freq == 'quarterly':
        df['formation_date'] = df[date_col].apply(
            lambda d: pd.Timestamp(
                year=d.year,
                month=((d.month - 1) // 3) * 3 + 1,
                day=1
            ) - pd.DateOffset(days=1)
        )
    
    # Assign signal and size groups at each formation date
    all_portfolios = []
    
    formation_dates = sorted(df['formation_date'].unique())
    
    for f_date in formation_dates:
        # Stocks available at formation
        formation_data = df[df['formation_date'] == f_date].copy()
        
        # Get signal values at formation
        available = formation_data.dropna(subset=[signal_col, size_col])
        if len(available) < min_stocks_per_portfolio * n_signal_groups * n_size_groups:
            continue
        
        # Compute breakpoints
        size_bp = compute_breakpoints(
            available, size_col, n_size_groups, bp_universe
        )
        signal_bp = compute_breakpoints(
            available, signal_col, n_signal_groups, bp_universe
        )
        
        # Assign groups
        available['size_group'] = np.searchsorted(
            size_bp.values, available[size_col].values
        )
        available['signal_group'] = np.searchsorted(
            signal_bp.values, available[signal_col].values
        )
        
        all_portfolios.append(available)
    
    if not all_portfolios:
        return None
    
    portfolios = pd.concat(all_portfolios, ignore_index=True)
    
    # Compute portfolio returns
    def weighted_return(group):
        if weighting == 'value':
            if group[size_col].sum() > 0:
                return np.average(group[return_col], weights=group[size_col])
            else:
                return group[return_col].mean()
        else:
            return group[return_col].mean()
    
    port_returns = (
        portfolios
        .groupby([date_col, 'size_group', 'signal_group'])
        .apply(weighted_return)
        .reset_index(name='port_return')
    )
    
    # Construct factor: average of high-signal portfolios minus
    # average of low-signal portfolios (across size groups)
    high_label = n_signal_groups - 1 if long_group == 'high' else 0
    low_label = 0 if long_group == 'high' else n_signal_groups - 1
    
    high_ports = port_returns[port_returns['signal_group'] == high_label]
    low_ports = port_returns[port_returns['signal_group'] == low_label]
    
    high_avg = high_ports.groupby(date_col)['port_return'].mean()
    low_avg = low_ports.groupby(date_col)['port_return'].mean()
    
    factor_returns = (high_avg - low_avg).to_frame('factor_return')
    
    # Diagnostics
    port_counts = (
        portfolios
        .groupby([date_col, 'size_group', 'signal_group'])['ticker']
        .nunique()
        .reset_index(name='n_stocks')
    )
    
    diagnostics = {
        'avg_stocks_per_portfolio': port_counts['n_stocks'].mean(),
        'min_stocks_per_portfolio': port_counts['n_stocks'].min(),
        'ann_return': factor_returns['factor_return'].mean() * 12,
        'ann_vol': factor_returns['factor_return'].std() * np.sqrt(12),
        'sharpe': (factor_returns['factor_return'].mean()
                   / factor_returns['factor_return'].std() * np.sqrt(12)),
        't_stat': (factor_returns['factor_return'].mean()
                   / (factor_returns['factor_return'].std()
                      / np.sqrt(len(factor_returns)))),
        'n_months': len(factor_returns)
    }
    
    return {
        'factor_returns': factor_returns,
        'portfolio_returns': port_returns,
        'portfolios': portfolios,
        'diagnostics': diagnostics
    }

19.6.2 Xây dựng các nhân tố cốt lõi

Bây giờ chúng ta dùng động cơ này để xây dựng các nhân tố Fama-French chuẩn cho Việt Nam:

Construct SMB, HML, RMW, CMA, and WML for Vietnam
# SMB (Size): small minus big
# Signal = market cap; long_group = 'low' (small stocks)
smb_result = construct_factor(
    panel, signal_col='log_mcap', long_group='low',
    formation_month=6, rebalance_freq='annual',
    n_signal_groups=2, n_size_groups=1,  # No double sort for size itself
    weighting='value', bp_universe='hose'
)

# HML (Value): high BM minus low BM
hml_result = construct_factor(
    panel, signal_col='bm', long_group='high',
    formation_month=6, rebalance_freq='annual',
    n_signal_groups=3, n_size_groups=2,
    weighting='value', bp_universe='hose'
)

# RMW (Profitability): robust minus weak
rmw_result = construct_factor(
    panel, signal_col='op', long_group='high',
    formation_month=6, rebalance_freq='annual',
    n_signal_groups=3, n_size_groups=2,
    weighting='value', bp_universe='hose'
)

# CMA (Investment): conservative minus aggressive
cma_result = construct_factor(
    panel, signal_col='investment', long_group='low',
    formation_month=6, rebalance_freq='annual',
    n_signal_groups=3, n_size_groups=2,
    weighting='value', bp_universe='hose'
)

# WML (Momentum): winners minus losers
wml_result = construct_factor(
    panel, signal_col='ret_12_2', long_group='high',
    formation_month=6, rebalance_freq='monthly',
    n_signal_groups=3, n_size_groups=2,
    weighting='value', bp_universe='hose'
)

# Summary table
print("Vietnamese Factor Summary:")
print(f"{'Factor':<8} {'Ann. Ret':>10} {'Ann. Vol':>10} {'Sharpe':>8} "
      f"{'t-stat':>8} {'Avg N':>8}")
print("-" * 54)
for name, result in [('SMB', smb_result), ('HML', hml_result),
                       ('RMW', rmw_result), ('CMA', cma_result),
                       ('WML', wml_result)]:
    if result is None:
        continue
    d = result['diagnostics']
    print(f"{name:<8} {d['ann_return']:>10.4f} {d['ann_vol']:>10.4f} "
          f"{d['sharpe']:>8.2f} {d['t_stat']:>8.2f} "
          f"{d['avg_stocks_per_portfolio']:>8.1f}")
Plot cumulative factor returns
fig, ax = plt.subplots(figsize=(14, 6))

factor_colors = {
    'SMB': '#2C5F8A', 'HML': '#C0392B', 'RMW': '#27AE60',
    'CMA': '#E67E22', 'WML': '#8E44AD'
}

for name, result in [('SMB', smb_result), ('HML', hml_result),
                       ('RMW', rmw_result), ('CMA', cma_result),
                       ('WML', wml_result)]:
    if result is None:
        continue
    fr = result['factor_returns']
    cum = (1 + fr['factor_return']).cumprod()
    ax.plot(cum.index, cum.values, color=factor_colors[name],
            linewidth=2, label=name)

ax.axhline(y=1, color='gray', linewidth=0.5)
ax.set_ylabel('Cumulative Return')
ax.set_xlabel('Date')
ax.set_title('Vietnamese Factor Cumulative Returns (2×3 VW, HOSE Breakpoints)')
ax.legend(ncol=5)
ax.set_yscale('log')
plt.tight_layout()
plt.show()
Hình 19.3

19.7 Bước 5: Độ nhạy với các lựa chọn xây dựng

Bài học quan trọng nhất trong xây dựng nhân tố là phần bù nhân tố thu được không được xác định duy nhất bởi giả thuyết kinh tế; nó phụ thuộc đáng kể vào các lựa chọn triển khai. Chúng ta thay đổi có hệ thống từng lựa chọn và xem xét nhân tố thay đổi ra sao.

Các lựa chọn được thay đổi dưới đây là những lựa chọn mà các nhà nghiên cứu thường coi là lựa chọn: cách đặt trọng số, các điểm cắt, số nhóm, tần suất tái cân bằng. Chuẩn so sánh được trừ đi để tạo lợi nhuận vượt mức hiếm khi nằm trong danh sách đó, nhưng Binsbergen, Ma, và Schwert (2026) cho thấy nó thuộc về danh sách này. Việc thay lãi suất ngắn hạn bằng một danh mục trái phiếu chính phủ có thời gian đáo hạn bình quân (duration) tương ứng làm thay đổi dấu hoặc ý nghĩa thống kê của một thiểu số đáng kể các hiện tượng bất thường đã công bố ở Hoa Kỳ mà không hề động đến biến phân loại. Chúng tôi không triển khai điều chỉnh đó ở đây, nhưng cần lưu ý rằng bảng độ nhạy ở cuối mục này đánh giá thấp toàn bộ đa vũ trụ (multiverse) các đặc tả có cơ sở hợp lý.

19.7.1 Cách đặt trọng số: theo vốn hóa so với bằng nhau

Compare VW and EW factor construction
sensitivity_results = {}

for name, signal, long_grp in [
    ('HML', 'bm', 'high'), ('RMW', 'op', 'high'), ('WML', 'ret_12_2', 'high')
]:
    for wt in ['value', 'equal']:
        result = construct_factor(
            panel, signal_col=signal, long_group=long_grp,
            weighting=wt, bp_universe='hose',
            rebalance_freq='annual' if name != 'WML' else 'monthly'
        )
        if result:
            d = result['diagnostics']
            sensitivity_results[f"{name}_{wt}"] = {
                'Factor': name, 'Weighting': wt,
                'Ann. Return': d['ann_return'],
                't-stat': d['t_stat']
            }

sens_df = pd.DataFrame(sensitivity_results).T
print("VW vs EW Factor Returns:")
print(sens_df.round(3).to_string())

19.7.2 Tập hợp tính điểm cắt: HOSE so với tất cả cổ phiếu

Compare HOSE vs all-stock breakpoints
for name, signal, long_grp in [
    ('HML', 'bm', 'high'), ('WML', 'ret_12_2', 'high')
]:
    for bp in ['hose', 'all']:
        result = construct_factor(
            panel, signal_col=signal, long_group=long_grp,
            bp_universe=bp, weighting='value',
            rebalance_freq='annual' if name != 'WML' else 'monthly'
        )
        if result:
            d = result['diagnostics']
            sensitivity_results[f"{name}_bp_{bp}"] = {
                'Factor': name, 'Breakpoints': bp,
                'Ann. Return': d['ann_return'],
                't-stat': d['t_stat'],
                'Avg N': d['avg_stocks_per_portfolio']
            }

bp_sens = pd.DataFrame({k: v for k, v in sensitivity_results.items()
                          if 'bp_' in k}).T
print("\nBreakpoint Universe Sensitivity:")
print(bp_sens.round(3).to_string())

19.7.3 Số nhóm: 2×3 so với 5×5 so với thập phân vị

Compare different sorting granularities
group_configs = [
    (2, 3, '2x3 (FF standard)'),
    (2, 5, '2x5 (quintiles)'),
    (1, 10, '1x10 (deciles)')
]

group_results = {}
for n_size, n_signal, label in group_configs:
    result = construct_factor(
        panel, signal_col='bm', long_group='high',
        n_size_groups=n_size, n_signal_groups=n_signal,
        weighting='value', bp_universe='hose',
        rebalance_freq='annual'
    )
    if result:
        d = result['diagnostics']
        group_results[label] = {
            'Ann. Return': d['ann_return'],
            't-stat': d['t_stat'],
            'Avg N per port': d['avg_stocks_per_portfolio'],
            'Min N per port': d['min_stocks_per_portfolio']
        }

print("HML: Sorting Granularity Sensitivity:")
print(pd.DataFrame(group_results).T.round(3).to_string())

19.7.4 Tần suất tái cân bằng

Compare annual vs quarterly vs monthly rebalancing
rebal_results = {}
for freq in ['annual', 'quarterly', 'monthly']:
    result = construct_factor(
        panel, signal_col='bm', long_group='high',
        rebalance_freq=freq, weighting='value', bp_universe='hose'
    )
    if result:
        d = result['diagnostics']
        rebal_results[freq] = {
            'Ann. Return': d['ann_return'],
            'Ann. Vol': d['ann_vol'],
            't-stat': d['t_stat']
        }

print("HML: Rebalancing Frequency Sensitivity:")
print(pd.DataFrame(rebal_results).T.round(4).to_string())

19.7.5 Tóm tắt toàn diện về độ nhạy

Heatmap of factor premium sensitivity
# Systematic grid search for HML
configs = list(product(
    ['value', 'equal'],        # Weighting
    ['hose', 'all'],           # Breakpoint universe
    [(2, 3), (2, 5), (1, 10)]  # (n_size, n_signal)
))

grid_results = []
for wt, bp, (ns, nsig) in configs:
    result = construct_factor(
        panel, signal_col='bm', long_group='high',
        weighting=wt, bp_universe=bp,
        n_size_groups=ns, n_signal_groups=nsig,
        rebalance_freq='annual'
    )
    if result:
        d = result['diagnostics']
        grid_results.append({
            'Weighting': 'VW' if wt == 'value' else 'EW',
            'Breakpoints': bp.upper(),
            'Sort': f'{ns}x{nsig}',
            'Ann. Return': d['ann_return'],
            't-stat': d['t_stat']
        })

grid_df = pd.DataFrame(grid_results)
print("HML Factor: Full Sensitivity Grid:")
print(grid_df.to_string(index=False))

# Pivot for heatmap
pivot = grid_df.pivot_table(
    values='Ann. Return', index=['Weighting', 'Breakpoints'],
    columns='Sort'
)

fig, ax = plt.subplots(figsize=(8, 5))
sns.heatmap(pivot * 100, annot=True, fmt='.1f', cmap='RdYlGn',
            center=0, linewidths=0.5, ax=ax,
            cbar_kws={'label': 'Ann. Return (%)'})
ax.set_title('HML Premium: Sensitivity to Construction Choices')
plt.tight_layout()
plt.show()
Hình 19.4

19.8 Cấu trúc tương quan giữa các nhân tố

Compute and plot factor return correlation matrix
# Merge all factor return series
factor_panel = pd.DataFrame()
for name, result in [('SMB', smb_result), ('HML', hml_result),
                       ('RMW', rmw_result), ('CMA', cma_result),
                       ('WML', wml_result)]:
    if result is None:
        continue
    fr = result['factor_returns'].rename(columns={'factor_return': name})
    if factor_panel.empty:
        factor_panel = fr
    else:
        factor_panel = factor_panel.merge(fr, left_index=True,
                                           right_index=True, how='outer')

corr = factor_panel.corr()

fig, ax = plt.subplots(figsize=(7, 6))
mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
sns.heatmap(corr, mask=mask, annot=True, fmt='.2f', cmap='RdBu_r',
            center=0, vmin=-1, vmax=1, square=True,
            linewidths=0.5, ax=ax)
ax.set_title('Factor Return Correlations')
plt.tight_layout()
plt.show()
Hình 19.5

19.9 Kiểm định nhân tố

Một nhân tố được xây dựng tốt cần vượt qua một số kiểm tra chẩn đoán trước khi được dùng trong nghiên cứu định giá tài sản.

19.9.1 Danh sách kiểm tra chẩn đoán

Run diagnostic tests on each factor
def validate_factor(factor_result, name):
    """
    Run standard diagnostic tests on a constructed factor.
    """
    fr = factor_result['factor_returns']['factor_return']
    diag = factor_result['diagnostics']
    ports = factor_result['portfolios']
    
    tests = {}
    
    # 1. Statistical significance (t > 2)
    tests['t-stat'] = diag['t_stat']
    tests['t > 2'] = abs(diag['t_stat']) > 2.0
    
    # 2. Economic magnitude
    tests['Ann. Return'] = diag['ann_return']
    tests['Ann. Vol'] = diag['ann_vol']
    tests['Sharpe'] = diag['sharpe']
    
    # 3. Adequate portfolio diversification
    tests['Avg N per portfolio'] = diag['avg_stocks_per_portfolio']
    tests['Min N per portfolio'] = diag['min_stocks_per_portfolio']
    tests['Min N >= 5'] = diag['min_stocks_per_portfolio'] >= 5
    
    # 4. Not dominated by a single month
    tests['Max monthly return'] = fr.max()
    tests['Min monthly return'] = fr.min()
    tests['Fraction > 0'] = (fr > 0).mean()
    
    # 5. Persistence (ACF at lag 1)
    if len(fr) > 12:
        tests['ACF(1)'] = fr.autocorr(lag=1)
    
    # 6. Consistency across subperiods
    mid = len(fr) // 2
    first_half = fr.iloc[:mid]
    second_half = fr.iloc[mid:]
    tests['Return (1st half)'] = first_half.mean() * 12
    tests['Return (2nd half)'] = second_half.mean() * 12
    tests['Same sign both halves'] = (
        np.sign(first_half.mean()) == np.sign(second_half.mean())
    )
    
    return tests

print("Factor Validation Summary:")
print("=" * 70)
for name, result in [('SMB', smb_result), ('HML', hml_result),
                       ('RMW', rmw_result), ('CMA', cma_result),
                       ('WML', wml_result)]:
    if result is None:
        continue
    tests = validate_factor(result, name)
    print(f"\n{name}:")
    for test, value in tests.items():
        if isinstance(value, bool):
            status = 'PASS' if value else 'FAIL'
            print(f"  {test:<30}: {status}")
        elif isinstance(value, float):
            print(f"  {test:<30}: {value:.4f}")
        else:
            print(f"  {test:<30}: {value}")

19.9.2 Kiểm định tính đơn điệu

Một nhân tố được xây dựng từ phân loại theo đặc điểm cần cho lợi nhuận trung bình tăng đơn điệu (hoặc giảm đơn điệu) qua các phân vị. Vi phạm tính đơn điệu cho thấy mối quan hệ giữa tín hiệu và lợi nhuận là phi tuyến hoặc không tồn tại.

Test monotonicity of returns across signal deciles
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()

signals = [
    ('bm', 'Book-to-Market', 'high'),
    ('op', 'Operating Profit.', 'high'),
    ('investment', 'Investment', 'low'),
    ('ret_12_2', 'Momentum (12-2)', 'high'),
    ('ivol', 'Idio. Volatility', 'low'),
]

for i, (sig, label, long_grp) in enumerate(signals):
    result = construct_factor(
        panel, signal_col=sig, long_group=long_grp,
        n_size_groups=1, n_signal_groups=10,
        weighting='value', bp_universe='hose',
        rebalance_freq='annual' if sig not in ['ret_12_2'] else 'monthly'
    )
    if result is None:
        continue
    
    port_ret = result['portfolio_returns']
    decile_means = (
        port_ret.groupby('signal_group')['port_return']
        .mean() * 12 * 100
    )
    
    colors_mono = plt.cm.RdYlGn_r(np.linspace(0.1, 0.9, len(decile_means)))
    axes[i].bar(range(len(decile_means)), decile_means.values,
                color=colors_mono, edgecolor='white')
    axes[i].set_xticks(range(len(decile_means)))
    axes[i].set_xticklabels([f'D{d+1}' for d in range(len(decile_means))],
                             fontsize=8)
    axes[i].set_ylabel('Ann. Return (%)')
    axes[i].set_title(label)
    axes[i].axhline(y=0, color='gray', linewidth=0.5)

axes[5].set_visible(False)
plt.suptitle('Decile Portfolio Returns by Signal', fontsize=14)
plt.tight_layout()
plt.show()
Hình 19.6

19.9.3 Kiểm định bao phủ (spanning)

Một nhân tố mới có bổ sung thông tin ngoài các nhân tố hiện có không? Chúng ta kiểm tra điều này bằng cách hồi quy từng nhân tố theo tất cả các nhân tố còn lại và xem xét hệ số chặn (alpha). Alpha có ý nghĩa thống kê nghĩa là nhân tố nắm bắt biến thiên lợi nhuận mà các nhân tố khác không bao phủ được:

Run spanning tests: regress each factor on remaining factors
factor_names = [c for c in factor_panel.columns if factor_panel[c].notna().sum() > 24]
spanning_data = factor_panel[factor_names].dropna()

print("Spanning Tests (alpha = intercept when regressed on other factors):")
print(f"{'Factor':<8} {'Alpha (ann.)':>12} {'t-stat':>8} {'R²':>6}")
print("-" * 36)

for target in factor_names:
    others = [f for f in factor_names if f != target]
    y = spanning_data[target]
    X = sm.add_constant(spanning_data[others])
    model = sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': 6})
    
    alpha_ann = model.params['const'] * 12
    alpha_t = model.tvalues['const']
    r2 = model.rsquared
    
    print(f"{target:<8} {alpha_ann:>12.4f} {alpha_t:>8.2f} {r2:>6.3f}")

19.10 Những cân nhắc riêng của Việt Nam

19.10.1 Phân loại doanh nghiệp nhà nước

Một đặc điểm độc đáo của cổ phiếu Việt Nam là tỷ lệ cao các doanh nghiệp nhà nước (SOE). Nhà nước nắm giữ phần vốn chi phối hoặc phần vốn thiểu số đáng kể ở nhiều doanh nghiệp niêm yết, điều này ảnh hưởng đến quản trị công ty, môi trường thông tin và động lực giao dịch. Các nhân tố có thể hành xử khác nhau trong các mẫu con SOE và phi SOE:

Compare factor premia within SOE and non-SOE subsamples
# Get SOE classification
firm_info = client.get_firm_info(
    exchanges=['HOSE', 'HNX'],
    fields=['ticker', 'state_ownership_pct', 'is_soe']
)

panel_soe = panel.merge(firm_info[['ticker', 'is_soe']], on='ticker', how='left')

for label, subset in [('SOE', panel_soe[panel_soe['is_soe'] == True]),
                        ('Non-SOE', panel_soe[panel_soe['is_soe'] == False])]:
    hml = construct_factor(
        subset, signal_col='bm', long_group='high',
        weighting='value', bp_universe='all',
        rebalance_freq='annual'
    )
    if hml:
        d = hml['diagnostics']
        print(f"HML ({label}): Ann = {d['ann_return']:.4f}, "
              f"t = {d['t_stat']:.2f}, N = {d['avg_stocks_per_portfolio']:.0f}")

19.10.2 Giới hạn sở hữu nước ngoài

Trần sở hữu nước ngoài (49% với hầu hết các ngành, 30% với ngân hàng) ảnh hưởng đến tập hợp cổ phiếu có thể đầu tư của các nhà đầu tư quốc tế. Các nhân tố được xây dựng từ toàn bộ tập hợp cổ phiếu có thể không thực hiện được đối với nhà đầu tư nước ngoài nếu vị thế mua tập trung vào các cổ phiếu đã chạm giới hạn sở hữu nước ngoài (hết room ngoại):

Check foreign ownership proximity in factor portfolios
fol = client.get_foreign_ownership(
    exchanges=['HOSE', 'HNX'],
    fields=['ticker', 'month_end', 'foreign_pct', 'foreign_limit_pct']
)

# Merge with HML portfolios
if hml_result:
    hml_ports = hml_result['portfolios'].merge(
        fol, on=['ticker', 'month_end'], how='left'
    )
    hml_ports['near_limit'] = (
        (hml_ports['foreign_limit_pct'] - hml_ports['foreign_pct']) < 5
    )
    
    fol_by_group = (
        hml_ports.groupby('signal_group')
        .agg(
            avg_foreign_pct=('foreign_pct', 'mean'),
            pct_near_limit=('near_limit', 'mean')
        )
    )
    print("Foreign Ownership in HML Portfolios:")
    print(fol_by_group.round(3))

19.11 Các đặc tả nhân tố khuyến nghị cho Việt Nam

Dựa trên phân tích độ nhạy, chúng tôi khuyến nghị các đặc tả cơ sở sau (Bảng 19.1).

Bảng 19.1: Các tham số xây dựng nhân tố được khuyến nghị cho cổ phiếu Việt Nam.
Lựa chọn Khuyến nghị Lý do
Tập hợp cổ phiếu Bộ lọc chuẩn (thời gian niêm yết ≥ 6 tháng, khối lượng > 0) Loại bỏ các doanh nghiệp vỏ mà không mất quá nhiều độ rộng
Điểm cắt Chỉ cổ phiếu HOSE Ngăn các cổ phiếu vốn hóa siêu nhỏ của HNX chi phối điểm cắt
Nhóm quy mô 2 (chia theo trung vị) Đủ kiểm soát với mặt cắt ngang hạn chế
Nhóm tín hiệu 3 (tam phân vị) để xây dựng nhân tố; 5 hoặc 10 để phân tích danh mục 2×3 = đa dạng hóa đủ cho mỗi danh mục
Trọng số Theo vốn hóa Có thể đầu tư; ít nhiễu hơn EW
Tái cân bằng Hằng năm (tháng 6) với tín hiệu kế toán; hằng tháng với động lượng Chuẩn; nhất quán với Fama-French
Độ trễ kế toán 4 tháng (có sẵn vào tháng 4 với năm tài chính kết thúc tháng 12) Căn chỉnh PIT thận trọng
Số cổ phiếu tối thiểu ≥ 5 cho mỗi danh mục mỗi tháng Dưới mức này, rủi ro riêng của từng cổ phiếu chi phối

Luôn báo cáo kết quả theo đặc tả cơ sở và ít nhất một đặc tả thay thế (ví dụ: EW, điểm cắt tính từ tất cả cổ phiếu) để chứng minh độ vững.

19.12 Tóm tắt

Chương này đã phát triển một khung xây dựng nhân tố mô-đun, minh bạch cho cổ phiếu Việt Nam. Những hiểu biết chính là:

  • Phương pháp 2×3 của Fama và French (1993) chuyển sang Việt Nam khá tốt với một điều chỉnh then chốt: các điểm cắt nên được tính chỉ từ cổ phiếu HOSE. Dùng điểm cắt tính từ tất cả cổ phiếu khiến các cổ phiếu vốn hóa siêu nhỏ của HNX chi phối các nhóm cổ phiếu nhỏ, làm tăng phần bù biểu kiến bằng những khoản lợi nhuận không thể giao dịch về mặt kinh tế.

  • Trọng số theo vốn hóa cho ra phần bù nhân tố thận trọng hơn (và khả thi hơn) so với trọng số bằng nhau. Chênh lệch đặc biệt lớn với các tín hiệu tương quan với quy mô (BM, đầu tư), khi EW đặt trọng số quá lớn vào các cổ phiếu nhỏ nhất, vốn đóng góp nhiều nhất cho phần bù nhưng ít nhất cho lợi nhuận có thể đầu tư.

  • Không có lựa chọn xây dựng đơn lẻ nào quyết định một nhân tố có “tồn tại” hay không. Một phần bù nhân tố chỉ xuất hiện dưới một tổ hợp cụ thể của các điểm cắt, cách đặt trọng số và tần suất tái cân bằng là mong manh và cần được xem xét với thái độ hoài nghi. Các nhân tố vững sống sót qua một lưới các đặc tả. Khung phân tích độ nhạy được phát triển ở đây (ví dụ: thay đổi đồng thời cách đặt trọng số, các điểm cắt, độ mịn của phân loại và tái cân bằng) nên trở thành thông lệ chuẩn.

Hàm construct_factor() được phát triển trong chương này được thiết kế để tái sử dụng xuyên suốt cuốn sách. Mọi biến đo lường hiện tượng bất thường đều có thể đưa qua cùng quy trình này, tạo ra một nhân tố có thể giao dịch cùng đầy đủ các chẩn đoán. Điều này bảo đảm sự nhất quán về phương pháp giữa các chương và giúp dễ dàng so sánh các phần bù một cách tương đương.