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:
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ô).
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).
Đặ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ị).
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.
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).
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 pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import statsfrom itertools import productimport warningswarnings.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
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 monthsif 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'] =Falsereturn d# Apply all filter levels and comparefilter_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).Tprint("Universe Filter Effects:")print(filter_df.round(1).to_string())
Plot universe size under different filters over time
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 + lagif'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 notin ['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 mergepanel = pit_merge_accounting(monthly, accounting, lag_months=4)# Construct common signalspanel['log_mcap'] = np.log(panel['market_cap'].clip(lower=1))# Book-to-marketpanel['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))# Leveragepanel['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 signalspanel = 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 returnpanel['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à:
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)?
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-marketexample_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 monthbp_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 rulefor 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 timefor 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 groupfor 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 requestedif signal_lag >0: df[signal_col] = ( df.groupby('ticker')[signal_col].shift(signal_lag) )# Determine formation datesif 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])iflen(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)ifnot all_portfolios:returnNone portfolios = pd.concat(all_portfolios, ignore_index=True)# Compute portfolio returnsdef 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 -1if long_group =='high'else0 low_label =0if 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 BMhml_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 weakrmw_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 aggressivecma_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 loserswml_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 tableprint("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 isNone: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}")
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).Tprint("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}).Tprint("\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())
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)iflen(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 testsprint("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 isNone:continue tests = validate_factor(result, name)print(f"\n{name}:")for test, value in tests.items():ifisinstance(value, bool): status ='PASS'if value else'FAIL'print(f" {test:<30}: {status}")elifisinstance(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
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.rsquaredprint(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 classificationfirm_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
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.
Binsbergen, Jules H. van, Liang Ma, và Michael Schwert. 2026. “The factor multiverse: The role of interest rates in factor return measurement”. Management Science. https://doi.org/10.1287/mnsc.2024.08851.
Fama, Eugene F., và Kenneth R. French. 1993. “Common risk factors in the returns on stocks and bonds”. Journal of Financial Economics 33 (1): 3–56. https://doi.org/10.1016/0304-405X(93)90023-5.
Hou, Kewei, Chen Xue, và Lu Zhang. 2020. “Replicating Anomalies”. The Review of Financial Studies 33 (5): 2019–2133.
# Nguyên tắc xây dựng nhân tố::: callout-noteTrong 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ỳ, @fama1993common đã 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.@hou2020replicating đã 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.## Quy trình xây dựng nhân tố {#sec-factor-con-pipeline}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.## Xây dựng dữ liệu {#sec-factor-con-data}```{python}#| label: setup#| code-summary: "Import libraries and configure environment"import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import statsfrom itertools import productimport warningswarnings.filterwarnings('ignore')plt.rcParams.update({'figure.figsize': (12, 6),'figure.dpi': 150,'font.size': 11,'axes.spines.top': False,'axes.spines.right': False})``````{python}#| label: data-load#| eval: false#| code-summary: "Load returns, characteristics, and accounting data"from datacore import DataCoreClientclient = 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 dataaccounting = 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 signalsdaily = 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()}")```## Bước 1: Xác định tập hợp cổ phiếu {#sec-factor-con-universe}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.### Vấn đề tập hợp cổ phiếu ở Việt NamViệ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ỏ.```{python}#| label: universe-filters#| eval: false#| code-summary: "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 monthsif 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'] =Falsereturn d# Apply all filter levels and comparefilter_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).Tprint("Universe Filter Effects:")print(filter_df.round(1).to_string())``````{python}#| label: fig-universe-size#| eval: false#| fig-cap: "Số lượng cổ phiếu trong tập hợp có thể đầu tư theo các định nghĩa bộ lọc khác nhau. Bộ lọc 'strict' (nghiêm ngặt) làm giảm mặt cắt ngang khoảng 30-40% nhưng vẫn giữ lại hơn 90% tổng vốn hóa thị trường. Sự đánh đổi nằm giữa độ rộng của mặt cắt ngang (điểm cắt chính xác hơn) và chất lượng dữ liệu (ít nhiễu hơn từ các cổ phiếu vốn hóa siêu nhỏ kém thanh khoản)."#| code-summary: "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 coveragefor 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()```## Bước 2: Xây dựng tín hiệu {#sec-factor-con-signals}### 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):```{python}#| label: pit-merge#| eval: false#| code-summary: "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 + lagif'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 notin ['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 mergepanel = pit_merge_accounting(monthly, accounting, lag_months=4)# Construct common signalspanel['log_mcap'] = np.log(panel['market_cap'].clip(lower=1))# Book-to-marketpanel['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))# Leveragepanel['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%}")```### Tín hiệu động lượng và biến độngCá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.```{python}#| label: price-signals#| eval: false#| code-summary: "Construct momentum, reversal, and volatility signals"# Past returns for momentum signalspanel = 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 returnpanel['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%}")```## Bước 3: Tính các điểm cắt {#sec-factor-con-breakpoints}### Quyết định về điểm cắtCá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?@fama1993common 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.```{python}#| label: breakpoints#| eval: false#| code-summary: "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-marketexample_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")``````{python}#| label: fig-breakpoints#| eval: false#| fig-cap: "Ảnh hưởng của tập hợp tính điểm cắt đến thành phần danh mục. Panel A thể hiện chuỗi thời gian của điểm cắt BM trung vị khi chỉ tính từ HOSE so với tính từ tất cả cổ phiếu. Panel B thể hiện số cổ phiếu thu được trong mỗi tam phân vị. Việc dùng điểm cắt của HOSE đưa nhiều cổ phiếu HNX hơn vào nhóm tăng trưởng (BM thấp), phản ánh tương quan giữa quy mô và giá trị."#| code-summary: "Visualize breakpoint effects over time"# Compute breakpoints for every monthbp_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 rulefor 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 timefor 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 groupfor 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()```## Bước 4: Hình thành danh mục {#sec-factor-con-formation}### Động cơ nhân tố tổng quátChú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.```{python}#| label: factor-engine#| eval: false#| code-summary: "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 requestedif signal_lag >0: df[signal_col] = ( df.groupby('ticker')[signal_col].shift(signal_lag) )# Determine formation datesif 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])iflen(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)ifnot all_portfolios:returnNone portfolios = pd.concat(all_portfolios, ignore_index=True)# Compute portfolio returnsdef 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 -1if long_group =='high'else0 low_label =0if 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 }```### Xây dựng các nhân tố cốt lõiBâ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:```{python}#| label: core-factors#| eval: false#| code-summary: "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 BMhml_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 weakrmw_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 aggressivecma_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 loserswml_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 tableprint("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 isNone: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}")``````{python}#| label: fig-factor-cumulative#| eval: false#| fig-cap: "Lợi nhuận tích lũy của năm nhân tố Việt Nam (SMB, HML, RMW, CMA, WML) được xây dựng theo phương pháp 2×3 của Fama-French với các điểm cắt của HOSE và trọng số theo vốn hóa. Hành vi của các nhân tố phản ánh đặc điểm thị trường Việt Nam: phần bù HML mạnh phản ánh thiên hướng giá trị của thị trường, trong khi động lượng (WML) cho thấy mô thức sụp đổ rồi phục hồi đặc trưng."#| code-summary: "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 isNone: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()```## Bước 5: Độ nhạy với các lựa chọn xây dựng {#sec-factor-con-sensitivity}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 @binsbergen2026factor 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ý.### Cách đặt trọng số: theo vốn hóa so với bằng nhau```{python}#| label: vw-vs-ew#| eval: false#| code-summary: "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).Tprint("VW vs EW Factor Returns:")print(sens_df.round(3).to_string())```### Tập hợp tính điểm cắt: HOSE so với tất cả cổ phiếu```{python}#| label: bp-sensitivity#| eval: false#| code-summary: "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}).Tprint("\nBreakpoint Universe Sensitivity:")print(bp_sens.round(3).to_string())```### Số nhóm: 2×3 so với 5×5 so với thập phân vị```{python}#| label: group-sensitivity#| eval: false#| code-summary: "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())```### Tần suất tái cân bằng```{python}#| label: rebalance-sensitivity#| eval: false#| code-summary: "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())```### Tóm tắt toàn diện về độ nhạy```{python}#| label: fig-sensitivity-heatmap#| eval: false#| fig-cap: "Độ nhạy của các phần bù nhân tố với các lựa chọn xây dựng. Mỗi ô thể hiện lợi nhuận mua–bán khống quy năm theo một tổ hợp cụ thể của cách đặt trọng số (VW so với EW), tập hợp tính điểm cắt (HOSE so với tất cả) và số nhóm. Các ô được tô đậm theo độ lớn của thống kê t: càng đậm = càng có ý nghĩa thống kê. Các kết quả vững trên tất cả các ô đáng tin cậy hơn."#| code-summary: "Heatmap of factor premium sensitivity"# Systematic grid search for HMLconfigs =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 heatmappivot = 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()```## Cấu trúc tương quan giữa các nhân tố {#sec-factor-con-correlation}```{python}#| label: fig-factor-correlations#| eval: false#| fig-cap: "Ma trận tương quan của lợi nhuận các nhân tố Việt Nam. SMB và HML có tương quan gần bằng không (theo cách xây dựng trong phương pháp 2×3, vốn kiểm soát quy mô trong mỗi phân loại tín hiệu). WML có tương quan thấp với các nhân tố dựa trên kế toán. Cấu trúc tương quan cho thấy mức độ thông tin độc lập trong mỗi nhân tố."#| code-summary: "Compute and plot factor return correlation matrix"# Merge all factor return seriesfactor_panel = pd.DataFrame()for name, result in [('SMB', smb_result), ('HML', hml_result), ('RMW', rmw_result), ('CMA', cma_result), ('WML', wml_result)]:if result isNone:continue fr = result['factor_returns'].rename(columns={'factor_return': name})if factor_panel.empty: factor_panel = frelse: 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()```## Kiểm định nhân tố {#sec-factor-con-validation}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.### Danh sách kiểm tra chẩn đoán```{python}#| label: validation#| eval: false#| code-summary: "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)iflen(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 testsprint("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 isNone:continue tests = validate_factor(result, name)print(f"\n{name}:")for test, value in tests.items():ifisinstance(value, bool): status ='PASS'if value else'FAIL'print(f" {test:<30}: {status}")elifisinstance(value, float):print(f" {test:<30}: {value:.4f}")else:print(f" {test:<30}: {value}")```### Kiểm định tính đơn điệuMộ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.```{python}#| label: fig-monotonicity#| eval: false#| fig-cap: "Lợi nhuận các danh mục thập phân vị được phân loại theo từng tín hiệu (độc lập với quy mô). Tính đơn điệu—mức tăng đều đặn từ thập phân vị tín hiệu thấp đến cao—xác nhận mối quan hệ giữa tín hiệu và lợi nhuận. Các vi phạm (ví dụ: dạng chữ U) cho thấy tính phi tuyến hoặc các sai lệch do cách xây dựng."#| code-summary: "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) inenumerate(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 notin ['ret_12_2'] else'monthly' )if result isNone: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 inrange(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()```### 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:```{python}#| label: spanning#| eval: false#| code-summary: "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.rsquaredprint(f"{target:<8}{alpha_ann:>12.4f}{alpha_t:>8.2f}{r2:>6.3f}")```## Những cân nhắc riêng của Việt Nam {#sec-factor-con-vietnam}### Phân loại doanh nghiệp nhà nướcMộ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:```{python}#| label: soe-split#| eval: false#| code-summary: "Compare factor premia within SOE and non-SOE subsamples"# Get SOE classificationfirm_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}")```### Giới hạn sở hữu nước ngoàiTrầ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):```{python}#| label: foreign-ownership#| eval: false#| code-summary: "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 portfoliosif 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))```## Các đặc tả nhân tố khuyến nghị cho Việt Nam {#sec-factor-con-recommendations}Dựa trên phân tích độ nhạy, chúng tôi khuyến nghị các đặc tả cơ sở sau (@tbl-factor-con-recommendations).| 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 |: Các tham số xây dựng nhân tố được khuyến nghị cho cổ phiếu Việt Nam. {#tbl-factor-con-recommendations}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.## Tóm tắt {#sec-factor-con-summary}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 @fama1993common 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.<!-- ## Bài tập {#sec-factor-con-exercises}1. **Phân loại có điều kiện.** Triển khai phân loại kép có điều kiện (tuần tự) như một phương án thay thế cho các phân loại độc lập được dùng bởi @fama1993common. Trước tiên phân loại theo quy mô, sau đó trong mỗi nhóm quy mô phân loại theo BM. So sánh HML thu được với HML từ phân loại độc lập. Khi nào các phân loại có điều kiện cho kết quả khác biệt đáng kể?2. **Độ nhạy với winsorization.** Xây dựng HML có và không winsorize (cắt đuôi giá trị cực đoan) tỷ số giá trị sổ sách trên giá trị thị trường ở phân vị 1 và 99 mỗi tháng. Phần bù nhân tố nhạy đến mức nào với các giá trị BM cực đoan? Hãy khảo sát xem các doanh nghiệp có BM cực đoan có tập trung ở một số ngành cụ thể hay không.3. **Dữ liệu kế toán có độ trễ.** Xây dựng lại cả năm nhân tố với độ trễ kế toán 6 tháng thay vì 4 tháng. Độ trễ bổ sung làm giảm phần bù bao nhiêu? Bài tập này kiểm tra bao nhiêu phần của phần bù biểu kiến đến từ dữ liệu lỗi thời mà lúc đó chưa được công bố công khai.4. **Nhân tố trung lập theo ngành.** Xây dựng HML trung lập theo ngành bằng cách phân loại theo BM trong từng ngành ICB (thay vì trên toàn thị trường). Phần bù giá trị trong nội bộ ngành có khác với phần bù giữa các ngành không? Thành phần nào lớn hơn ở Việt Nam?5. **Nhân tố kiểm soát thanh khoản.** Thêm phân loại độc lập thứ ba theo độ phi thanh khoản Amihud (2×3×3 = 18 danh mục). Phần bù HML có còn tồn tại sau khi kiểm soát thanh khoản không? Bài tập này kiểm tra xem phần bù giá trị biểu kiến có thực chất là phần bù thanh khoản trá hình hay không.6. **Bootstrap mẫu nhỏ.** Mặt cắt ngang Việt Nam đủ nhỏ để các phân loại danh mục có thể cho ước lượng nhiễu. Triển khai một thủ tục bootstrap: lấy mẫu lại mặt cắt ngang có hoàn lại 1.000 lần tại mỗi ngày hình thành, xây dựng lại nhân tố mỗi lần, và báo cáo khoảng tin cậy 90% cho phần bù quy năm. Khoảng này rộng bao nhiêu?7. **Định thời điểm nhân tố.** Kiểm tra xem các phần bù nhân tố có dự báo được bằng các biến tổng hợp có độ trễ (biến động thị trường, chênh lệch tín dụng, tỷ lệ vòng quay tổng hợp, lãi suất điều hành của NHNN) hay không. Ước lượng hồi quy dự báo lợi nhuận nhân tố tháng sau theo các biến này. Bạn có thể xây dựng chiến lược định thời điểm nhân tố cải thiện so với nhân tố mua và nắm giữ vô điều kiện không?8. **Lặp lại @hou2020replicating cho Việt Nam.** Chọn 10 hiện tượng bất thường nổi tiếng từ tài liệu quốc tế (ví dụ: lợi nhuận gộp trên tài sản, tăng trưởng tài sản, các khoản dồn tích, phát hành cổ phiếu, ROE, beta, biến động đặc thù, động lượng lợi nhuận kế toán, đầu tư trên tài sản, phát hành cổ phiếu ròng). Xây dựng từng hiện tượng thành một nhân tố mua–bán khống bằng động cơ của chương này. Có bao nhiêu hiện tượng sống sót với t \> 2.0? Tỷ lệ lặp lại này so với tỷ lệ \~50% do @hou2020replicating báo cáo cho dữ liệu Hoa Kỳ như thế nào? -->