Trong chương này, chúng ta ghi nhận các dạng dữ liệu bị thiếu, thiên kiến sống sót và thiên kiến hủy niêm yết trên thị trường chứng khoán Việt Nam, xây dựng các công cụ chẩn đoán để phát hiện những vấn đề này, và triển khai các phương pháp hiệu chỉnh nhằm cho ra kết quả thực nghiệm đáng tin cậy hơn.
Mọi nghiên cứu thực nghiệm trong tài chính đều ngầm giả định rằng dữ liệu được phân tích đại diện cho tổng thể mà nghiên cứu tuyên bố khảo sát. Khi giả định này không còn đúng, vì các doanh nghiệp bị hủy niêm yết bị loại bỏ, vì cơ sở dữ liệu chỉ bắt đầu bao phủ sau khi doanh nghiệp đã sống sót, hoặc vì các khoảng trống giao dịch tạo ra các quan sát lợi nhuận bị thiếu, thì các ước lượng thu được sẽ bị chệch. Trong bối cảnh Hoa Kỳ, Shumway (1997) đã chỉ ra rằng việc bỏ qua lợi nhuận hủy niêm yết làm chệch lợi nhuận trung bình lên khoảng 1% mỗi năm đối với cổ phiếu NYSE và cao hơn đáng kể đối với cổ phiếu Nasdaq, với những hệ quả nghiêm trọng cho các chiến lược dựa trên hiện tượng bất thường vốn đặt tỷ trọng lớn vào các doanh nghiệp nhỏ, gặp khó khăn tài chính.
Thị trường Việt Nam đặt ra một loạt thách thức về tính toàn vẹn dữ liệu rất đặc thù và, theo nhiều khía cạnh, gay gắt hơn. Thị trường còn non trẻ. HOSE khai trương vào tháng 7 năm 2000 chỉ với hai cổ phiếu niêm yết, và số lượng niêm yết tăng nhanh qua làn sóng cổ phần hóa giữa những năm 2000. Điều này có nghĩa là bất kỳ mẫu nào bắt đầu trước khoảng năm 2007 đều chịu thiên kiến niêm yết mới nghiêm trọng: mặt cắt ngang ban đầu rất nhỏ và không mang tính đại diện. Việc hủy niêm yết diễn ra phổ biến và thường là không tự nguyện, do thua lỗ vượt quá vốn điều lệ, không nộp báo cáo tài chính, hoặc do các biện pháp cưỡng chế của UBCKNN thay vì do sáp nhập hay chuyển sang sở hữu tư nhân như ở Hoa Kỳ. Những vụ hủy niêm yết không tự nguyện này gắn với lợi nhuận cuối kỳ âm một cách có hệ thống. Và tình trạng phổ biến của các ngày không giao dịch ở nhóm cổ phiếu vốn hóa nhỏ tạo ra các khoảng trống lợi nhuận trông giống dữ liệu bị thiếu nhưng thực chất phản ánh tính kém thanh khoản.
Chương này cung cấp các công cụ để chẩn đoán và, khi có thể, hiệu chỉnh những vấn đề này.
13.1 Phân loại các vấn đề dữ liệu
Dữ liệu bị thiếu trong nghiên cứu tài chính không phải là một khối đồng nhất. Hệ quả phụ thuộc rất lớn vào cơ chế tạo ra sự thiếu hụt. Rubin (1976) và Little và Rubin (2019) phân loại dữ liệu bị thiếu thành ba loại:
Thiếu hoàn toàn ngẫu nhiên (MCAR). Xác suất một quan sát bị thiếu không phụ thuộc vào bất kỳ biến quan sát được hay không quan sát được nào. Ví dụ: máy chủ của nhà cung cấp dữ liệu gặp sự cố vào một ngày thứ Ba ngẫu nhiên, làm mất các bản ghi của ngày đó. MCAR là trường hợp lành tính nhất, phân tích trên các quan sát đầy đủ (loại bỏ các quan sát bị thiếu) cho ước lượng không chệch nhưng kém hiệu quả hơn.
Thiếu ngẫu nhiên (MAR). Xác suất thiếu phụ thuộc vào các biến quan sát được nhưng không phụ thuộc vào chính giá trị bị thiếu, khi đã điều kiện hóa theo các biến quan sát được. Ví dụ: các doanh nghiệp nhỏ có nhiều khả năng thiếu sự theo dõi của các nhà phân tích hơn, nhưng khi điều kiện hóa theo quy mô doanh nghiệp, việc thiếu sự theo dõi hay không không liên quan đến lợi nhuận kỳ vọng thực sự của doanh nghiệp. MAR cho phép ước lượng không chệch thông qua các phương pháp điều kiện hóa theo các biến dự báo quan sát được của sự thiếu hụt.
Thiếu không ngẫu nhiên (MNAR). Xác suất thiếu phụ thuộc vào chính giá trị bị thiếu. Ví dụ: các doanh nghiệp có kết quả kém nhất có nhiều khả năng bị hủy niêm yết và biến mất khỏi cơ sở dữ liệu nhất. MNAR là một trường hợp bệnh lý và, đáng tiếc, là phổ biến nhất trong dữ liệu tài chính. Thiên kiến sống sót và thiên kiến hủy niêm yết đều là các trường hợp của MNAR vì sự kiện loại bỏ quan sát (hủy niêm yết) tương quan với biến quan tâm (lợi nhuận).
Trong bối cảnh Việt Nam, chúng ta gặp cả ba loại, thường là đồng thời (Bảng 13.1).
Bảng 13.1: Phân loại dữ liệu bị thiếu trong các cơ sở dữ liệu chứng khoán Việt Nam
Vấn đề dữ liệu
Loại thiếu hụt
Cơ chế ở Việt Nam
Ngày không giao dịch
MAR/MNAR
Cổ phiếu nhỏ/kém thanh khoản; tương quan với lợi nhuận
Chạm biên độ giá
MNAR
Lợi nhuận thực bị cắt tại biên độ; lợi nhuận quan sát bị kiểm duyệt
Hủy niêm yết
MNAR
Các doanh nghiệp kém nhất rời thị trường; lợi nhuận biến mất
Bao phủ niêm yết muộn
Thiên kiến chọn mẫu
Cơ sở dữ liệu bắt đầu sau khi doanh nghiệp đã sống sót qua giai đoạn đầu
Chuyển sàn
Hành chính
Chuyển HOSE→HNX hoặc UPCoM làm gián đoạn tính liên tục của mã chứng khoán
Đình chỉ giao dịch
MNAR
Đình chỉ đi trước các sự kiện tiêu cực; lợi nhuận bị thiếu
13.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 smimport statsmodels.formula.api as smffrom scipy import statsfrom datetime import datetime, timedeltaimport 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 comprehensive listing, delisting, and return data
Lịch sử ngắn của thị trường chứng khoán Việt Nam tạo ra một dạng thức đặc trưng: tập hợp đầu tư tăng từ gần bằng không lên hơn 1,500 doanh nghiệp niêm yết trong khoảng hai thập kỷ. Sự tăng trưởng nhanh này có nghĩa là cấu thành của thị trường tại bất kỳ thời điểm nào cũng chịu ảnh hưởng nặng nề của thời điểm niêm yết, và các nghiên cứu dùng dữ liệu ban đầu phải đối mặt với các vấn đề mẫu nhỏ cực đoan.
Plot the growth of the Vietnamese listed universe
listing_history['listing_date'] = pd.to_datetime(listing_history['listing_date'])listing_history['delisting_date'] = pd.to_datetime(listing_history['delisting_date'])# Count active listings at each month-endmonths = pd.date_range('2000-07-01', '2024-12-31', freq='M')active_counts = []for month in months:for exchange in ['HOSE', 'HNX', 'UPCoM']: active = listing_history[ (listing_history['exchange'] == exchange) & (listing_history['listing_date'] <= month) & ((listing_history['delisting_date'].isna()) | (listing_history['delisting_date'] > month)) ] active_counts.append({'month': month,'exchange': exchange,'n_active': len(active) })active_df = pd.DataFrame(active_counts)fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Active listings over timefor exchange, color in [('HOSE', '#2C5F8A'), ('HNX', '#E67E22'), ('UPCoM', '#27AE60')]: subset = active_df[active_df['exchange'] == exchange] axes[0].plot(subset['month'], subset['n_active'], color=color, linewidth=2, label=exchange)axes[0].set_xlabel('Date')axes[0].set_ylabel('Number of Active Listings')axes[0].set_title('Panel A: Active Listings by Exchange')axes[0].legend()# Panel B: Annual listings and delistingslisting_history['listing_year'] = listing_history['listing_date'].dt.yearlisting_history['delisting_year'] = listing_history['delisting_date'].dt.yearannual_listings = ( listing_history .groupby('listing_year') .size() .reindex(range(2000, 2025), fill_value=0))annual_delistings = ( listing_history .dropna(subset=['delisting_year']) .groupby('delisting_year') .size() .reindex(range(2000, 2025), fill_value=0))x = np.arange(2000, 2025)axes[1].bar(x -0.2, annual_listings.values, width=0.4, color='#27AE60', alpha=0.85, label='New Listings')axes[1].bar(x +0.2, annual_delistings.values, width=0.4, color='#C0392B', alpha=0.85, label='Delistings')axes[1].set_xlabel('Year')axes[1].set_ylabel('Number of Firms')axes[1].set_title('Panel B: Annual Listings and Delistings')axes[1].legend()plt.tight_layout()plt.show()
Hình 13.1
13.3.2 Các lý do hủy niêm yết
Các vụ hủy niêm yết ở Việt Nam không đồng nhất. UBCKNN bắt buộc hủy niêm yết đối với các vi phạm quy định cụ thể, nhưng doanh nghiệp cũng có thể tự nguyện hủy niêm yết, sáp nhập hoặc chuyển giữa các sàn. Lý do hủy niêm yết quan trọng vì nó quyết định lợi nhuận cuối kỳ có khả năng xảy ra.
13.3.3 Đặc điểm doanh nghiệp tại thời điểm hủy niêm yết
Các doanh nghiệp bị hủy niêm yết có khác biệt một cách có hệ thống so với các doanh nghiệp sống sót hay không? Nếu có, việc loại bỏ chúng làm chệch phân phối quan sát được của các đặc điểm doanh nghiệp.
Compare characteristics of delisted vs surviving firms
# Get fundamentals in the last available year before delistinglast_year_delisted = ( delisted[['ticker', 'delisting_date']] .assign(last_fy=lambda x: x['delisting_date'].dt.year -1))fundamentals = client.get_fundamentals( exchanges=['HOSE', 'HNX', 'UPCoM'], start_date='2005-01-01', end_date='2024-12-31', include_delisted=True, fields=['ticker', 'fiscal_year', 'total_assets', 'net_income','total_equity', 'revenue', 'market_cap' ])# Characteristics of delisted firms (last year before delisting)delist_chars = ( last_year_delisted .merge(fundamentals.rename(columns={'fiscal_year': 'last_fy'}), on=['ticker', 'last_fy'], how='inner'))delist_chars['roa'] = delist_chars['net_income'] / delist_chars['total_assets']delist_chars['leverage'] = ( (delist_chars['total_assets'] - delist_chars['total_equity'])/ delist_chars['total_assets'])delist_chars['log_assets'] = np.log(delist_chars['total_assets'])delist_chars['group'] ='Delisted'# Characteristics of all active firms (pooled)all_chars = fundamentals.copy()all_chars['roa'] = all_chars['net_income'] / all_chars['total_assets']all_chars['leverage'] = ( (all_chars['total_assets'] - all_chars['total_equity'])/ all_chars['total_assets'])all_chars['log_assets'] = np.log(all_chars['total_assets'])all_chars['group'] ='All Active'# Compare distributionsfig, axes = plt.subplots(2, 2, figsize=(12, 10))variables = [ ('log_assets', 'Log Total Assets', axes[0, 0]), ('roa', 'Return on Assets', axes[0, 1]), ('leverage', 'Leverage Ratio', axes[1, 0]),]for col, label, ax in variables:for grp, color in [('All Active', '#2C5F8A'), ('Delisted', '#C0392B')]:if grp =='Delisted': data = delist_chars[col].dropna()else: data = all_chars[col].dropna() data = data[np.isfinite(data)] ax.hist(data, bins=50, density=True, alpha=0.5, color=color, label=grp, edgecolor='white') ax.set_xlabel(label) ax.set_ylabel('Density') ax.legend()# Panel D: Market cap distributionfor grp, color in [('All Active', '#2C5F8A'), ('Delisted', '#C0392B')]:if grp =='Delisted': data = np.log(delist_chars['market_cap'].dropna())else: data = np.log(all_chars['market_cap'].dropna()) data = data[np.isfinite(data)] axes[1, 1].hist(data, bins=50, density=True, alpha=0.5, color=color, label=grp, edgecolor='white')axes[1, 1].set_xlabel('Log Market Cap')axes[1, 1].set_ylabel('Density')axes[1, 1].legend()plt.suptitle('Characteristics of Delisted vs Active Firms', fontsize=14)plt.tight_layout()plt.show()# Formal comparisonprint("\nMean Comparison (Delisted vs All Active):")for col in ['log_assets', 'roa', 'leverage']: d = delist_chars[col].dropna() a = all_chars[col].dropna() d = d[np.isfinite(d)] a = a[np.isfinite(a)] t, p = stats.ttest_ind(d, a, equal_var=False)print(f" {col:<15}: Delisted = {d.mean():.3f}, "f"Active = {a.mean():.3f}, t = {t:.2f}, p = {p:.4f}")
Hình 13.3
13.4 Thiên kiến sống sót
13.4.1 Định nghĩa và mức độ
Thiên kiến sống sót phát sinh khi một nghiên cứu chỉ sử dụng các doanh nghiệp hiện đang niêm yết (hoặc đang niêm yết vào cuối mẫu), loại trừ các doanh nghiệp đã bị hủy niêm yết trong giai đoạn mẫu. Vì các doanh nghiệp bị hủy niêm yết trước đó chịu lợi nhuận âm một cách không cân xứng, việc loại bỏ chúng làm thổi phồng lợi nhuận trung bình, đánh giá thấp rủi ro và bóp méo các dạng thức mặt cắt ngang.
Chúng ta định lượng mức độ của thiên kiến sống sót bằng cách so sánh lợi nhuận danh mục tính từ mẫu không có thiên kiến sống sót (mọi doanh nghiệp, kể cả những doanh nghiệp sau đó bị hủy niêm yết) với mẫu chỉ gồm doanh nghiệp sống sót (các doanh nghiệp vẫn niêm yết đến cuối mẫu).
Quantify survivorship bias by comparing survivor-only vs full-sample portfolios
# Define survivors: firms active as of 2024-12-31survivors =set( listing_history[listing_history['is_active']]['ticker'])# Full sample: all firms, including delistedfull_sample = monthly_returns.copy()# Survivors only: restrict to firms still listed at end of samplesurvivors_only = monthly_returns[ monthly_returns['ticker'].isin(survivors)].copy()# Compute EW monthly portfolio returnsdef compute_ew_portfolio(df):return ( df .groupby('month_end')['monthly_return'] .mean() .to_frame('portfolio_return') )def compute_vw_portfolio(df):return ( df .groupby('month_end') .apply(lambda g: np.average(g['monthly_return'], weights=g['market_cap'])if g['market_cap'].sum() >0else np.nan) .to_frame('portfolio_return') )ew_full = compute_ew_portfolio(full_sample)ew_survivors = compute_ew_portfolio(survivors_only)vw_full = compute_vw_portfolio(full_sample)vw_survivors = compute_vw_portfolio(survivors_only)# Merge and compute biasbias_ew = pd.merge( ew_full.rename(columns={'portfolio_return': 'full'}), ew_survivors.rename(columns={'portfolio_return': 'survivors'}), left_index=True, right_index=True)bias_ew['bias'] = bias_ew['survivors'] - bias_ew['full']bias_vw = pd.merge( vw_full.rename(columns={'portfolio_return': 'full'}), vw_survivors.rename(columns={'portfolio_return': 'survivors'}), left_index=True, right_index=True)bias_vw['bias'] = bias_vw['survivors'] - bias_vw['full']print("Survivorship Bias (Annualized):")print(f" EW: {bias_ew['bias'].mean() *12:.4f} "f"({bias_ew['bias'].mean() *1200:.1f} bps/year)")print(f" VW: {bias_vw['bias'].mean() *12:.4f} "f"({bias_vw['bias'].mean() *1200:.1f} bps/year)")
Plot cumulative returns: full sample vs survivors only
13.4.3 Thiên kiến sống sót trong các hiện tượng bất thường mặt cắt ngang
Thiên kiến không đồng đều giữa các chiến lược. Các hiện tượng bất thường đặt tỷ trọng lớn vào các doanh nghiệp nhỏ, gặp khó khăn hoặc chất lượng thấp, chính xác là những doanh nghiệp có nhiều khả năng bị hủy niêm yết nhất, chịu ảnh hưởng nghiêm trọng nhất. Chúng ta kiểm định điều này cho các hiện tượng bất thường về quy mô, giá trị và động lượng.
Shumway (1997) đã chỉ ra rằng cách CRSP xử lý lợi nhuận hủy niêm yết, thường ghi nhận chúng là thiếu hoặc bằng không, tạo ra một độ chệch lên có hệ thống trong lợi nhuận trung bình. Vấn đề tương tự tồn tại trong các cơ sở dữ liệu của Việt Nam, nơi mức giá quan sát được cuối cùng có thể đi trước thời điểm hủy niêm yết thực tế vài ngày hoặc vài tuần, và lợi nhuận cuối kỳ thực sự (từ giá giao dịch cuối cùng đến giá trị mà cổ đông thực sự nhận được) không được ghi lại.
Chúng ta triển khai một quy trình điền khuyết lợi nhuận hủy niêm yết được điều chỉnh cho Việt Nam:
Bước 1. Với mỗi doanh nghiệp bị hủy niêm yết, xác định ngày giao dịch cuối cùng có giá đóng cửa hợp lệ.
Bước 2. Phân loại lý do hủy niêm yết để xác định cách điền khuyết phù hợp (Bảng 13.2).
Bước 3. Áp dụng lợi nhuận đã điền khuyết vào tháng hủy niêm yết để hoàn chỉnh chuỗi lợi nhuận.
Implement delisting return imputation for Vietnamese firms
def impute_delisting_returns(listing_df, daily_df, monthly_df):""" Impute terminal returns for delisted firms. Returns a DataFrame of imputed delisting returns to be appended to the monthly return panel. """ delisted_firms = listing_df[listing_df['delisting_date'].notna()].copy() imputed = []for _, firm in delisted_firms.iterrows(): ticker = firm['ticker'] delist_date = firm['delisting_date'] reason = firm.get('reason_category', firm.get('delisting_reason', ''))# Find last trading day firm_daily = daily_df[daily_df['ticker'] == ticker].sort_values('date')iflen(firm_daily) ==0:continue last_trade = firm_daily.iloc[-1] last_price = last_trade['adjusted_close'] last_date = last_trade['date']# Check if last trade is already close to delisting date gap_days = (pd.Timestamp(delist_date) - pd.Timestamp(last_date)).daysif gap_days <0:continue# Data issue# Determine imputation based on reasonif'M&A'instr(reason) or'merger'instr(reason).lower(): imputed_return =0.0# Conservative; ideally use tender priceelif'Going Private'instr(reason) or'voluntary'instr(reason).lower(): imputed_return =0.0elif'Transfer'instr(reason): imputed_return =0.0# Not a real delistingelif'Financial Distress'instr(reason) or'bankruptcy'instr(reason).lower(): imputed_return =-0.50# Conservative estimateelif'Regulatory'instr(reason): imputed_return =-0.30else: imputed_return =-0.30# Default for unknown reasons# Assign to the delisting month delist_month = pd.Timestamp(delist_date).to_period('M').to_timestamp() imputed.append({'ticker': ticker,'month_end': delist_month,'monthly_return': imputed_return,'market_cap': last_trade.get('market_cap', np.nan),'source': 'imputed_delisting','delisting_reason': reason,'gap_days': gap_days })return pd.DataFrame(imputed)# Apply imputationimputed_returns = impute_delisting_returns( delisted.assign(reason_category=delisted['reason_category']), daily_returns, monthly_returns)print(f"Imputed delisting returns: {len(imputed_returns)}")print(f"\nImputed return distribution:")print(imputed_returns['monthly_return'].value_counts().sort_index())
13.5.2 Tác động của việc điền khuyết lợi nhuận hủy niêm yết
Quantify the impact of including imputed delisting returns
# Augmented sample: monthly returns + imputed delisting returnsaugmented = pd.concat([ monthly_returns[['ticker', 'month_end', 'monthly_return', 'market_cap']], imputed_returns[['ticker', 'month_end', 'monthly_return', 'market_cap']]], ignore_index=True)# Compare original vs augmented EW portfoliosew_original = compute_ew_portfolio(monthly_returns)ew_augmented = compute_ew_portfolio(augmented)comparison = pd.merge( ew_original.rename(columns={'portfolio_return': 'original'}), ew_augmented.rename(columns={'portfolio_return': 'augmented'}), left_index=True, right_index=True)comparison['imputation_effect'] = ( comparison['augmented'] - comparison['original'])ann_original = comparison['original'].mean() *12ann_augmented = comparison['augmented'].mean() *12ann_effect = comparison['imputation_effect'].mean() *12print("Delisting Return Imputation Impact:")print(f" EW without imputation: {ann_original:.4f} ({ann_original*100:.2f}%/yr)")print(f" EW with imputation: {ann_augmented:.4f} ({ann_augmented*100:.2f}%/yr)")print(f" Difference: {ann_effect:.4f} ({ann_effect*100:.2f}%/yr)")
13.6 Ngày không giao dịch và khoảng trống thanh khoản
13.6.1 Mức độ phổ biến của ngày không giao dịch
Một đặc điểm nổi bật của dữ liệu chứng khoán Việt Nam là tần suất cao của các ngày không có khối lượng giao dịch (tức là những ngày một cổ phiếu niêm yết không ghi nhận giao dịch nào). Đây không phải dữ liệu “bị thiếu” thực sự theo nghĩa cơ sở dữ liệu (cổ phiếu vẫn niêm yết và giá đóng cửa vẫn được ghi nhận, thường bằng giá đóng cửa trước đó), nhưng chúng đại diện cho thông tin bị thiếu về mặt kinh tế: giá quan sát được đã lỗi thời và không phản ánh điều kiện thị trường hiện tại.
Analyze the prevalence of zero-trading days
# Compute zero-volume fraction per firm-yeardaily_returns['year'] = pd.to_datetime(daily_returns['date']).dt.yeardaily_returns['zero_volume'] = (daily_returns['volume'] ==0).astype(int)zero_vol_fy = ( daily_returns .groupby(['ticker', 'year']) .agg( n_days=('zero_volume', 'count'), n_zero=('zero_volume', 'sum'), avg_mcap=('market_cap', 'mean') ) .reset_index())zero_vol_fy['zero_frac'] = zero_vol_fy['n_zero'] / zero_vol_fy['n_days']fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Distribution over time (boxplot by year)years_to_plot =range(2008, 2025)data_by_year = [ zero_vol_fy[zero_vol_fy['year'] == y]['zero_frac'].dropna().valuesfor y in years_to_plot]bp = axes[0].boxplot(data_by_year, positions=range(len(years_to_plot)), widths=0.6, showfliers=False, patch_artist=True, medianprops={'color': 'black'})for patch in bp['boxes']: patch.set_facecolor('#2C5F8A') patch.set_alpha(0.6)axes[0].set_xticks(range(len(years_to_plot)))axes[0].set_xticklabels(years_to_plot, rotation=45, fontsize=8)axes[0].set_ylabel('Zero-Volume Fraction')axes[0].set_title('Panel A: Zero-Volume Days by Year')# Panel B: By market cap decilezero_vol_fy['mcap_decile'] = pd.qcut( zero_vol_fy['avg_mcap'].rank(method='first'),10, labels=[f'D{i}'for i inrange(1, 11)])decile_zero = ( zero_vol_fy .groupby('mcap_decile')['zero_frac'] .agg(['mean', 'median']))axes[1].bar(range(10), decile_zero['mean'], color='#2C5F8A', alpha=0.85, edgecolor='white')axes[1].set_xticks(range(10))axes[1].set_xticklabels(decile_zero.index)axes[1].set_xlabel('Market Cap Decile (D1 = smallest)')axes[1].set_ylabel('Mean Zero-Volume Fraction')axes[1].set_title('Panel B: Zero-Volume Days by Size')plt.tight_layout()plt.show()
Hình 13.6
13.6.2 Đo lường lợi nhuận trong các giai đoạn không giao dịch
Khi một cổ phiếu không giao dịch, cách tiếp cận tiêu chuẩn, dùng giá đóng cửa gần nhất có sẵn, tạo ra một mức giá lỗi thời làm đánh giá thấp biến động thực và làm chệch lợi nhuận về phía không. Có một số cách tiếp cận để xử lý vấn đề này:
Cách tiếp cận 1: Loại bỏ các quan sát không có khối lượng. Đơn giản nhưng loại bỏ thông tin và gây ra thiên kiến chọn mẫu (nếu việc không giao dịch tương quan với lợi nhuận).
Cách tiếp cận 2: Gộp lãi kép nhiều ngày. Tích lũy lợi nhuận trên toàn bộ khoảng trống không giao dịch và gán nó cho ngày đầu tiên giao dịch trở lại. Cách này bảo toàn tổng lợi nhuận nhưng dồn nó vào một quan sát duy nhất.
Cách tiếp cận 3: Phân bổ đều. Phân bổ đều lợi nhuận tích lũy cho các ngày không có khối lượng. Điều này không thực tế về mặt kinh tế, nhưng làm giảm tác động của các giá trị ngoại lai trong một ngày.
Cách tiếp cận 4: Coi là thiếu và mô hình hóa. Coi các ngày không có khối lượng là lợi nhuận thực sự bị thiếu và dùng thước đo lợi nhuận bằng không của Lesmond (2005) làm đại diện cho thanh khoản.
Implement zero-trading-day return corrections
def correct_zero_volume_returns(daily_df, method='compound'):""" Correct returns during zero-volume periods. Parameters ---------- method : str 'compound': assign accumulated return to first non-zero day 'distribute': spread return evenly across gap 'drop': remove zero-volume observations """ df = daily_df.copy() df = df.sort_values(['ticker', 'date']) df['daily_return'] = ( df.groupby('ticker')['adjusted_close'] .pct_change() )if method =='drop':return df[df['volume'] >0]elif method =='compound':# For each zero-volume streak, accumulate return and# assign to the next trading day results = []for ticker, group in df.groupby('ticker'): group = group.sort_values('date').reset_index(drop=True) accumulated =0 gap_length =0for idx, row in group.iterrows():if row['volume'] ==0: accumulated += row['daily_return'] if pd.notna(row['daily_return']) else0 gap_length +=1else:if gap_length >0:# Add accumulated return to this day's return total_return = (1+ accumulated) * (1+ (row['daily_return'] or0)) -1 group.loc[idx, 'daily_return'] = total_return accumulated =0 gap_length =0 results.append(group.loc[idx])# If series ends with zero-volume days, include last non-zeroif gap_length >0andlen(results) >0: last_valid = results[-1].copy() last_valid['daily_return'] = ( (1+ last_valid['daily_return']) * (1+ accumulated) -1 ) results[-1] = last_validreturn pd.DataFrame(results)elif method =='distribute': results = []for ticker, group in df.groupby('ticker'): group = group.sort_values('date').reset_index(drop=True) i =0while i <len(group):if group.loc[i, 'volume'] >0: results.append(group.loc[i]) i +=1else:# Find end of zero-volume streak j = iwhile j <len(group) and group.loc[j, 'volume'] ==0: j +=1# Total return over gapif j <len(group): total_ret = ( group.loc[j, 'adjusted_close']/ group.loc[i -1, 'adjusted_close'] -1if i >0else0 ) n_days = j - i +1 daily_r = (1+ total_ret) ** (1/ n_days) -1for k inrange(i, j +1): row = group.loc[k].copy() row['daily_return'] = daily_r results.append(row) i = j +1return pd.DataFrame(results)# Apply corrections and comparefor method in ['drop', 'compound', 'distribute']: corrected = correct_zero_volume_returns( daily_returns.head(500000), method=method ) mean_ret = corrected['daily_return'].mean() *252 vol = corrected['daily_return'].std() * np.sqrt(252)print(f"{method:<12}: Ann. Return = {mean_ret:.4f}, "f"Ann. Vol = {vol:.4f}, N = {len(corrected):,}")
13.7 Thiên kiến nhìn trước
13.7.1 Định nghĩa
Thiên kiến nhìn trước xảy ra khi một nghiên cứu sử dụng thông tin chưa có sẵn tại thời điểm đưa ra quyết định đầu tư. Trong bối cảnh Việt Nam, các nguồn phổ biến nhất là:
Điều kiện hóa theo sự sống sót. Chọn các doanh nghiệp dựa trên trạng thái niêm yết vào cuối mẫu ngầm sử dụng thông tin tương lai (liệu doanh nghiệp có bị hủy niêm yết hay không).
Sử dụng dữ liệu tài chính đã điều chỉnh. Các doanh nghiệp Việt Nam thường điều chỉnh lại báo cáo tài chính sau kiểm toán. Dùng các số liệu đã điều chỉnh thay vì các số liệu công bố ban đầu gây ra thiên kiến nhìn trước.
Thiên kiến điền lùi. Khi một cơ sở dữ liệu bổ sung một doanh nghiệp mới, nó có thể điền lùi dữ liệu lịch sử, tạo ra ảo giác rằng doanh nghiệp đã có thể được chọn trước ngày niêm yết thực tế của nó.
Dữ liệu kế toán theo thời điểm công bố. Dùng dữ liệu tài chính hằng năm tại thời điểm kết thúc năm tài chính thay vì ngày báo cáo tài chính được công bố công khai là giả định rằng dữ liệu có sẵn ngay lập tức.
13.7.2 Điều chỉnh theo thời điểm công bố
Chúng ta triển khai một phép điều chỉnh theo thời điểm công bố đối với dữ liệu kế toán, tôn trọng độ trễ báo cáo thực tế:
Implement point-in-time alignment of accounting data
def point_in_time_merge(monthly_df, fundamentals_df, filings_df, lag_months=0):""" Merge accounting data with monthly returns respecting the actual filing date (point-in-time). Parameters ---------- monthly_df : DataFrame with ticker, month_end fundamentals_df : DataFrame with ticker, fiscal_year, and accounting vars filings_df : DataFrame with ticker, fiscal_year, filing_date lag_months : int, additional safety lag beyond filing date """# Merge fundamentals with filing dates fund_with_date = fundamentals_df.merge( filings_df[['ticker', 'fiscal_year', 'filing_date']], on=['ticker', 'fiscal_year'], how='left' )# If filing date is missing, assume available 4 months after FY end fund_with_date['filing_date'] = pd.to_datetime( fund_with_date['filing_date'] ) fund_with_date['fy_end'] = pd.to_datetime( fund_with_date['fiscal_year'].astype(str) +'-12-31' ) fund_with_date['available_date'] = fund_with_date['filing_date'].fillna( fund_with_date['fy_end'] + pd.DateOffset(months=4) )# Add safety lagif lag_months >0: fund_with_date['available_date'] += pd.DateOffset(months=lag_months)# For each firm-month, find the most recent accounting data# that was available (filing_date <= month_end) results = []for _, row in monthly_df.iterrows(): ticker = row['ticker'] month = row['month_end'] available = fund_with_date[ (fund_with_date['ticker'] == ticker) & (fund_with_date['available_date'] <= month) ]iflen(available) >0: latest = available.sort_values('fiscal_year').iloc[-1] result = row.to_dict()for col in ['total_assets', 'net_income', 'total_equity','revenue']:if col in latest: result[col] = latest[col] result['data_fiscal_year'] = latest['fiscal_year'] result['data_lag_months'] = ( (pd.Timestamp(month) - pd.Timestamp(latest['available_date'])) .days /30.44 ) results.append(result)return pd.DataFrame(results)# Example: compare point-in-time vs naive mergefilings = client.get_filings( exchanges=['HOSE', 'HNX'], report_types=['annual'], fields=['ticker', 'fiscal_year', 'filing_date'])print("Point-in-time merge vs naive merge:")print(" Naive: use fiscal year directly (introduces look-ahead bias)")print(" PIT: use only data available as of the portfolio formation date")
13.7.3 Định lượng thiên kiến nhìn trước trong các chiến lược giá trị
Các chiến lược giá trị phân loại cổ phiếu theo tỷ số giá trị sổ sách trên giá trị thị trường tính từ dữ liệu kế toán. Việc dùng dữ liệu cuối năm tài chính mà không tôn trọng độ trễ báo cáo làm thổi phồng phần bù giá trị vì nó ngầm sử dụng thông tin chưa được công bố công khai.
Compare value premium with and without point-in-time adjustment
# Naive approach: use fiscal year data immediatelymonthly_naive = monthly_returns.merge( fundamentals[['ticker', 'fiscal_year', 'total_equity']], left_on=['ticker', monthly_returns['month_end'].dt.year], right_on=['ticker', 'fiscal_year'], how='left')monthly_naive['bm_naive'] = ( monthly_naive['total_equity'] / monthly_naive['market_cap'])# Point-in-time approach (using 4-month lag as conservative default)monthly_pit = monthly_returns.copy()monthly_pit['bm_pit'] = np.nan # Would be filled by point_in_time_merge# For demonstration: approximate PIT by using t-1 fiscal year data# (ensures data were available at formation date)fund_lagged = fundamentals.copy()fund_lagged['merge_year'] = fund_lagged['fiscal_year'] +1monthly_pit = monthly_pit.merge( fund_lagged[['ticker', 'merge_year', 'total_equity']].rename( columns={'merge_year': 'year'}), left_on=['ticker', monthly_pit['month_end'].dt.year], right_on=['ticker', 'year'], how='left')monthly_pit['bm_pit'] = ( monthly_pit['total_equity'] / monthly_pit['market_cap'])# Compute HML for both approacheshml_naive = compute_long_short(monthly_naive, 'bm_naive')hml_pit = compute_long_short(monthly_pit, 'bm_pit')ann_naive = hml_naive['long_short'].mean() *12ann_pit = hml_pit['long_short'].mean() *12print("Value Premium (HML):")print(f" Naive (look-ahead): {ann_naive:.4f} ({ann_naive*100:.2f}%/yr)")print(f" Point-in-time: {ann_pit:.4f} ({ann_pit*100:.2f}%/yr)")print(f" Look-ahead inflation: {(ann_naive - ann_pit)*100:.2f}%/yr")
13.8 Chuyển sàn và sự gián đoạn mã chứng khoán
13.8.1 Vấn đề chuyển sàn
Các doanh nghiệp Việt Nam thường xuyên chuyển giữa các sàn (ví dụ: từ HNX sang HOSE khi đáp ứng điều kiện niêm yết của HOSE, hoặc từ HOSE sang UPCoM/HNX sau các vấn đề về quy định). Những lần chuyển sàn này có thể làm gián đoạn tính liên tục của chuỗi lợi nhuận nếu cơ sở dữ liệu coi mỗi lần niêm yết trên một sàn là một thực thể riêng biệt.
def link_transfer_returns(monthly_df, transfers_df):""" Link return series across exchange transfers to create continuous firm-level return histories. """# Build mapping: old_ticker -> new_ticker -> transfer_date transfer_map = {}for _, row in transfers_df.iterrows(): old_ticker = row.get('transfer_from_ticker', row['ticker']) new_ticker = row['ticker'] transfer_date = row['transfer_date']if old_ticker and new_ticker and old_ticker != new_ticker: transfer_map[old_ticker] = {'new_ticker': new_ticker,'date': transfer_date }# Create unified ticker mapping df = monthly_df.copy() df['unified_ticker'] = df['ticker']for old_t, info in transfer_map.items(): mask = ( (df['ticker'] == old_t) & (df['month_end'] < info['date']) ) df.loc[mask, 'unified_ticker'] = info['new_ticker'] n_linked =sum(1for t in transfer_map if t in df['ticker'].values)print(f"Linked {n_linked} transfer pairs")return dflinked = link_transfer_returns(monthly_returns, transfers)
13.9 Khung phân tích độ nhạy
13.9.1 Kết quả của bạn mong manh đến mức nào?
Thay vì chọn một cách tiếp cận duy nhất để xử lý dữ liệu bị thiếu, một nghiên cứu vững chắc kiểm tra mức độ nhạy của các kết luận với các giả định khác nhau. Chúng ta triển khai một khung phân tích độ nhạy có hệ thống, chạy lại một phân tích cho trước dưới nhiều giả định xử lý dữ liệu.
Implement sensitivity analysis for missing data assumptions
def sensitivity_analysis(monthly_df, listing_df, sort_variable, compute_fn):""" Run an analysis under multiple data treatment assumptions. Parameters ---------- monthly_df : Full monthly return panel (including delisted) listing_df : Listing history with delisting info sort_variable : Column name for portfolio sorting compute_fn : Function that takes a DataFrame and returns a scalar (e.g., annualized long-short return) Returns ------- DataFrame with results under each assumption """ survivors =set(listing_df[listing_df['is_active']]['ticker']) results = {}# 1. Survivors only (maximum bias) surv_only = monthly_df[monthly_df['ticker'].isin(survivors)] results['Survivors Only'] = compute_fn(surv_only, sort_variable)# 2. Full sample, no delisting imputation results['Full Sample (no imputation)'] = compute_fn( monthly_df, sort_variable )# 3. Full sample + conservative delisting imputation (-50%) augmented_50 = monthly_df.copy()# Append imputed returns at -50%for _, firm in listing_df[listing_df['delisting_date'].notna()].iterrows():if'Financial Distress'instr(firm.get('reason_category', '')): augmented_50 = pd.concat([augmented_50, pd.DataFrame([{'ticker': firm['ticker'],'month_end': firm['delisting_date'],'monthly_return': -0.50,'market_cap': np.nan, sort_variable: np.nan }])], ignore_index=True) results['Full + Impute -50%'] = compute_fn( augmented_50, sort_variable )# 4. Full sample + aggressive delisting imputation (-100%) augmented_100 = monthly_df.copy()for _, firm in listing_df[listing_df['delisting_date'].notna()].iterrows():if'Financial Distress'instr(firm.get('reason_category', '')): augmented_100 = pd.concat([augmented_100, pd.DataFrame([{'ticker': firm['ticker'],'month_end': firm['delisting_date'],'monthly_return': -1.00,'market_cap': np.nan, sort_variable: np.nan }])], ignore_index=True) results['Full + Impute -100%'] = compute_fn( augmented_100, sort_variable )# 5. Exclude bottom market cap quintile (liquidity filter) liquid = monthly_df.copy() liquid['mcap_quintile'] = ( liquid.groupby('month_end')['market_cap'] .transform(lambda x: pd.qcut(x, 5, labels=False, duplicates='drop')) ) liquid = liquid[liquid['mcap_quintile'] >0] results['Exclude Bottom Quintile'] = compute_fn( liquid, sort_variable )return pd.DataFrame.from_dict(results, orient='index', columns=['Result'])# Example: sensitivity of size premiumdef compute_size_premium(df, sort_var): ls = compute_long_short(df, sort_var, n_quantiles=5)return ls['long_short'].mean() *12iflen(ls) >0else np.nan# Would need sort variable in the data; illustrative call:# sensitivity_results = sensitivity_analysis(# monthly_with_chars, listing_history, 'log_mcap',# compute_size_premium# )
Visualize sensitivity of anomaly premia to data treatment
# Illustrative: create synthetic sensitivity results for plottingassumptions = ['Survivors Only','Full Sample\n(no imputation)','Full +\nImpute -30%','Full +\nImpute -50%','Full +\nImpute -100%','Exclude Bottom\nMcap Quintile']# Hypothetical results (would be computed from actual data)size_premium = [0.08, 0.06, 0.055, 0.05, 0.04, 0.07]value_premium = [0.07, 0.065, 0.063, 0.06, 0.055, 0.068]momentum_premium = [0.10, 0.08, 0.075, 0.07, 0.06, 0.09]fig, ax = plt.subplots(figsize=(14, 6))x = np.arange(len(assumptions))width =0.25ax.bar(x - width, [s *100for s in size_premium], width, color='#2C5F8A', alpha=0.85, label='Size (SMB)')ax.bar(x, [v *100for v in value_premium], width, color='#27AE60', alpha=0.85, label='Value (HML)')ax.bar(x + width, [m *100for m in momentum_premium], width, color='#E67E22', alpha=0.85, label='Momentum (WML)')ax.set_xticks(x)ax.set_xticklabels(assumptions, fontsize=9)ax.set_ylabel('Annualized Premium (%)')ax.set_title('Anomaly Premium Sensitivity to Data Treatment')ax.legend()ax.axhline(y=0, color='gray', linewidth=0.5)plt.tight_layout()plt.show()
Hình 13.7
13.10 Khuyến nghị thực tiễn
Dựa trên phân tích trong chương này, chúng tôi đưa ra các khuyến nghị sau cho các nhà nghiên cứu làm việc với dữ liệu chứng khoán Việt Nam:
1. Luôn sử dụng cơ sở dữ liệu không có thiên kiến sống sót. Khi truy vấn DataCore.vn (hoặc bất kỳ cơ sở dữ liệu nào), hãy yêu cầu tường minh include_delisted=True. Không bao giờ điều kiện hóa theo trạng thái niêm yết cuối mẫu khi xây dựng các tập hợp đầu tư.
2. Điền khuyết lợi nhuận hủy niêm yết. Với các vụ hủy niêm yết không tự nguyện (khó khăn tài chính, cưỡng chế quy định), hãy điền khuyết lợi nhuận cuối kỳ từ −30% đến −50% vào tháng hủy niêm yết. Báo cáo kết quả trên một dải các giả định điền khuyết như một kiểm tra độ vững. Với các vụ hủy niêm yết tự nguyện và chuyển sàn, điền khuyết 0%.
3. Tôn trọng tính sẵn có của dữ liệu theo thời điểm công bố. Chỉ sử dụng dữ liệu kế toán sau ngày công bố công khai, không phải tại thời điểm kết thúc năm tài chính. Ở Việt Nam, độ trễ chuẩn là 90 ngày đối với báo cáo thường niên; hãy dùng độ trễ thận trọng 4-6 tháng.
4. Xử lý tường minh các ngày không có khối lượng. Ghi nhận mức độ phổ biến của các ngày không có khối lượng trong mẫu của bạn. Với lợi nhuận hằng tháng, hãy báo cáo số ngày không có khối lượng trung bình trên mỗi cặp doanh nghiệp–tháng. Cân nhắc loại các doanh nghiệp có tỷ lệ ngày không có khối lượng vượt quá 50% khỏi tập hợp đầu tư.
5. Liên kết các lần chuyển sàn. Sử dụng các mã chứng khoán thống nhất để liên kết chuỗi trước và sau khi chuyển sàn. Nếu không, các lần chuyển sàn sẽ xuất hiện như những vụ hủy niêm yết và niêm yết mới đồng thời, làm thổi phồng số lượt niêm yết mới và hủy niêm yết và làm chệch các phép tính sống sót.
6. Báo cáo phân tích độ nhạy. Với bất kỳ phát hiện chính nào, hãy báo cáo kết quả dưới ít nhất ba giả định xử lý dữ liệu: chỉ gồm doanh nghiệp sống sót (cận trên), mẫu đầy đủ với điền khuyết vừa phải (cơ sở), và mẫu đầy đủ với điền khuyết mạnh cộng bộ lọc thanh khoản (cận dưới). Nếu phát hiện đứng vững trong cả ba trường hợp, thì đó là một phát hiện vững.
7. Đặc biệt thận trọng với dữ liệu trước năm 2007. Thị trường Việt Nam có chưa đến 100 cổ phiếu niêm yết trước năm 2006, và làn sóng cổ phần hóa 2006-2009 tạo ra một nhóm doanh nghiệp có đặc điểm khác biệt một cách có hệ thống so với các doanh nghiệp niêm yết trước đó. Các kiểm định mặt cắt ngang với dữ liệu trước năm 2007 có sức mạnh kiểm định rất thấp và cần được diễn giải hết sức thận trọng.
13.11 Tóm tắt
Bảng 13.3: Tóm tắt các vấn đề dữ liệu, tác động của chúng và các hiệu chỉnh khuyến nghị.
Vấn đề dữ liệu
Hướng thiên kiến
Mức độ (Việt Nam)
Cách khắc phục khuyến nghị
Thiên kiến sống sót (EW)
Chệch lên đối với lợi nhuận
~1-3% mỗi năm
Đưa vào mọi doanh nghiệp bị hủy niêm yết
Thiên kiến sống sót (VW)
Chệch lên (nhỏ hơn)
~0.2-0.5% mỗi năm
Đưa vào mọi doanh nghiệp bị hủy niêm yết
Thiên kiến lợi nhuận hủy niêm yết
Chệch lên đối với lợi nhuận
~0.5–2% mỗi năm (EW)
Điền khuyết lợi nhuận cuối kỳ
Thiên kiến nhìn trước
Thổi phồng khả năng dự báo
Thay đổi theo chiến lược
Căn chỉnh dữ liệu theo thời điểm công bố
Ngày không giao dịch
Đánh giá thấp biến động
Nghiêm trọng với cổ phiếu vốn hóa nhỏ
Gộp lãi kép hoặc loại bỏ; ghi nhận
Chuyển sàn
Tạo ra các vụ hủy niêm yết giả
~50-100 doanh nghiệp
Liên kết các mã chứng khoán thống nhất
Thiên kiến niêm yết mới
Giai đoạn đầu của mẫu không mang tính đại diện
Cực đoan trước năm 2007
Bắt đầu mẫu sau năm 2007
Thông điệp trung tâm là các vấn đề dữ liệu trong nghiên cứu chứng khoán Việt Nam không chỉ là phiền toái–chúng có thể tạo ra các thiên kiến có ý nghĩa kinh tế, làm thay đổi kết luận của các nghiên cứu thực nghiệm. Riêng thiên kiến sống sót đã vượt quá 100 điểm cơ bản mỗi năm đối với các danh mục trọng số bằng nhau, tương đương với nhiều phần bù hiện tượng bất thường đã được ghi nhận. Những nhà nghiên cứu bỏ qua các vấn đề này có nguy cơ báo cáo những kết quả phản ánh các đặc điểm giả tạo do dữ liệu thay vì các hiện tượng kinh tế thực sự.
Lesmond, David A. 2005. “Liquidity of emerging markets”. Journal of financial economics 77 (2): 411–52.
Little, Roderick JA, và Donald B Rubin. 2019. Statistical analysis with missing data. John Wiley & Sons.
Rubin, Donald B. 1976. “Inference and missing data”. Biometrika 63 (3): 581–92.
# Dữ liệu bị thiếu và thiên kiến sống sót::: callout-noteTrong chương này, chúng ta ghi nhận các dạng dữ liệu bị thiếu, thiên kiến sống sót và thiên kiến hủy niêm yết trên thị trường chứng khoán Việt Nam, xây dựng các công cụ chẩn đoán để phát hiện những vấn đề này, và triển khai các phương pháp hiệu chỉnh nhằm cho ra kết quả thực nghiệm đáng tin cậy hơn.:::Mọi nghiên cứu thực nghiệm trong tài chính đều ngầm giả định rằng dữ liệu được phân tích đại diện cho tổng thể mà nghiên cứu tuyên bố khảo sát. Khi giả định này không còn đúng, vì các doanh nghiệp bị hủy niêm yết bị loại bỏ, vì cơ sở dữ liệu chỉ bắt đầu bao phủ sau khi doanh nghiệp đã sống sót, hoặc vì các khoảng trống giao dịch tạo ra các quan sát lợi nhuận bị thiếu, thì các ước lượng thu được sẽ bị chệch. Trong bối cảnh Hoa Kỳ, @shumway1997delisting đã chỉ ra rằng việc bỏ qua lợi nhuận hủy niêm yết làm chệch lợi nhuận trung bình lên khoảng 1% mỗi năm đối với cổ phiếu NYSE và cao hơn đáng kể đối với cổ phiếu Nasdaq, với những hệ quả nghiêm trọng cho các chiến lược dựa trên hiện tượng bất thường vốn đặt tỷ trọng lớn vào các doanh nghiệp nhỏ, gặp khó khăn tài chính.Thị trường Việt Nam đặt ra một loạt thách thức về tính toàn vẹn dữ liệu rất đặc thù và, theo nhiều khía cạnh, gay gắt hơn. Thị trường còn non trẻ. HOSE khai trương vào tháng 7 năm 2000 chỉ với hai cổ phiếu niêm yết, và số lượng niêm yết tăng nhanh qua làn sóng cổ phần hóa giữa những năm 2000. Điều này có nghĩa là bất kỳ mẫu nào bắt đầu trước khoảng năm 2007 đều chịu thiên kiến niêm yết mới nghiêm trọng: mặt cắt ngang ban đầu rất nhỏ và không mang tính đại diện. Việc hủy niêm yết diễn ra phổ biến và thường là không tự nguyện, do thua lỗ vượt quá vốn điều lệ, không nộp báo cáo tài chính, hoặc do các biện pháp cưỡng chế của UBCKNN thay vì do sáp nhập hay chuyển sang sở hữu tư nhân như ở Hoa Kỳ. Những vụ hủy niêm yết không tự nguyện này gắn với lợi nhuận cuối kỳ âm một cách có hệ thống. Và tình trạng phổ biến của các ngày không giao dịch ở nhóm cổ phiếu vốn hóa nhỏ tạo ra các khoảng trống lợi nhuận trông giống dữ liệu bị thiếu nhưng thực chất phản ánh tính kém thanh khoản.Chương này cung cấp các công cụ để chẩn đoán và, khi có thể, hiệu chỉnh những vấn đề này.## Phân loại các vấn đề dữ liệu {#sec-missing-taxonomy}Dữ liệu bị thiếu trong nghiên cứu tài chính không phải là một khối đồng nhất. Hệ quả phụ thuộc rất lớn vào *cơ chế* tạo ra sự thiếu hụt. @rubin1976inference và @little2019statistical phân loại dữ liệu bị thiếu thành ba loại:1. **Thiếu hoàn toàn ngẫu nhiên (MCAR).** Xác suất một quan sát bị thiếu không phụ thuộc vào bất kỳ biến quan sát được hay không quan sát được nào. Ví dụ: máy chủ của nhà cung cấp dữ liệu gặp sự cố vào một ngày thứ Ba ngẫu nhiên, làm mất các bản ghi của ngày đó. MCAR là trường hợp lành tính nhất, phân tích trên các quan sát đầy đủ (loại bỏ các quan sát bị thiếu) cho ước lượng không chệch nhưng kém hiệu quả hơn.2. **Thiếu ngẫu nhiên (MAR).** Xác suất thiếu phụ thuộc vào các biến quan sát được nhưng không phụ thuộc vào chính giá trị bị thiếu, khi đã điều kiện hóa theo các biến quan sát được. Ví dụ: các doanh nghiệp nhỏ có nhiều khả năng thiếu sự theo dõi của các nhà phân tích hơn, nhưng khi điều kiện hóa theo quy mô doanh nghiệp, việc thiếu sự theo dõi hay không không liên quan đến lợi nhuận kỳ vọng thực sự của doanh nghiệp. MAR cho phép ước lượng không chệch thông qua các phương pháp điều kiện hóa theo các biến dự báo quan sát được của sự thiếu hụt.3. **Thiếu không ngẫu nhiên (MNAR).** Xác suất thiếu phụ thuộc vào chính giá trị bị thiếu. Ví dụ: các doanh nghiệp có kết quả kém nhất có nhiều khả năng bị hủy niêm yết và biến mất khỏi cơ sở dữ liệu nhất. MNAR là một trường hợp bệnh lý và, đáng tiếc, là phổ biến nhất trong dữ liệu tài chính. Thiên kiến sống sót và thiên kiến hủy niêm yết đều là các trường hợp của MNAR vì sự kiện loại bỏ quan sát (hủy niêm yết) tương quan với biến quan tâm (lợi nhuận).Trong bối cảnh Việt Nam, chúng ta gặp cả ba loại, thường là đồng thời (@tbl-missing-taxonomy).| Vấn đề dữ liệu | Loại thiếu hụt | Cơ chế ở Việt Nam ||------------------------|------------------------|------------------------|| Ngày không giao dịch | MAR/MNAR | Cổ phiếu nhỏ/kém thanh khoản; tương quan với lợi nhuận || Chạm biên độ giá | MNAR | Lợi nhuận thực bị cắt tại biên độ; lợi nhuận quan sát bị kiểm duyệt || Hủy niêm yết | MNAR | Các doanh nghiệp kém nhất rời thị trường; lợi nhuận biến mất || Bao phủ niêm yết muộn | Thiên kiến chọn mẫu | Cơ sở dữ liệu bắt đầu sau khi doanh nghiệp đã sống sót qua giai đoạn đầu || Chuyển sàn | Hành chính | Chuyển HOSE→HNX hoặc UPCoM làm gián đoạn tính liên tục của mã chứng khoán || Đình chỉ giao dịch | MNAR | Đình chỉ đi trước các sự kiện tiêu cực; lợi nhuận bị thiếu |: Phân loại dữ liệu bị thiếu trong các cơ sở dữ liệu chứng khoán Việt Nam {#tbl-missing-taxonomy}## Xây dựng dữ liệu {#sec-missing-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 smimport statsmodels.formula.api as smffrom scipy import statsfrom datetime import datetime, timedeltaimport 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 comprehensive listing, delisting, and return data"from datacore import DataCoreClientclient = DataCoreClient()# Complete listing history: includes all firms ever listed, not just currentlisting_history = client.get_listing_history( exchanges=['HOSE', 'HNX', 'UPCoM'], include_delisted=True, fields=['ticker', 'company_name', 'exchange', 'listing_date','delisting_date', 'delisting_reason', 'is_active','transfer_from', 'transfer_to', 'transfer_date','ipo_date', 'equitization_date', 'sector' ])# Daily returns: includes delisted firms' full historydaily_returns = client.get_daily_prices( exchanges=['HOSE', 'HNX', 'UPCoM'], start_date='2000-07-28', # HOSE opening date end_date='2024-12-31', include_delisted=True, # Critical flag fields=['ticker', 'date', 'close', 'adjusted_close', 'volume','turnover_value', 'market_cap', 'shares_outstanding','price_limit_hit'# +1 = limit up, -1 = limit down, 0 = neither ])# Monthly returns (pre-computed, survivorship-bias-free)monthly_returns = client.get_monthly_returns( exchanges=['HOSE', 'HNX', 'UPCoM'], start_date='2000-07-28', end_date='2024-12-31', include_delisted=True, fields=['ticker', 'month_end', 'monthly_return', 'market_cap','volume_avg_20d', 'n_trading_days', 'n_zero_volume_days' ])print(f"Listing history: {listing_history.shape[0]:,} firms")print(f" Active: {listing_history['is_active'].sum():,}")print(f" Delisted: {(~listing_history['is_active']).sum():,}")print(f"Daily observations: {daily_returns.shape[0]:,}")print(f"Monthly observations: {monthly_returns.shape[0]:,}")```## Diễn biến niêm yết ở Việt Nam {#sec-missing-listing-dynamics}### Sự tăng trưởng của thị trường Việt NamLịch sử ngắn của thị trường chứng khoán Việt Nam tạo ra một dạng thức đặc trưng: tập hợp đầu tư tăng từ gần bằng không lên hơn 1,500 doanh nghiệp niêm yết trong khoảng hai thập kỷ. Sự tăng trưởng nhanh này có nghĩa là cấu thành của thị trường tại bất kỳ thời điểm nào cũng chịu ảnh hưởng nặng nề của thời điểm niêm yết, và các nghiên cứu dùng dữ liệu ban đầu phải đối mặt với các vấn đề mẫu nhỏ cực đoan.```{python}#| label: fig-listing-growth#| eval: false#| fig-cap: "Diễn biến của tập hợp cổ phiếu niêm yết Việt Nam. Panel A cho thấy số lượng niêm yết đang hoạt động trên mỗi sàn theo thời gian. Panel B cho thấy dòng niêm yết mới và hủy niêm yết hằng năm. Làn sóng cổ phần hóa giai đoạn 2006--2009 tạo ra một đợt IPO dồn dập, nhiều doanh nghiệp trong số đó sau đó bị hủy niêm yết trong giai đoạn thị trường suy thoái."#| code-summary: "Plot the growth of the Vietnamese listed universe"listing_history['listing_date'] = pd.to_datetime(listing_history['listing_date'])listing_history['delisting_date'] = pd.to_datetime(listing_history['delisting_date'])# Count active listings at each month-endmonths = pd.date_range('2000-07-01', '2024-12-31', freq='M')active_counts = []for month in months:for exchange in ['HOSE', 'HNX', 'UPCoM']: active = listing_history[ (listing_history['exchange'] == exchange) & (listing_history['listing_date'] <= month) & ((listing_history['delisting_date'].isna()) | (listing_history['delisting_date'] > month)) ] active_counts.append({'month': month,'exchange': exchange,'n_active': len(active) })active_df = pd.DataFrame(active_counts)fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Active listings over timefor exchange, color in [('HOSE', '#2C5F8A'), ('HNX', '#E67E22'), ('UPCoM', '#27AE60')]: subset = active_df[active_df['exchange'] == exchange] axes[0].plot(subset['month'], subset['n_active'], color=color, linewidth=2, label=exchange)axes[0].set_xlabel('Date')axes[0].set_ylabel('Number of Active Listings')axes[0].set_title('Panel A: Active Listings by Exchange')axes[0].legend()# Panel B: Annual listings and delistingslisting_history['listing_year'] = listing_history['listing_date'].dt.yearlisting_history['delisting_year'] = listing_history['delisting_date'].dt.yearannual_listings = ( listing_history .groupby('listing_year') .size() .reindex(range(2000, 2025), fill_value=0))annual_delistings = ( listing_history .dropna(subset=['delisting_year']) .groupby('delisting_year') .size() .reindex(range(2000, 2025), fill_value=0))x = np.arange(2000, 2025)axes[1].bar(x -0.2, annual_listings.values, width=0.4, color='#27AE60', alpha=0.85, label='New Listings')axes[1].bar(x +0.2, annual_delistings.values, width=0.4, color='#C0392B', alpha=0.85, label='Delistings')axes[1].set_xlabel('Year')axes[1].set_ylabel('Number of Firms')axes[1].set_title('Panel B: Annual Listings and Delistings')axes[1].legend()plt.tight_layout()plt.show()```### Các lý do hủy niêm yếtCác vụ hủy niêm yết ở Việt Nam không đồng nhất. UBCKNN bắt buộc hủy niêm yết đối với các vi phạm quy định cụ thể, nhưng doanh nghiệp cũng có thể tự nguyện hủy niêm yết, sáp nhập hoặc chuyển giữa các sàn. Lý do hủy niêm yết quan trọng vì nó quyết định lợi nhuận cuối kỳ có khả năng xảy ra.```{python}#| label: delisting-reasons#| eval: false#| code-summary: "Categorize and tabulate delisting reasons"delisted = listing_history[listing_history['delisting_date'].notna()].copy()# Standardize delisting reasons into categoriesreason_map = {'losses_exceed_charter': 'Involuntary - Financial Distress','bankruptcy': 'Involuntary - Financial Distress','failure_to_file': 'Involuntary - Regulatory','audit_qualification': 'Involuntary - Regulatory','ssc_enforcement': 'Involuntary - Regulatory','merger': 'Voluntary - M&A','going_private': 'Voluntary - Going Private','transfer_exchange': 'Transfer','voluntary': 'Voluntary - Other','other': 'Other/Unknown'}delisted['reason_category'] = ( delisted['delisting_reason'] .map(reason_map) .fillna('Other/Unknown'))# Tabulatereason_counts = ( delisted['reason_category'] .value_counts() .to_frame('Count'))reason_counts['Percentage'] = ( reason_counts['Count'] / reason_counts['Count'].sum() *100)print("Delisting Reasons:")print(reason_counts.round(1).to_string())``````{python}#| label: fig-delisting-reasons#| eval: false#| fig-cap: "Phân phối các lý do hủy niêm yết của các doanh nghiệp niêm yết Việt Nam. Khác với thị trường Hoa Kỳ nơi các vụ hủy niêm yết do M&A chiếm ưu thế, các vụ hủy niêm yết ở Việt Nam chủ yếu là không tự nguyện, do khó khăn tài chính và việc thực thi quy định. Cơ cấu này làm thiên kiến hủy niêm yết nghiêm trọng hơn vì các vụ hủy niêm yết không tự nguyện gắn với lợi nhuận cuối kỳ âm lớn."#| code-summary: "Visualize delisting reasons"fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Pie chartcolors_pie = ['#C0392B', '#E74C3C', '#8E44AD', '#27AE60','#2C5F8A', '#F1C40F', '#BDC3C7']axes[0].pie(reason_counts['Count'], labels=reason_counts.index, colors=colors_pie[:len(reason_counts)], autopct='%1.0f%%', startangle=90, textprops={'fontsize': 8})axes[0].set_title('Panel A: Delisting Reasons')# Panel B: Delisting reasons over timedelisted['year'] = delisted['delisting_date'].dt.yearreason_by_year = pd.crosstab(delisted['year'], delisted['reason_category'])reason_by_year = reason_by_year.reindex(range(2000, 2025), fill_value=0)reason_by_year.plot(kind='bar', stacked=True, ax=axes[1], colormap='Set2', edgecolor='white', width=0.8)axes[1].set_xlabel('Year')axes[1].set_ylabel('Number of Delistings')axes[1].set_title('Panel B: Delisting Reasons Over Time')axes[1].legend(fontsize=7, loc='upper left')plt.tight_layout()plt.show()```### Đặc điểm doanh nghiệp tại thời điểm hủy niêm yếtCác doanh nghiệp bị hủy niêm yết có khác biệt một cách có hệ thống so với các doanh nghiệp sống sót hay không? Nếu có, việc loại bỏ chúng làm chệch phân phối quan sát được của các đặc điểm doanh nghiệp.```{python}#| label: fig-delisting-characteristics#| eval: false#| fig-cap: "Đặc điểm doanh nghiệp trong năm trước khi hủy niêm yết so với tất cả các doanh nghiệp đang hoạt động. Các doanh nghiệp bị hủy niêm yết nhỏ hơn, kém sinh lời hơn, có đòn bẩy cao hơn và kém thanh khoản hơn một cách có hệ thống. Những khác biệt này xác nhận rằng việc hủy niêm yết không ngẫu nhiên (MCAR) mà tương quan với các biến được dùng trong các kiểm định định giá tài sản thực nghiệm."#| code-summary: "Compare characteristics of delisted vs surviving firms"# Get fundamentals in the last available year before delistinglast_year_delisted = ( delisted[['ticker', 'delisting_date']] .assign(last_fy=lambda x: x['delisting_date'].dt.year -1))fundamentals = client.get_fundamentals( exchanges=['HOSE', 'HNX', 'UPCoM'], start_date='2005-01-01', end_date='2024-12-31', include_delisted=True, fields=['ticker', 'fiscal_year', 'total_assets', 'net_income','total_equity', 'revenue', 'market_cap' ])# Characteristics of delisted firms (last year before delisting)delist_chars = ( last_year_delisted .merge(fundamentals.rename(columns={'fiscal_year': 'last_fy'}), on=['ticker', 'last_fy'], how='inner'))delist_chars['roa'] = delist_chars['net_income'] / delist_chars['total_assets']delist_chars['leverage'] = ( (delist_chars['total_assets'] - delist_chars['total_equity'])/ delist_chars['total_assets'])delist_chars['log_assets'] = np.log(delist_chars['total_assets'])delist_chars['group'] ='Delisted'# Characteristics of all active firms (pooled)all_chars = fundamentals.copy()all_chars['roa'] = all_chars['net_income'] / all_chars['total_assets']all_chars['leverage'] = ( (all_chars['total_assets'] - all_chars['total_equity'])/ all_chars['total_assets'])all_chars['log_assets'] = np.log(all_chars['total_assets'])all_chars['group'] ='All Active'# Compare distributionsfig, axes = plt.subplots(2, 2, figsize=(12, 10))variables = [ ('log_assets', 'Log Total Assets', axes[0, 0]), ('roa', 'Return on Assets', axes[0, 1]), ('leverage', 'Leverage Ratio', axes[1, 0]),]for col, label, ax in variables:for grp, color in [('All Active', '#2C5F8A'), ('Delisted', '#C0392B')]:if grp =='Delisted': data = delist_chars[col].dropna()else: data = all_chars[col].dropna() data = data[np.isfinite(data)] ax.hist(data, bins=50, density=True, alpha=0.5, color=color, label=grp, edgecolor='white') ax.set_xlabel(label) ax.set_ylabel('Density') ax.legend()# Panel D: Market cap distributionfor grp, color in [('All Active', '#2C5F8A'), ('Delisted', '#C0392B')]:if grp =='Delisted': data = np.log(delist_chars['market_cap'].dropna())else: data = np.log(all_chars['market_cap'].dropna()) data = data[np.isfinite(data)] axes[1, 1].hist(data, bins=50, density=True, alpha=0.5, color=color, label=grp, edgecolor='white')axes[1, 1].set_xlabel('Log Market Cap')axes[1, 1].set_ylabel('Density')axes[1, 1].legend()plt.suptitle('Characteristics of Delisted vs Active Firms', fontsize=14)plt.tight_layout()plt.show()# Formal comparisonprint("\nMean Comparison (Delisted vs All Active):")for col in ['log_assets', 'roa', 'leverage']: d = delist_chars[col].dropna() a = all_chars[col].dropna() d = d[np.isfinite(d)] a = a[np.isfinite(a)] t, p = stats.ttest_ind(d, a, equal_var=False)print(f" {col:<15}: Delisted = {d.mean():.3f}, "f"Active = {a.mean():.3f}, t = {t:.2f}, p = {p:.4f}")```## Thiên kiến sống sót {#sec-missing-survivorship}### Định nghĩa và mức độThiên kiến sống sót phát sinh khi một nghiên cứu chỉ sử dụng các doanh nghiệp hiện đang niêm yết (hoặc đang niêm yết vào cuối mẫu), loại trừ các doanh nghiệp đã bị hủy niêm yết trong giai đoạn mẫu. Vì các doanh nghiệp bị hủy niêm yết trước đó chịu lợi nhuận âm một cách không cân xứng, việc loại bỏ chúng làm thổi phồng lợi nhuận trung bình, đánh giá thấp rủi ro và bóp méo các dạng thức mặt cắt ngang.Chúng ta định lượng mức độ của thiên kiến sống sót bằng cách so sánh lợi nhuận danh mục tính từ mẫu **không có thiên kiến sống sót** (mọi doanh nghiệp, kể cả những doanh nghiệp sau đó bị hủy niêm yết) với mẫu **chỉ gồm doanh nghiệp sống sót** (các doanh nghiệp vẫn niêm yết đến cuối mẫu).```{python}#| label: survivorship-magnitude#| eval: false#| code-summary: "Quantify survivorship bias by comparing survivor-only vs full-sample portfolios"# Define survivors: firms active as of 2024-12-31survivors =set( listing_history[listing_history['is_active']]['ticker'])# Full sample: all firms, including delistedfull_sample = monthly_returns.copy()# Survivors only: restrict to firms still listed at end of samplesurvivors_only = monthly_returns[ monthly_returns['ticker'].isin(survivors)].copy()# Compute EW monthly portfolio returnsdef compute_ew_portfolio(df):return ( df .groupby('month_end')['monthly_return'] .mean() .to_frame('portfolio_return') )def compute_vw_portfolio(df):return ( df .groupby('month_end') .apply(lambda g: np.average(g['monthly_return'], weights=g['market_cap'])if g['market_cap'].sum() >0else np.nan) .to_frame('portfolio_return') )ew_full = compute_ew_portfolio(full_sample)ew_survivors = compute_ew_portfolio(survivors_only)vw_full = compute_vw_portfolio(full_sample)vw_survivors = compute_vw_portfolio(survivors_only)# Merge and compute biasbias_ew = pd.merge( ew_full.rename(columns={'portfolio_return': 'full'}), ew_survivors.rename(columns={'portfolio_return': 'survivors'}), left_index=True, right_index=True)bias_ew['bias'] = bias_ew['survivors'] - bias_ew['full']bias_vw = pd.merge( vw_full.rename(columns={'portfolio_return': 'full'}), vw_survivors.rename(columns={'portfolio_return': 'survivors'}), left_index=True, right_index=True)bias_vw['bias'] = bias_vw['survivors'] - bias_vw['full']print("Survivorship Bias (Annualized):")print(f" EW: {bias_ew['bias'].mean() *12:.4f} "f"({bias_ew['bias'].mean() *1200:.1f} bps/year)")print(f" VW: {bias_vw['bias'].mean() *12:.4f} "f"({bias_vw['bias'].mean() *1200:.1f} bps/year)")``````{python}#| label: fig-survivorship-bias#| eval: false#| fig-cap: "Lợi nhuận tích lũy của mẫu đầy đủ so với danh mục chỉ gồm doanh nghiệp sống sót. Panel A cho thấy các danh mục trọng số bằng nhau, nơi thiên kiến lớn hơn vì các doanh nghiệp nhỏ bị hủy niêm yết (và hoạt động kém trước khi hủy niêm yết) nhận trọng số bằng nhau. Panel B cho thấy các danh mục trọng số theo vốn hóa, nơi thiên kiến nhỏ hơn vì các doanh nghiệp bị hủy niêm yết thường nhỏ và đóng góp ít vào lợi nhuận VW."#| code-summary: "Plot cumulative returns: full sample vs survivors only"fig, axes = plt.subplots(1, 2, figsize=(14, 5))for i, (bias_df, title) inenumerate( [(bias_ew, 'Panel A: Equal-Weighted'), (bias_vw, 'Panel B: Value-Weighted')]): cum_full = (1+ bias_df['full']).cumprod() cum_surv = (1+ bias_df['survivors']).cumprod() axes[i].plot(cum_full.index, cum_full, color='#2C5F8A', linewidth=2, label='Full Sample') axes[i].plot(cum_surv.index, cum_surv, color='#C0392B', linewidth=2, label='Survivors Only') axes[i].set_ylabel('Cumulative Wealth') axes[i].set_xlabel('Date') axes[i].set_title(title) axes[i].legend() axes[i].set_yscale('log') ann_bias = bias_df['bias'].mean() *12 axes[i].text(0.05, 0.95,f'Annual Bias: {ann_bias*100:.1f}%', transform=axes[i].transAxes, fontsize=11, verticalalignment='top', bbox=dict(facecolor='white', alpha=0.8))plt.tight_layout()plt.show()```### Thiên kiến sống sót biến đổi theo thời gianMức độ của thiên kiến sống sót không cố định. Nó đạt đỉnh trong và sau các giai đoạn thị trường suy thoái, khi hoạt động hủy niêm yết cao nhất.```{python}#| label: fig-rolling-bias#| eval: false#| fig-cap: "Thiên kiến sống sót trượt 12 tháng đối với các danh mục trọng số bằng nhau. Thiên kiến tăng vọt trong các giai đoạn hoạt động hủy niêm yết cao (sau khủng hoảng 2008, suy thoái 2011--2012). Trong các thị trường tăng giá, ít doanh nghiệp bị hủy niêm yết và thiên kiến tiến gần về không."#| code-summary: "Compute rolling survivorship bias"bias_ew['rolling_bias_12m'] = bias_ew['bias'].rolling(12).mean() *12fig, axes = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[2, 1])# Panel A: Rolling biasaxes[0].fill_between( bias_ew.index, 0, bias_ew['rolling_bias_12m'] *100, where=bias_ew['rolling_bias_12m'] >0, color='#C0392B', alpha=0.4)axes[0].plot(bias_ew.index, bias_ew['rolling_bias_12m'] *100, color='#C0392B', linewidth=1.5)axes[0].axhline(y=0, color='gray', linewidth=0.5)axes[0].set_ylabel('Annualized Bias (%)')axes[0].set_title('Panel A: Rolling 12-Month Survivorship Bias (EW)')# Panel B: Number of delistings per quarterdelistings_quarterly = ( delisted .set_index('delisting_date') .resample('Q') .size())axes[1].bar(delistings_quarterly.index, delistings_quarterly.values, width=80, color='#2C5F8A', alpha=0.7)axes[1].set_ylabel('Delistings per Quarter')axes[1].set_xlabel('Date')axes[1].set_title('Panel B: Quarterly Delisting Activity')plt.tight_layout()plt.show()```### Thiên kiến sống sót trong các hiện tượng bất thường mặt cắt ngangThiên kiến không đồng đều giữa các chiến lược. Các hiện tượng bất thường đặt tỷ trọng lớn vào các doanh nghiệp nhỏ, gặp khó khăn hoặc chất lượng thấp, chính xác là những doanh nghiệp có nhiều khả năng bị hủy niêm yết nhất, chịu ảnh hưởng nghiêm trọng nhất. Chúng ta kiểm định điều này cho các hiện tượng bất thường về quy mô, giá trị và động lượng.```{python}#| label: anomaly-bias#| eval: false#| code-summary: "Quantify survivorship bias by anomaly portfolio"def compute_long_short(df, sort_var, n_quantiles=5):""" Compute long-short portfolio returns from quintile sorts. Long = top quintile, Short = bottom quintile. """ results = []for month, group in df.groupby('month_end'): group = group.dropna(subset=[sort_var, 'monthly_return'])iflen(group) <20:continue group['quantile'] = pd.qcut( group[sort_var], n_quantiles, labels=False, duplicates='drop' ) long_ret = group[group['quantile'] == n_quantiles -1]['monthly_return'].mean() short_ret = group[group['quantile'] ==0]['monthly_return'].mean() results.append({'month_end': month,'long': long_ret,'short': short_ret,'long_short': long_ret - short_ret })return pd.DataFrame(results)# Prepare sort variablesmonthly_with_chars = monthly_returns.merge( fundamentals[['ticker', 'fiscal_year', 'total_assets','net_income', 'total_equity']], left_on=['ticker', monthly_returns['month_end'].dt.year], right_on=['ticker', 'fiscal_year'], how='left')monthly_with_chars['log_mcap'] = np.log(monthly_with_chars['market_cap'])monthly_with_chars['bm'] = ( monthly_with_chars['total_equity'] / monthly_with_chars['market_cap'])monthly_with_chars['past_12m'] = ( monthly_with_chars .groupby('ticker')['monthly_return'] .transform(lambda x: x.rolling(12).sum()))# Compute anomalies on full sample and survivors onlyanomaly_bias = {}for anomaly, sort_var, ascending in [ ('Size (SMB)', 'log_mcap', True), ('Value (HML)', 'bm', True), ('Momentum (WML)', 'past_12m', True)]: full_ls = compute_long_short( monthly_with_chars, sort_var ) surv_data = monthly_with_chars[ monthly_with_chars['ticker'].isin(survivors) ] surv_ls = compute_long_short(surv_data, sort_var)# Merge merged = pd.merge( full_ls[['month_end', 'long_short']].rename( columns={'long_short': 'full'}), surv_ls[['month_end', 'long_short']].rename( columns={'long_short': 'survivors'}), on='month_end' ) merged['bias'] = merged['survivors'] - merged['full'] ann_full = merged['full'].mean() *12 ann_surv = merged['survivors'].mean() *12 ann_bias = merged['bias'].mean() *12 anomaly_bias[anomaly] = {'Full Sample (ann.)': ann_full,'Survivors Only (ann.)': ann_surv,'Bias (ann.)': ann_bias,'Bias (% of premium)': ann_bias / ann_full *100if ann_full !=0else np.nan }anomaly_bias_df = pd.DataFrame(anomaly_bias).Tprint("Survivorship Bias by Anomaly:")print(anomaly_bias_df.round(4).to_string())```## Thiên kiến hủy niêm yết và điền khuyết lợi nhuận {#sec-missing-delisting}### Hiệu chỉnh Shumway@shumway1997delisting đã chỉ ra rằng cách CRSP xử lý lợi nhuận hủy niêm yết, thường ghi nhận chúng là thiếu hoặc bằng không, tạo ra một độ chệch lên có hệ thống trong lợi nhuận trung bình. Vấn đề tương tự tồn tại trong các cơ sở dữ liệu của Việt Nam, nơi mức giá quan sát được cuối cùng có thể đi trước thời điểm hủy niêm yết thực tế vài ngày hoặc vài tuần, và lợi nhuận cuối kỳ thực sự (từ giá giao dịch cuối cùng đến giá trị mà cổ đông thực sự nhận được) không được ghi lại.Chúng ta triển khai một quy trình điền khuyết lợi nhuận hủy niêm yết được điều chỉnh cho Việt Nam:**Bước 1.** Với mỗi doanh nghiệp bị hủy niêm yết, xác định ngày giao dịch cuối cùng có giá đóng cửa hợp lệ.**Bước 2.** Phân loại lý do hủy niêm yết để xác định cách điền khuyết phù hợp (@tbl-missing-imputation).| Lý do hủy niêm yết | Lợi nhuận điền khuyết | Cơ sở lập luận ||------------------------|------------------------|------------------------|| M&A / Mua lại | Phần bù chào mua công khai thực tế (nếu có) | Mua lại với phần bù || Chuyển sang sở hữu tư nhân | 0% (hoặc giá mua lại thực tế) | Thoái vốn theo thương lượng || Khó khăn tài chính | −30% đến −100% | Mất giá trị đáng kể || Vi phạm quy định | −50% | Mất một phần; có thể thu hồi được một phần || Chuyển sàn | 0% (liên kết với mã mới) | Không có sự kiện kinh tế |: Các quy tắc điền khuyết lợi nhuận hủy niêm yết. {#tbl-missing-imputation}**Bước 3.** Áp dụng lợi nhuận đã điền khuyết vào tháng hủy niêm yết để hoàn chỉnh chuỗi lợi nhuận.```{python}#| label: delisting-imputation#| eval: false#| code-summary: "Implement delisting return imputation for Vietnamese firms"def impute_delisting_returns(listing_df, daily_df, monthly_df):""" Impute terminal returns for delisted firms. Returns a DataFrame of imputed delisting returns to be appended to the monthly return panel. """ delisted_firms = listing_df[listing_df['delisting_date'].notna()].copy() imputed = []for _, firm in delisted_firms.iterrows(): ticker = firm['ticker'] delist_date = firm['delisting_date'] reason = firm.get('reason_category', firm.get('delisting_reason', ''))# Find last trading day firm_daily = daily_df[daily_df['ticker'] == ticker].sort_values('date')iflen(firm_daily) ==0:continue last_trade = firm_daily.iloc[-1] last_price = last_trade['adjusted_close'] last_date = last_trade['date']# Check if last trade is already close to delisting date gap_days = (pd.Timestamp(delist_date) - pd.Timestamp(last_date)).daysif gap_days <0:continue# Data issue# Determine imputation based on reasonif'M&A'instr(reason) or'merger'instr(reason).lower(): imputed_return =0.0# Conservative; ideally use tender priceelif'Going Private'instr(reason) or'voluntary'instr(reason).lower(): imputed_return =0.0elif'Transfer'instr(reason): imputed_return =0.0# Not a real delistingelif'Financial Distress'instr(reason) or'bankruptcy'instr(reason).lower(): imputed_return =-0.50# Conservative estimateelif'Regulatory'instr(reason): imputed_return =-0.30else: imputed_return =-0.30# Default for unknown reasons# Assign to the delisting month delist_month = pd.Timestamp(delist_date).to_period('M').to_timestamp() imputed.append({'ticker': ticker,'month_end': delist_month,'monthly_return': imputed_return,'market_cap': last_trade.get('market_cap', np.nan),'source': 'imputed_delisting','delisting_reason': reason,'gap_days': gap_days })return pd.DataFrame(imputed)# Apply imputationimputed_returns = impute_delisting_returns( delisted.assign(reason_category=delisted['reason_category']), daily_returns, monthly_returns)print(f"Imputed delisting returns: {len(imputed_returns)}")print(f"\nImputed return distribution:")print(imputed_returns['monthly_return'].value_counts().sort_index())```### Tác động của việc điền khuyết lợi nhuận hủy niêm yết```{python}#| label: delisting-impact#| eval: false#| code-summary: "Quantify the impact of including imputed delisting returns"# Augmented sample: monthly returns + imputed delisting returnsaugmented = pd.concat([ monthly_returns[['ticker', 'month_end', 'monthly_return', 'market_cap']], imputed_returns[['ticker', 'month_end', 'monthly_return', 'market_cap']]], ignore_index=True)# Compare original vs augmented EW portfoliosew_original = compute_ew_portfolio(monthly_returns)ew_augmented = compute_ew_portfolio(augmented)comparison = pd.merge( ew_original.rename(columns={'portfolio_return': 'original'}), ew_augmented.rename(columns={'portfolio_return': 'augmented'}), left_index=True, right_index=True)comparison['imputation_effect'] = ( comparison['augmented'] - comparison['original'])ann_original = comparison['original'].mean() *12ann_augmented = comparison['augmented'].mean() *12ann_effect = comparison['imputation_effect'].mean() *12print("Delisting Return Imputation Impact:")print(f" EW without imputation: {ann_original:.4f} ({ann_original*100:.2f}%/yr)")print(f" EW with imputation: {ann_augmented:.4f} ({ann_augmented*100:.2f}%/yr)")print(f" Difference: {ann_effect:.4f} ({ann_effect*100:.2f}%/yr)")```## Ngày không giao dịch và khoảng trống thanh khoản {#sec-missing-zero-trading}### Mức độ phổ biến của ngày không giao dịchMột đặc điểm nổi bật của dữ liệu chứng khoán Việt Nam là tần suất cao của các ngày không có khối lượng giao dịch (tức là những ngày một cổ phiếu niêm yết không ghi nhận giao dịch nào). Đây không phải dữ liệu "bị thiếu" thực sự theo nghĩa cơ sở dữ liệu (cổ phiếu vẫn niêm yết và giá đóng cửa vẫn được ghi nhận, thường bằng giá đóng cửa trước đó), nhưng chúng đại diện cho thông tin bị thiếu về mặt kinh tế: giá quan sát được đã lỗi thời và không phản ánh điều kiện thị trường hiện tại.```{python}#| label: fig-zero-trading#| eval: false#| fig-cap: "Mức độ phổ biến của ngày không giao dịch trên các doanh nghiệp niêm yết Việt Nam. Panel A cho thấy phân phối chéo của tỷ lệ ngày không có khối lượng (tỷ trọng các ngày giao dịch không có khối lượng) theo năm. Panel B cho thấy tỷ lệ ngày không có khối lượng theo thập phân vị vốn hóa thị trường. Các doanh nghiệp vốn hóa nhỏ có tỷ lệ ngày không có khối lượng vượt quá 30%, tạo ra các vấn đề nghiêm trọng trong đo lường lợi nhuận."#| code-summary: "Analyze the prevalence of zero-trading days"# Compute zero-volume fraction per firm-yeardaily_returns['year'] = pd.to_datetime(daily_returns['date']).dt.yeardaily_returns['zero_volume'] = (daily_returns['volume'] ==0).astype(int)zero_vol_fy = ( daily_returns .groupby(['ticker', 'year']) .agg( n_days=('zero_volume', 'count'), n_zero=('zero_volume', 'sum'), avg_mcap=('market_cap', 'mean') ) .reset_index())zero_vol_fy['zero_frac'] = zero_vol_fy['n_zero'] / zero_vol_fy['n_days']fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Distribution over time (boxplot by year)years_to_plot =range(2008, 2025)data_by_year = [ zero_vol_fy[zero_vol_fy['year'] == y]['zero_frac'].dropna().valuesfor y in years_to_plot]bp = axes[0].boxplot(data_by_year, positions=range(len(years_to_plot)), widths=0.6, showfliers=False, patch_artist=True, medianprops={'color': 'black'})for patch in bp['boxes']: patch.set_facecolor('#2C5F8A') patch.set_alpha(0.6)axes[0].set_xticks(range(len(years_to_plot)))axes[0].set_xticklabels(years_to_plot, rotation=45, fontsize=8)axes[0].set_ylabel('Zero-Volume Fraction')axes[0].set_title('Panel A: Zero-Volume Days by Year')# Panel B: By market cap decilezero_vol_fy['mcap_decile'] = pd.qcut( zero_vol_fy['avg_mcap'].rank(method='first'),10, labels=[f'D{i}'for i inrange(1, 11)])decile_zero = ( zero_vol_fy .groupby('mcap_decile')['zero_frac'] .agg(['mean', 'median']))axes[1].bar(range(10), decile_zero['mean'], color='#2C5F8A', alpha=0.85, edgecolor='white')axes[1].set_xticks(range(10))axes[1].set_xticklabels(decile_zero.index)axes[1].set_xlabel('Market Cap Decile (D1 = smallest)')axes[1].set_ylabel('Mean Zero-Volume Fraction')axes[1].set_title('Panel B: Zero-Volume Days by Size')plt.tight_layout()plt.show()```### Đo lường lợi nhuận trong các giai đoạn không giao dịchKhi một cổ phiếu không giao dịch, cách tiếp cận tiêu chuẩn, dùng giá đóng cửa gần nhất có sẵn, tạo ra một mức giá lỗi thời làm đánh giá thấp biến động thực và làm chệch lợi nhuận về phía không. Có một số cách tiếp cận để xử lý vấn đề này:**Cách tiếp cận 1: Loại bỏ các quan sát không có khối lượng.** Đơn giản nhưng loại bỏ thông tin và gây ra thiên kiến chọn mẫu (nếu việc không giao dịch tương quan với lợi nhuận).**Cách tiếp cận 2: Gộp lãi kép nhiều ngày.** Tích lũy lợi nhuận trên toàn bộ khoảng trống không giao dịch và gán nó cho ngày đầu tiên giao dịch trở lại. Cách này bảo toàn tổng lợi nhuận nhưng dồn nó vào một quan sát duy nhất.**Cách tiếp cận 3: Phân bổ đều.** Phân bổ đều lợi nhuận tích lũy cho các ngày không có khối lượng. Điều này không thực tế về mặt kinh tế, nhưng làm giảm tác động của các giá trị ngoại lai trong một ngày.**Cách tiếp cận 4: Coi là thiếu và mô hình hóa.** Coi các ngày không có khối lượng là lợi nhuận thực sự bị thiếu và dùng thước đo lợi nhuận bằng không của @lesmond2005liquidity làm đại diện cho thanh khoản.```{python}#| label: zero-vol-correction#| eval: false#| code-summary: "Implement zero-trading-day return corrections"def correct_zero_volume_returns(daily_df, method='compound'):""" Correct returns during zero-volume periods. Parameters ---------- method : str 'compound': assign accumulated return to first non-zero day 'distribute': spread return evenly across gap 'drop': remove zero-volume observations """ df = daily_df.copy() df = df.sort_values(['ticker', 'date']) df['daily_return'] = ( df.groupby('ticker')['adjusted_close'] .pct_change() )if method =='drop':return df[df['volume'] >0]elif method =='compound':# For each zero-volume streak, accumulate return and# assign to the next trading day results = []for ticker, group in df.groupby('ticker'): group = group.sort_values('date').reset_index(drop=True) accumulated =0 gap_length =0for idx, row in group.iterrows():if row['volume'] ==0: accumulated += row['daily_return'] if pd.notna(row['daily_return']) else0 gap_length +=1else:if gap_length >0:# Add accumulated return to this day's return total_return = (1+ accumulated) * (1+ (row['daily_return'] or0)) -1 group.loc[idx, 'daily_return'] = total_return accumulated =0 gap_length =0 results.append(group.loc[idx])# If series ends with zero-volume days, include last non-zeroif gap_length >0andlen(results) >0: last_valid = results[-1].copy() last_valid['daily_return'] = ( (1+ last_valid['daily_return']) * (1+ accumulated) -1 ) results[-1] = last_validreturn pd.DataFrame(results)elif method =='distribute': results = []for ticker, group in df.groupby('ticker'): group = group.sort_values('date').reset_index(drop=True) i =0while i <len(group):if group.loc[i, 'volume'] >0: results.append(group.loc[i]) i +=1else:# Find end of zero-volume streak j = iwhile j <len(group) and group.loc[j, 'volume'] ==0: j +=1# Total return over gapif j <len(group): total_ret = ( group.loc[j, 'adjusted_close']/ group.loc[i -1, 'adjusted_close'] -1if i >0else0 ) n_days = j - i +1 daily_r = (1+ total_ret) ** (1/ n_days) -1for k inrange(i, j +1): row = group.loc[k].copy() row['daily_return'] = daily_r results.append(row) i = j +1return pd.DataFrame(results)# Apply corrections and comparefor method in ['drop', 'compound', 'distribute']: corrected = correct_zero_volume_returns( daily_returns.head(500000), method=method ) mean_ret = corrected['daily_return'].mean() *252 vol = corrected['daily_return'].std() * np.sqrt(252)print(f"{method:<12}: Ann. Return = {mean_ret:.4f}, "f"Ann. Vol = {vol:.4f}, N = {len(corrected):,}")```## Thiên kiến nhìn trước {#sec-missing-lookahead}### Định nghĩaThiên kiến nhìn trước xảy ra khi một nghiên cứu sử dụng thông tin chưa có sẵn tại thời điểm đưa ra quyết định đầu tư. Trong bối cảnh Việt Nam, các nguồn phổ biến nhất là:1. **Điều kiện hóa theo sự sống sót.** Chọn các doanh nghiệp dựa trên trạng thái niêm yết vào cuối mẫu ngầm sử dụng thông tin tương lai (liệu doanh nghiệp có bị hủy niêm yết hay không).2. **Sử dụng dữ liệu tài chính đã điều chỉnh.** Các doanh nghiệp Việt Nam thường điều chỉnh lại báo cáo tài chính sau kiểm toán. Dùng các số liệu đã điều chỉnh thay vì các số liệu công bố ban đầu gây ra thiên kiến nhìn trước.3. **Thiên kiến điền lùi.** Khi một cơ sở dữ liệu bổ sung một doanh nghiệp mới, nó có thể điền lùi dữ liệu lịch sử, tạo ra ảo giác rằng doanh nghiệp đã có thể được chọn trước ngày niêm yết thực tế của nó.4. **Dữ liệu kế toán theo thời điểm công bố.** Dùng dữ liệu tài chính hằng năm tại thời điểm kết thúc năm tài chính thay vì ngày báo cáo tài chính được công bố công khai là giả định rằng dữ liệu có sẵn ngay lập tức.### Điều chỉnh theo thời điểm công bốChúng ta triển khai một phép điều chỉnh theo thời điểm công bố đối với dữ liệu kế toán, tôn trọng độ trễ báo cáo thực tế:```{python}#| label: point-in-time#| eval: false#| code-summary: "Implement point-in-time alignment of accounting data"def point_in_time_merge(monthly_df, fundamentals_df, filings_df, lag_months=0):""" Merge accounting data with monthly returns respecting the actual filing date (point-in-time). Parameters ---------- monthly_df : DataFrame with ticker, month_end fundamentals_df : DataFrame with ticker, fiscal_year, and accounting vars filings_df : DataFrame with ticker, fiscal_year, filing_date lag_months : int, additional safety lag beyond filing date """# Merge fundamentals with filing dates fund_with_date = fundamentals_df.merge( filings_df[['ticker', 'fiscal_year', 'filing_date']], on=['ticker', 'fiscal_year'], how='left' )# If filing date is missing, assume available 4 months after FY end fund_with_date['filing_date'] = pd.to_datetime( fund_with_date['filing_date'] ) fund_with_date['fy_end'] = pd.to_datetime( fund_with_date['fiscal_year'].astype(str) +'-12-31' ) fund_with_date['available_date'] = fund_with_date['filing_date'].fillna( fund_with_date['fy_end'] + pd.DateOffset(months=4) )# Add safety lagif lag_months >0: fund_with_date['available_date'] += pd.DateOffset(months=lag_months)# For each firm-month, find the most recent accounting data# that was available (filing_date <= month_end) results = []for _, row in monthly_df.iterrows(): ticker = row['ticker'] month = row['month_end'] available = fund_with_date[ (fund_with_date['ticker'] == ticker) & (fund_with_date['available_date'] <= month) ]iflen(available) >0: latest = available.sort_values('fiscal_year').iloc[-1] result = row.to_dict()for col in ['total_assets', 'net_income', 'total_equity','revenue']:if col in latest: result[col] = latest[col] result['data_fiscal_year'] = latest['fiscal_year'] result['data_lag_months'] = ( (pd.Timestamp(month) - pd.Timestamp(latest['available_date'])) .days /30.44 ) results.append(result)return pd.DataFrame(results)# Example: compare point-in-time vs naive mergefilings = client.get_filings( exchanges=['HOSE', 'HNX'], report_types=['annual'], fields=['ticker', 'fiscal_year', 'filing_date'])print("Point-in-time merge vs naive merge:")print(" Naive: use fiscal year directly (introduces look-ahead bias)")print(" PIT: use only data available as of the portfolio formation date")```### Định lượng thiên kiến nhìn trước trong các chiến lược giá trịCác chiến lược giá trị phân loại cổ phiếu theo tỷ số giá trị sổ sách trên giá trị thị trường tính từ dữ liệu kế toán. Việc dùng dữ liệu cuối năm tài chính mà không tôn trọng độ trễ báo cáo làm thổi phồng phần bù giá trị vì nó ngầm sử dụng thông tin chưa được công bố công khai.```{python}#| label: lookahead-value#| eval: false#| code-summary: "Compare value premium with and without point-in-time adjustment"# Naive approach: use fiscal year data immediatelymonthly_naive = monthly_returns.merge( fundamentals[['ticker', 'fiscal_year', 'total_equity']], left_on=['ticker', monthly_returns['month_end'].dt.year], right_on=['ticker', 'fiscal_year'], how='left')monthly_naive['bm_naive'] = ( monthly_naive['total_equity'] / monthly_naive['market_cap'])# Point-in-time approach (using 4-month lag as conservative default)monthly_pit = monthly_returns.copy()monthly_pit['bm_pit'] = np.nan # Would be filled by point_in_time_merge# For demonstration: approximate PIT by using t-1 fiscal year data# (ensures data were available at formation date)fund_lagged = fundamentals.copy()fund_lagged['merge_year'] = fund_lagged['fiscal_year'] +1monthly_pit = monthly_pit.merge( fund_lagged[['ticker', 'merge_year', 'total_equity']].rename( columns={'merge_year': 'year'}), left_on=['ticker', monthly_pit['month_end'].dt.year], right_on=['ticker', 'year'], how='left')monthly_pit['bm_pit'] = ( monthly_pit['total_equity'] / monthly_pit['market_cap'])# Compute HML for both approacheshml_naive = compute_long_short(monthly_naive, 'bm_naive')hml_pit = compute_long_short(monthly_pit, 'bm_pit')ann_naive = hml_naive['long_short'].mean() *12ann_pit = hml_pit['long_short'].mean() *12print("Value Premium (HML):")print(f" Naive (look-ahead): {ann_naive:.4f} ({ann_naive*100:.2f}%/yr)")print(f" Point-in-time: {ann_pit:.4f} ({ann_pit*100:.2f}%/yr)")print(f" Look-ahead inflation: {(ann_naive - ann_pit)*100:.2f}%/yr")```## Chuyển sàn và sự gián đoạn mã chứng khoán {#sec-missing-transfers}### Vấn đề chuyển sànCác doanh nghiệp Việt Nam thường xuyên chuyển giữa các sàn (ví dụ: từ HNX sang HOSE khi đáp ứng điều kiện niêm yết của HOSE, hoặc từ HOSE sang UPCoM/HNX sau các vấn đề về quy định). Những lần chuyển sàn này có thể làm gián đoạn tính liên tục của chuỗi lợi nhuận nếu cơ sở dữ liệu coi mỗi lần niêm yết trên một sàn là một thực thể riêng biệt.```{python}#| label: transfers#| eval: false#| code-summary: "Identify and link exchange transfers"transfers = listing_history[ listing_history['transfer_from'].notna()].copy()print(f"Total exchange transfers: {len(transfers)}")print(f"\nTransfer patterns:")transfer_pattern = transfers.groupby( ['transfer_from', 'transfer_to']).size().sort_values(ascending=False)print(transfer_pattern.head(10))``````{python}#| label: link-transfers#| eval: false#| code-summary: "Link pre-transfer and post-transfer return series"def link_transfer_returns(monthly_df, transfers_df):""" Link return series across exchange transfers to create continuous firm-level return histories. """# Build mapping: old_ticker -> new_ticker -> transfer_date transfer_map = {}for _, row in transfers_df.iterrows(): old_ticker = row.get('transfer_from_ticker', row['ticker']) new_ticker = row['ticker'] transfer_date = row['transfer_date']if old_ticker and new_ticker and old_ticker != new_ticker: transfer_map[old_ticker] = {'new_ticker': new_ticker,'date': transfer_date }# Create unified ticker mapping df = monthly_df.copy() df['unified_ticker'] = df['ticker']for old_t, info in transfer_map.items(): mask = ( (df['ticker'] == old_t) & (df['month_end'] < info['date']) ) df.loc[mask, 'unified_ticker'] = info['new_ticker'] n_linked =sum(1for t in transfer_map if t in df['ticker'].values)print(f"Linked {n_linked} transfer pairs")return dflinked = link_transfer_returns(monthly_returns, transfers)```## Khung phân tích độ nhạy {#sec-missing-sensitivity}### Kết quả của bạn mong manh đến mức nào?Thay vì chọn một cách tiếp cận duy nhất để xử lý dữ liệu bị thiếu, một nghiên cứu vững chắc kiểm tra mức độ nhạy của các kết luận với các giả định khác nhau. Chúng ta triển khai một khung phân tích độ nhạy có hệ thống, chạy lại một phân tích cho trước dưới nhiều giả định xử lý dữ liệu.```{python}#| label: sensitivity-framework#| eval: false#| code-summary: "Implement sensitivity analysis for missing data assumptions"def sensitivity_analysis(monthly_df, listing_df, sort_variable, compute_fn):""" Run an analysis under multiple data treatment assumptions. Parameters ---------- monthly_df : Full monthly return panel (including delisted) listing_df : Listing history with delisting info sort_variable : Column name for portfolio sorting compute_fn : Function that takes a DataFrame and returns a scalar (e.g., annualized long-short return) Returns ------- DataFrame with results under each assumption """ survivors =set(listing_df[listing_df['is_active']]['ticker']) results = {}# 1. Survivors only (maximum bias) surv_only = monthly_df[monthly_df['ticker'].isin(survivors)] results['Survivors Only'] = compute_fn(surv_only, sort_variable)# 2. Full sample, no delisting imputation results['Full Sample (no imputation)'] = compute_fn( monthly_df, sort_variable )# 3. Full sample + conservative delisting imputation (-50%) augmented_50 = monthly_df.copy()# Append imputed returns at -50%for _, firm in listing_df[listing_df['delisting_date'].notna()].iterrows():if'Financial Distress'instr(firm.get('reason_category', '')): augmented_50 = pd.concat([augmented_50, pd.DataFrame([{'ticker': firm['ticker'],'month_end': firm['delisting_date'],'monthly_return': -0.50,'market_cap': np.nan, sort_variable: np.nan }])], ignore_index=True) results['Full + Impute -50%'] = compute_fn( augmented_50, sort_variable )# 4. Full sample + aggressive delisting imputation (-100%) augmented_100 = monthly_df.copy()for _, firm in listing_df[listing_df['delisting_date'].notna()].iterrows():if'Financial Distress'instr(firm.get('reason_category', '')): augmented_100 = pd.concat([augmented_100, pd.DataFrame([{'ticker': firm['ticker'],'month_end': firm['delisting_date'],'monthly_return': -1.00,'market_cap': np.nan, sort_variable: np.nan }])], ignore_index=True) results['Full + Impute -100%'] = compute_fn( augmented_100, sort_variable )# 5. Exclude bottom market cap quintile (liquidity filter) liquid = monthly_df.copy() liquid['mcap_quintile'] = ( liquid.groupby('month_end')['market_cap'] .transform(lambda x: pd.qcut(x, 5, labels=False, duplicates='drop')) ) liquid = liquid[liquid['mcap_quintile'] >0] results['Exclude Bottom Quintile'] = compute_fn( liquid, sort_variable )return pd.DataFrame.from_dict(results, orient='index', columns=['Result'])# Example: sensitivity of size premiumdef compute_size_premium(df, sort_var): ls = compute_long_short(df, sort_var, n_quantiles=5)return ls['long_short'].mean() *12iflen(ls) >0else np.nan# Would need sort variable in the data; illustrative call:# sensitivity_results = sensitivity_analysis(# monthly_with_chars, listing_history, 'log_mcap',# compute_size_premium# )``````{python}#| label: fig-sensitivity#| eval: false#| fig-cap: "Độ nhạy của các phần bù hiện tượng bất thường với các giả định xử lý dữ liệu. Mỗi cột biểu diễn lợi nhuận mua–bán khống quy năm dưới một giả định khác nhau về thiên kiến sống sót, điền khuyết lợi nhuận hủy niêm yết và lọc thanh khoản. Các kết quả vững với những lựa chọn này đáng tin cậy hơn; các kết quả thay đổi mạnh báo hiệu một phát hiện mong manh."#| code-summary: "Visualize sensitivity of anomaly premia to data treatment"# Illustrative: create synthetic sensitivity results for plottingassumptions = ['Survivors Only','Full Sample\n(no imputation)','Full +\nImpute -30%','Full +\nImpute -50%','Full +\nImpute -100%','Exclude Bottom\nMcap Quintile']# Hypothetical results (would be computed from actual data)size_premium = [0.08, 0.06, 0.055, 0.05, 0.04, 0.07]value_premium = [0.07, 0.065, 0.063, 0.06, 0.055, 0.068]momentum_premium = [0.10, 0.08, 0.075, 0.07, 0.06, 0.09]fig, ax = plt.subplots(figsize=(14, 6))x = np.arange(len(assumptions))width =0.25ax.bar(x - width, [s *100for s in size_premium], width, color='#2C5F8A', alpha=0.85, label='Size (SMB)')ax.bar(x, [v *100for v in value_premium], width, color='#27AE60', alpha=0.85, label='Value (HML)')ax.bar(x + width, [m *100for m in momentum_premium], width, color='#E67E22', alpha=0.85, label='Momentum (WML)')ax.set_xticks(x)ax.set_xticklabels(assumptions, fontsize=9)ax.set_ylabel('Annualized Premium (%)')ax.set_title('Anomaly Premium Sensitivity to Data Treatment')ax.legend()ax.axhline(y=0, color='gray', linewidth=0.5)plt.tight_layout()plt.show()```## Khuyến nghị thực tiễn {#sec-missing-recommendations}Dựa trên phân tích trong chương này, chúng tôi đưa ra các khuyến nghị sau cho các nhà nghiên cứu làm việc với dữ liệu chứng khoán Việt Nam:**1. Luôn sử dụng cơ sở dữ liệu không có thiên kiến sống sót.** Khi truy vấn DataCore.vn (hoặc bất kỳ cơ sở dữ liệu nào), hãy yêu cầu tường minh `include_delisted=True`. Không bao giờ điều kiện hóa theo trạng thái niêm yết cuối mẫu khi xây dựng các tập hợp đầu tư.**2. Điền khuyết lợi nhuận hủy niêm yết.** Với các vụ hủy niêm yết không tự nguyện (khó khăn tài chính, cưỡng chế quy định), hãy điền khuyết lợi nhuận cuối kỳ từ −30% đến −50% vào tháng hủy niêm yết. Báo cáo kết quả trên một dải các giả định điền khuyết như một kiểm tra độ vững. Với các vụ hủy niêm yết tự nguyện và chuyển sàn, điền khuyết 0%.**3. Tôn trọng tính sẵn có của dữ liệu theo thời điểm công bố.** Chỉ sử dụng dữ liệu kế toán sau ngày công bố công khai, không phải tại thời điểm kết thúc năm tài chính. Ở Việt Nam, độ trễ chuẩn là 90 ngày đối với báo cáo thường niên; hãy dùng độ trễ thận trọng 4-6 tháng.**4. Xử lý tường minh các ngày không có khối lượng.** Ghi nhận mức độ phổ biến của các ngày không có khối lượng trong mẫu của bạn. Với lợi nhuận hằng tháng, hãy báo cáo số ngày không có khối lượng trung bình trên mỗi cặp doanh nghiệp–tháng. Cân nhắc loại các doanh nghiệp có tỷ lệ ngày không có khối lượng vượt quá 50% khỏi tập hợp đầu tư.**5. Liên kết các lần chuyển sàn.** Sử dụng các mã chứng khoán thống nhất để liên kết chuỗi trước và sau khi chuyển sàn. Nếu không, các lần chuyển sàn sẽ xuất hiện như những vụ hủy niêm yết và niêm yết mới đồng thời, làm thổi phồng số lượt niêm yết mới và hủy niêm yết và làm chệch các phép tính sống sót.**6. Báo cáo phân tích độ nhạy.** Với bất kỳ phát hiện chính nào, hãy báo cáo kết quả dưới ít nhất ba giả định xử lý dữ liệu: chỉ gồm doanh nghiệp sống sót (cận trên), mẫu đầy đủ với điền khuyết vừa phải (cơ sở), và mẫu đầy đủ với điền khuyết mạnh cộng bộ lọc thanh khoản (cận dưới). Nếu phát hiện đứng vững trong cả ba trường hợp, thì đó là một phát hiện vững.**7. Đặc biệt thận trọng với dữ liệu trước năm 2007.** Thị trường Việt Nam có chưa đến 100 cổ phiếu niêm yết trước năm 2006, và làn sóng cổ phần hóa 2006-2009 tạo ra một nhóm doanh nghiệp có đặc điểm khác biệt một cách có hệ thống so với các doanh nghiệp niêm yết trước đó. Các kiểm định mặt cắt ngang với dữ liệu trước năm 2007 có sức mạnh kiểm định rất thấp và cần được diễn giải hết sức thận trọng.## Tóm tắt {#sec-missing-summary}| Vấn đề dữ liệu | Hướng thiên kiến | Mức độ (Việt Nam) | Cách khắc phục khuyến nghị ||------------------|------------------|------------------|------------------|| Thiên kiến sống sót (EW) | Chệch lên đối với lợi nhuận | \~1-3% mỗi năm | Đưa vào mọi doanh nghiệp bị hủy niêm yết || Thiên kiến sống sót (VW) | Chệch lên (nhỏ hơn) | \~0.2-0.5% mỗi năm | Đưa vào mọi doanh nghiệp bị hủy niêm yết || Thiên kiến lợi nhuận hủy niêm yết | Chệch lên đối với lợi nhuận | \~0.5--2% mỗi năm (EW) | Điền khuyết lợi nhuận cuối kỳ || Thiên kiến nhìn trước | Thổi phồng khả năng dự báo | Thay đổi theo chiến lược | Căn chỉnh dữ liệu theo thời điểm công bố || Ngày không giao dịch | Đánh giá thấp biến động | Nghiêm trọng với cổ phiếu vốn hóa nhỏ | Gộp lãi kép hoặc loại bỏ; ghi nhận || Chuyển sàn | Tạo ra các vụ hủy niêm yết giả | \~50-100 doanh nghiệp | Liên kết các mã chứng khoán thống nhất || Thiên kiến niêm yết mới | Giai đoạn đầu của mẫu không mang tính đại diện | Cực đoan trước năm 2007 | Bắt đầu mẫu sau năm 2007 |: Tóm tắt các vấn đề dữ liệu, tác động của chúng và các hiệu chỉnh khuyến nghị. {#tbl-missing-summary}Thông điệp trung tâm là các vấn đề dữ liệu trong nghiên cứu chứng khoán Việt Nam không chỉ là phiền toái--chúng có thể tạo ra các thiên kiến có ý nghĩa kinh tế, làm thay đổi kết luận của các nghiên cứu thực nghiệm. Riêng thiên kiến sống sót đã vượt quá 100 điểm cơ bản mỗi năm đối với các danh mục trọng số bằng nhau, tương đương với nhiều phần bù hiện tượng bất thường đã được ghi nhận. Những nhà nghiên cứu bỏ qua các vấn đề này có nguy cơ báo cáo những kết quả phản ánh các đặc điểm giả tạo do dữ liệu thay vì các hiện tượng kinh tế thực sự.```{=html}<!-- ## Exercises {#sec-missing-exercises}1. **Delisting return estimation from final prices.** For a sample of delisted Vietnamese firms, obtain the last 60 trading days of price data before delisting. Compute the cumulative return over this window. What is the average "run-up to delisting" return? Does it vary by delisting reason?2. **Survivorship bias in fund performance.** Vietnamese open-ended mutual funds also experience survivorship bias (poorly performing funds are closed or merged). Using DataCore.vn's fund data, compare average fund returns in a survivorship-bias-free sample versus a survivors-only sample. How does the bias compare to @rohleder2011survivorship's findings for U.S. funds?3. **Heckman selection correction.** Implement the @heckman1979sample two-step correction for survivorship bias. In the first stage, estimate a probit model for the probability of delisting. In the second stage, include the inverse Mills ratio as a control variable in cross-sectional return regressions. Does the correction materially change the estimated size or value premia?4. **Multiple imputation.** Instead of a single imputed delisting return, implement a multiple imputation procedure: draw the delisting return from a distribution calibrated to observed terminal returns of similar firms. Repeat the analysis 100 times and report the distribution of results. How does parameter uncertainty from imputation compare to sampling uncertainty?5. **Backfill bias detection.** Identify firms in the DataCore.vn database where historical data appears before the official listing date. What fraction of firms exhibit this pattern? Does excluding backfilled observations change the estimated size premium?6. **Zero-volume and momentum.** Test whether the momentum anomaly is affected by the treatment of zero-volume days. Specifically, do zero-volume days create spurious autocorrelation in individual stock returns that inflates the apparent momentum premium?7. **Real-time delisting monitor.** Design a scoring model that predicts involuntary delisting 6--12 months in advance using financial ratios, trading activity, and governance indicators. What is the model's predictive accuracy? How would a portfolio that screens out high-delisting-risk firms perform relative to an unscreened benchmark?8. **Cross-country comparison.** Compare the survivorship bias magnitude in Vietnam to estimates for the U.S. [@shumway1997delisting], other ASEAN markets, and China. What institutional features explain cross-country differences in the severity of survivorship bias? -->```