13  Dữ liệu bị thiếu và thiên kiến sống sót

Ghi chú

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:

  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 (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 pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
import statsmodels.formula.api as smf
from scipy import stats
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings('ignore')

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

client = DataCoreClient()

# Complete listing history: includes all firms ever listed, not just current
listing_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 history
daily_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]:,}")

13.3 Diễn biến niêm yết ở Việt Nam

13.3.1 Sự tăng trưởng của thị trường Việt Nam

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-end
months = 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 time
for 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 delistings
listing_history['listing_year'] = listing_history['listing_date'].dt.year
listing_history['delisting_year'] = listing_history['delisting_date'].dt.year

annual_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.

Categorize and tabulate delisting reasons
delisted = listing_history[listing_history['delisting_date'].notna()].copy()

# Standardize delisting reasons into categories
reason_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')
)

# Tabulate
reason_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())
Visualize delisting reasons
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Panel A: Pie chart
colors_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 time
delisted['year'] = delisted['delisting_date'].dt.year
reason_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()
Hình 13.2

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 delisting
last_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 distributions
fig, 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 distribution
for 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 comparison
print("\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-31
survivors = set(
    listing_history[listing_history['is_active']]['ticker']
)

# Full sample: all firms, including delisted
full_sample = monthly_returns.copy()

# Survivors only: restrict to firms still listed at end of sample
survivors_only = monthly_returns[
    monthly_returns['ticker'].isin(survivors)
].copy()

# Compute EW monthly portfolio returns
def 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() > 0 else 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 bias
bias_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
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for i, (bias_df, title) in enumerate(
    [(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()
Hình 13.4

13.4.2 Thiên kiến sống sót biến đổi theo thời gian

Mứ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.

Compute rolling survivorship bias
bias_ew['rolling_bias_12m'] = bias_ew['bias'].rolling(12).mean() * 12

fig, axes = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[2, 1])

# Panel A: Rolling bias
axes[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 quarter
delistings_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()
Hình 13.5

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.

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'])
        if len(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 variables
monthly_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 only
anomaly_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 * 100 if ann_full != 0 else np.nan
    }

anomaly_bias_df = pd.DataFrame(anomaly_bias).T
print("Survivorship Bias by Anomaly:")
print(anomaly_bias_df.round(4).to_string())

13.5 Thiên kiến hủy niêm yết và điền khuyết lợi nhuận

13.5.1 Hiệu chỉnh Shumway

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ảng 13.2: Các quy tắc điền khuyết lợi nhuận hủy niêm yết.
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ế

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')
        if len(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)).days
        if gap_days < 0:
            continue  # Data issue

        # Determine imputation based on reason
        if 'M&A' in str(reason) or 'merger' in str(reason).lower():
            imputed_return = 0.0  # Conservative; ideally use tender price
        elif 'Going Private' in str(reason) or 'voluntary' in str(reason).lower():
            imputed_return = 0.0
        elif 'Transfer' in str(reason):
            imputed_return = 0.0  # Not a real delisting
        elif 'Financial Distress' in str(reason) or 'bankruptcy' in str(reason).lower():
            imputed_return = -0.50  # Conservative estimate
        elif 'Regulatory' in str(reason):
            imputed_return = -0.30
        else:
            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 imputation
imputed_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 returns
augmented = 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 portfolios
ew_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() * 12
ann_augmented = comparison['augmented'].mean() * 12
ann_effect = comparison['imputation_effect'].mean() * 12

print("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-year
daily_returns['year'] = pd.to_datetime(daily_returns['date']).dt.year
daily_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().values
    for 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 decile
zero_vol_fy['mcap_decile'] = pd.qcut(
    zero_vol_fy['avg_mcap'].rank(method='first'),
    10, labels=[f'D{i}' for i in range(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 = 0

            for idx, row in group.iterrows():
                if row['volume'] == 0:
                    accumulated += row['daily_return'] if pd.notna(row['daily_return']) else 0
                    gap_length += 1
                else:
                    if gap_length > 0:
                        # Add accumulated return to this day's return
                        total_return = (1 + accumulated) * (1 + (row['daily_return'] or 0)) - 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-zero
            if gap_length > 0 and len(results) > 0:
                last_valid = results[-1].copy()
                last_valid['daily_return'] = (
                    (1 + last_valid['daily_return']) * (1 + accumulated) - 1
                )
                results[-1] = last_valid

        return pd.DataFrame(results)

    elif method == 'distribute':
        results = []
        for ticker, group in df.groupby('ticker'):
            group = group.sort_values('date').reset_index(drop=True)
            i = 0
            while i < len(group):
                if group.loc[i, 'volume'] > 0:
                    results.append(group.loc[i])
                    i += 1
                else:
                    # Find end of zero-volume streak
                    j = i
                    while j < len(group) and group.loc[j, 'volume'] == 0:
                        j += 1
                    # Total return over gap
                    if j < len(group):
                        total_ret = (
                            group.loc[j, 'adjusted_close']
                            / group.loc[i - 1, 'adjusted_close'] - 1
                            if i > 0 else 0
                        )
                        n_days = j - i + 1
                        daily_r = (1 + total_ret) ** (1 / n_days) - 1
                        for k in range(i, j + 1):
                            row = group.loc[k].copy()
                            row['daily_return'] = daily_r
                            results.append(row)
                    i = j + 1

        return pd.DataFrame(results)

# Apply corrections and compare
for 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à:

  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.

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 lag
    if 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)
        ]

        if len(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 merge
filings = 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 immediately
monthly_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'] + 1
monthly_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 approaches
hml_naive = compute_long_short(monthly_naive, 'bm_naive')
hml_pit = compute_long_short(monthly_pit, 'bm_pit')

ann_naive = hml_naive['long_short'].mean() * 12
ann_pit = hml_pit['long_short'].mean() * 12

print("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.

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))

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' in str(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' in str(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 premium
def compute_size_premium(df, sort_var):
    ls = compute_long_short(df, sort_var, n_quantiles=5)
    return ls['long_short'].mean() * 12 if len(ls) > 0 else 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 plotting
assumptions = [
    '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.25

ax.bar(x - width, [s * 100 for s in size_premium], width,
       color='#2C5F8A', alpha=0.85, label='Size (SMB)')
ax.bar(x, [v * 100 for v in value_premium], width,
       color='#27AE60', alpha=0.85, label='Value (HML)')
ax.bar(x + width, [m * 100 for 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ự.