37  Biên độ giá và biến động

Ghi chú

Trong chương này, chúng ta xem xét cách chế độ biên độ giá hằng ngày của Việt Nam làm méo phân phối lợi nhuận quan sát được, gây chệch các ước lượng biến động và ảnh hưởng đến tính hợp lệ của các kiểm định định giá tài sản chuẩn. Chúng ta phát triển các phương pháp hiệu chỉnh cho phép nhà nghiên cứu làm việc với lợi nhuận bị kiểm duyệt (censored) và trình bày các phương pháp ước lượng biến động vững trước biên độ giá.

Việt Nam là một trong số ít thị trường cổ phiếu sôi động vẫn áp dụng biên độ giá hằng ngày cho từng cổ phiếu. HOSE áp dụng biên độ \(\pm\) 7%, HNX áp dụng \(\pm\) 10%, và UPCoM áp dụng \(\pm\) 15%, mỗi mức được đo so với giá đóng cửa (hoặc giá tham chiếu) của ngày hôm trước. Khi biến động giá cân bằng của một cổ phiếu vượt quá biên độ, lợi nhuận quan sát được bị kiểm duyệt tại ngưỡng. Cổ phiếu đóng cửa tại mức giá biên, nhưng lợi nhuận “thực” không quan sát được—mức thay đổi giá lẽ ra đã xảy ra nếu không có ràng buộc—vẫn chưa được biết.

Việc kiểm duyệt này có những hệ quả lan rộng đối với tài chính thực nghiệm. Các phân phối lợi nhuận bị cắt cụt, làm chệch các ước lượng trung bình và phương sai. Các mô hình biến động bỏ qua kiểm duyệt đánh giá thấp rủi ro thực. Các beta nhân tố bị suy giảm. Lợi nhuận bất thường của nghiên cứu sự kiện bị nén lại. Các bộ ước lượng chênh lệch giá mua–bán dựa trên tương quan chuỗi của lợi nhuận bị méo. Bất kỳ nhà nghiên cứu nào làm việc với dữ liệu cổ phiếu Việt Nam đều phải hiểu những hiệu ứng này và hoặc hiệu chỉnh chúng, hoặc chứng minh rằng chúng không ảnh hưởng đáng kể đến kết luận.

Biên độ giá tồn tại với một mục đích chính sách đã nêu: ngăn chặn bán tháo hoảng loạn và đầu cơ thái quá, từ đó “hạ nhiệt” thị trường trong các giai đoạn căng thẳng (Brennan 1986). Liệu chúng có đạt được mục tiêu này—hay chỉ làm trì hoãn quá trình khám phá giá và tạo ra hiệu ứng nam châm—là một câu hỏi thực nghiệm với nhiều nghiên cứu quốc tế nhưng chưa có sự đồng thuận. Chúng ta xem xét bằng chứng của Việt Nam.

37.1 Chế độ biên độ giá của Việt Nam

37.1.1 Chi tiết thể chế

Import libraries and configure environment
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
from scipy import stats, optimize
from arch import arch_model
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
})

Cấu trúc biên độ giá đã thay đổi theo thời gian. HOSE bắt đầu giao dịch vào tháng 7 năm 2000 với biên độ \(\pm\) 2%, được nới lên \(\pm\) 5% năm 2002 và lên \(\pm\) 7% năm 2013. HNX vận hành ở mức \(\pm\) 10% kể từ khi có hình thức hiện nay, và UPCoM ở mức \(\pm\) 15% Bảng 37.1. Biên độ áp dụng cho giá đóng cửa điều chỉnh so với giá tham chiếu (thường là giá đóng cửa ngày hôm trước, được điều chỉnh theo các sự kiện doanh nghiệp).

Bảng 37.1: Chế độ biên độ giá hằng ngày của Việt Nam theo sở giao dịch.
Sở giao dịch Biên độ hiện hành Ngày hiệu lực Biên độ trước đây
HOSE \(\pm\) 7% Tháng 6/2013 \(\pm\) 2% (2000), \(\pm\) 5% (2002)
HNX \(\pm\) 10% — Nhiều mức, ổn định ở \(\pm\) 10%
UPCoM \(\pm\) 15% — Biên độ rộng hơn phản ánh tính chất OTC

Điều quan trọng là các biên độ này bất đối xứng trong thực tế: chúng áp dụng như nhau cho biến động tăng và giảm, nhưng hệ quả kinh tế lại khác nhau. Một cổ phiếu chạm giá trần ngăn người mua đặt giá cao hơn (dư cầu vẫn còn), trong khi chạm giá sàn ngăn người bán chào giá thấp hơn (dư cung vẫn còn). Cả hai đều tạo ra các lệnh chưa khớp tràn sang các phiên giao dịch tiếp theo.

Load daily price data with limit hit indicators
from datacore import DataCoreClient

client = DataCoreClient()

# Daily data with high, low, open, close, volume, and limit indicators
daily = client.get_daily_prices(
    exchanges=['HOSE', 'HNX', 'UPCoM'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    fields=[
        'ticker', 'date', 'exchange',
        'open', 'high', 'low', 'close', 'adjusted_close',
        'reference_price', 'ceiling_price', 'floor_price',
        'volume', 'turnover_value',
        'limit_up_hit', 'limit_down_hit'
    ]
)

daily['date'] = pd.to_datetime(daily['date'])
daily = daily.sort_values(['ticker', 'date'])

# Compute daily returns
daily['daily_return'] = daily.groupby('ticker')['adjusted_close'].pct_change()

# Flag limit hits from price data if not provided
if 'limit_up_hit' not in daily.columns or daily['limit_up_hit'].isna().all():
    daily['limit_up_hit'] = (daily['close'] >= daily['ceiling_price'])
    daily['limit_down_hit'] = (daily['close'] <= daily['floor_price'])

# Exchange-specific limits
exchange_limits = {'HOSE': 0.07, 'HNX': 0.10, 'UPCoM': 0.15}
daily['limit_pct'] = daily['exchange'].map(exchange_limits)

print(f"Daily observations: {len(daily):,}")
print(f"Date range: {daily['date'].min()} to {daily['date'].max()}")
print(f"Unique tickers: {daily['ticker'].nunique()}")

37.2 Mức độ phổ biến của việc chạm biên độ

37.2.1 Tần suất tổng hợp

Các cổ phiếu Việt Nam chạm biên độ giá thường xuyên đến mức nào? Câu trả lời thay đổi rất lớn theo sở giao dịch, vốn hóa thị trường và điều kiện thị trường.

Compute limit hit frequencies by exchange and over time
# Overall frequencies
limit_stats = daily.groupby('exchange').agg(
    n_obs=('daily_return', 'count'),
    n_up=('limit_up_hit', 'sum'),
    n_down=('limit_down_hit', 'sum'),
).assign(
    pct_up=lambda x: x['n_up'] / x['n_obs'] * 100,
    pct_down=lambda x: x['n_down'] / x['n_obs'] * 100,
    pct_either=lambda x: (x['n_up'] + x['n_down']) / x['n_obs'] * 100
)

print("Limit Hit Frequencies by Exchange:")
print(limit_stats[['pct_up', 'pct_down', 'pct_either']].round(2).to_string())

# Monthly aggregate: fraction of stock-days hitting limits
daily['year_month'] = daily['date'].dt.to_period('M')
monthly_limit = (
    daily.groupby(['year_month', 'exchange'])
    .agg(
        n_obs=('daily_return', 'count'),
        n_up=('limit_up_hit', 'sum'),
        n_down=('limit_down_hit', 'sum')
    )
    .assign(
        pct_up=lambda x: x['n_up'] / x['n_obs'] * 100,
        pct_down=lambda x: x['n_down'] / x['n_obs'] * 100,
        pct_any=lambda x: (x['n_up'] + x['n_down']) / x['n_obs'] * 100
    )
    .reset_index()
)
monthly_limit['date'] = monthly_limit['year_month'].dt.to_timestamp()
Plot limit hit frequency over time
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True,
                          gridspec_kw={'height_ratios': [3, 1]})

hose_monthly = monthly_limit[monthly_limit['exchange'] == 'HOSE']

axes[0].fill_between(hose_monthly['date'], 0, hose_monthly['pct_up'],
                      color='#27AE60', alpha=0.6, label='Upper limit hits')
axes[0].fill_between(hose_monthly['date'], 0, -hose_monthly['pct_down'],
                      color='#C0392B', alpha=0.6, label='Lower limit hits')
axes[0].axhline(y=0, color='black', linewidth=0.5)
axes[0].set_ylabel('% of Stock-Days')
axes[0].set_title('Panel A: HOSE Daily Price Limit Hits')
axes[0].legend(loc='upper left')

# VN-Index for context
vnindex = client.get_index_returns(
    index='VNINDEX', start_date='2008-01-01', end_date='2024-12-31',
    frequency='monthly'
)
vnindex['date'] = pd.to_datetime(vnindex['date'])
axes[1].bar(vnindex['date'], vnindex['return'] * 100,
            color=np.where(vnindex['return'] > 0, '#27AE60', '#C0392B'),
            width=25, alpha=0.7)
axes[1].set_ylabel('VN-Index (%)')
axes[1].set_title('Panel B: VN-Index Monthly Returns')

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

37.2.2 Theo vốn hóa thị trường

Compute limit hit rates by size quintile
# Merge with lagged market cap
monthly_mcap = client.get_monthly_returns(
    exchanges=['HOSE'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    fields=['ticker', 'month_end', 'market_cap']
)
monthly_mcap['month_end'] = pd.to_datetime(monthly_mcap['month_end'])

# Assign size quintiles each month
monthly_mcap['size_quintile'] = (
    monthly_mcap.groupby('month_end')['market_cap']
    .transform(lambda x: pd.qcut(x.rank(method='first'), 5,
                                   labels=['Q1\n(Small)', 'Q2', 'Q3', 'Q4',
                                           'Q5\n(Big)']))
)

# Map to daily
daily_hose = daily[daily['exchange'] == 'HOSE'].copy()
daily_hose['month_end'] = daily_hose['date'].dt.to_period('M').dt.to_timestamp('M')
daily_hose = daily_hose.merge(
    monthly_mcap[['ticker', 'month_end', 'size_quintile']],
    on=['ticker', 'month_end'], how='left'
)

size_limit = (
    daily_hose.dropna(subset=['size_quintile'])
    .groupby('size_quintile')
    .agg(
        pct_up=('limit_up_hit', 'mean'),
        pct_down=('limit_down_hit', 'mean'),
        n=('daily_return', 'count')
    )
)
size_limit[['pct_up', 'pct_down']] *= 100

fig, ax = plt.subplots(figsize=(10, 5))

x = np.arange(len(size_limit))
width = 0.35
ax.bar(x - width / 2, size_limit['pct_up'], width,
       color='#27AE60', alpha=0.85, label='Upper limit', edgecolor='white')
ax.bar(x + width / 2, size_limit['pct_down'], width,
       color='#C0392B', alpha=0.85, label='Lower limit', edgecolor='white')

ax.set_xticks(x)
ax.set_xticklabels(size_limit.index)
ax.set_ylabel('% of Stock-Days')
ax.set_title('Price Limit Hit Frequency by Size Quintile (HOSE)')
ax.legend()

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

37.2.3 Các ngày chạm biên độ liên tiếp

Một lần chạm biên độ đơn lẻ có thể chỉ phản ánh một sự kiện thông tin lớn được hấp thụ trong vòng một ngày. Các lần chạm biên độ liên tiếp cùng chiều có vấn đề hơn vì chúng cho thấy biên độ đang chủ động ngăn cản việc khám phá giá trong nhiều ngày.

Analyze consecutive limit hit sequences
def count_consecutive_limits(group):
    """Count consecutive limit-up and limit-down sequences."""
    up_runs = []
    down_runs = []
    
    up_count = 0
    down_count = 0
    
    for _, row in group.iterrows():
        if row['limit_up_hit']:
            up_count += 1
            if down_count > 0:
                down_runs.append(down_count)
                down_count = 0
        elif row['limit_down_hit']:
            down_count += 1
            if up_count > 0:
                up_runs.append(up_count)
                up_count = 0
        else:
            if up_count > 0:
                up_runs.append(up_count)
            if down_count > 0:
                down_runs.append(down_count)
            up_count = 0
            down_count = 0
    
    if up_count > 0:
        up_runs.append(up_count)
    if down_count > 0:
        down_runs.append(down_count)
    
    return up_runs, down_runs

# Sample: compute for HOSE stocks
hose_tickers = daily_hose['ticker'].unique()
all_up_runs = []
all_down_runs = []

for ticker in hose_tickers:
    group = daily_hose[daily_hose['ticker'] == ticker].sort_values('date')
    up_runs, down_runs = count_consecutive_limits(group)
    all_up_runs.extend(up_runs)
    all_down_runs.extend(down_runs)

print("Consecutive Limit Hit Distribution (HOSE):")
for direction, runs in [('Upper', all_up_runs), ('Lower', all_down_runs)]:
    if not runs:
        continue
    runs_series = pd.Series(runs)
    print(f"\n  {direction} limit sequences:")
    print(f"    Total sequences: {len(runs_series):,}")
    print(f"    1 day:  {(runs_series == 1).sum():,} ({(runs_series == 1).mean():.1%})")
    print(f"    2 days: {(runs_series == 2).sum():,} ({(runs_series == 2).mean():.1%})")
    print(f"    3 days: {(runs_series == 3).sum():,} ({(runs_series == 3).mean():.1%})")
    print(f"    4+ days: {(runs_series >= 4).sum():,} ({(runs_series >= 4).mean():.1%})")
    print(f"    Max consecutive: {runs_series.max()}")

37.3 Sự méo mó của phân phối lợi nhuận

37.3.1 Cơ chế kiểm duyệt

Biên độ giá tạo ra kiểm duyệt Loại I (còn gọi là “cắt cụt tại một điểm đã biết”): lợi nhuận tiềm ẩn (không quan sát được) \(r^*\) được sinh ra từ một phân phối liên tục nào đó, nhưng lợi nhuận quan sát được là:

\[ r^{\text{obs}} = \begin{cases} \bar{L} & \text{if } r^* \geq \bar{L} \quad \text{(upper limit hit)} \\ r^* & \text{if } \underline{L} < r^* < \bar{L} \quad \text{(interior)} \\ \underline{L} & \text{if } r^* \leq \underline{L} \quad \text{(lower limit hit)} \end{cases} \tag{37.1}\]

trong đó \(\bar{L}\) và \(\underline{L}\) là biên độ trên và dưới. Với HOSE, \(\bar{L} = +0.07\) và \(\underline{L} = -0.07\).

Việc kiểm duyệt có những tác động có thể dự đoán lên phân phối quan sát được:

  1. Chệch trung bình. Nếu phân phối không bị kiểm duyệt là đối xứng, kiểm duyệt từ hai phía xấp xỉ giữ nguyên trung bình. Nhưng nếu phân phối lệch (như lợi nhuận cổ phiếu, với độ lệch âm), độ chệch có thể đi theo cả hai hướng.
  2. Đánh giá thấp phương sai. Kiểm duyệt luôn làm giảm phương sai quan sát được so với phương sai thực, vì các lợi nhuận cực đoan bị nén về các giá trị biên.
  3. Méo độ nhọn (kurtosis). Khối xác suất dồn lại tại các giá trị biên, tạo ra các đỉnh nhọn trong phân phối.
Visualize the effect of price limits on the return distribution
hose_returns = daily_hose['daily_return'].dropna()
hose_returns = hose_returns[hose_returns.abs() < 0.15]  # Remove data errors

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

# Panel A: Full distribution
axes[0].hist(hose_returns, bins=200, density=True,
             color='#2C5F8A', alpha=0.7, edgecolor='none')
axes[0].axvline(x=0.07, color='#C0392B', linewidth=2, linestyle='--',
                label='$\pm$ 7% limit')
axes[0].axvline(x=-0.07, color='#C0392B', linewidth=2, linestyle='--')
axes[0].set_xlabel('Daily Return')
axes[0].set_ylabel('Density')
axes[0].set_title('Panel A: HOSE Daily Return Distribution')
axes[0].legend()

# Panel B: Zoom on tails
bins_tail = np.linspace(-0.09, -0.05, 40)
bins_tail_up = np.linspace(0.05, 0.09, 40)

axes[1].hist(hose_returns[hose_returns < -0.04], bins=80, density=True,
             color='#C0392B', alpha=0.6, label='Left tail')
axes[1].hist(hose_returns[hose_returns > 0.04], bins=80, density=True,
             color='#27AE60', alpha=0.6, label='Right tail')
axes[1].axvline(x=0.07, color='black', linewidth=2)
axes[1].axvline(x=-0.07, color='black', linewidth=2)
axes[1].set_xlabel('Daily Return')
axes[1].set_ylabel('Density')
axes[1].set_title('Panel B: Tail Behavior at Limits')
axes[1].legend()

plt.tight_layout()
plt.show()

# Quantify the spike
n_at_upper = ((hose_returns >= 0.069) & (hose_returns <= 0.071)).sum()
n_at_lower = ((hose_returns >= -0.071) & (hose_returns <= -0.069)).sum()
n_total = len(hose_returns)
print(f"Observations at upper limit ($\pm$ 0.1% of 7%): {n_at_upper:,} "
      f"({n_at_upper/n_total:.2%})")
print(f"Observations at lower limit: {n_at_lower:,} "
      f"({n_at_lower/n_total:.2%})")
Hình 37.3

37.3.2 So sánh HOSE, HNX và UPCoM

Ba sở giao dịch của Việt Nam có độ rộng biên độ khác nhau, tạo ra một thí nghiệm tự nhiên: nếu biên độ làm méo phân phối, biên độ rộng hơn sẽ cho ra các phân phối gần với chuẩn so sánh không bị kiểm duyệt hơn.

Compare return distributions across exchanges
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))

for i, (exchange, limit, color) in enumerate([
    ('HOSE', 0.07, '#2C5F8A'),
    ('HNX', 0.10, '#C0392B'),
    ('UPCoM', 0.15, '#27AE60')
]):
    rets = daily[daily['exchange'] == exchange]['daily_return'].dropna()
    rets = rets[rets.abs() < limit + 0.05]
    
    axes[i].hist(rets, bins=150, density=True,
                  color=color, alpha=0.7, edgecolor='none')
    axes[i].axvline(x=limit, color='black', linewidth=1.5, linestyle='--')
    axes[i].axvline(x=-limit, color='black', linewidth=1.5, linestyle='--')
    axes[i].set_title(f'{exchange} ($\pm$ {limit*100:.0f}%)')
    axes[i].set_xlabel('Daily Return')
    if i == 0:
        axes[i].set_ylabel('Density')
    
    # Stats
    pct_at_limit = ((rets.abs() >= limit - 0.001).sum() / len(rets) * 100)
    axes[i].text(0.95, 0.95, f'At limit: {pct_at_limit:.2f}%',
                  transform=axes[i].transAxes, ha='right', va='top',
                  fontsize=9, bbox=dict(boxstyle='round', facecolor='white',
                                         alpha=0.8))

plt.suptitle('Return Distributions by Exchange', fontsize=13)
plt.tight_layout()
plt.show()
Hình 37.4

37.4 Độ chệch phương sai do kiểm duyệt

37.4.1 Độ chệch giải tích

Nếu lợi nhuận thực tuân theo \(r^* \sim N(\mu, \sigma^2)\), phương sai của lợi nhuận bị kiểm duyệt có thể được suy ra bằng giải tích. Đặt \(a = (\underline{L} - \mu) / \sigma\) và \(b = (\bar{L} - \mu) / \sigma\):

\[ \text{Var}(r^{\text{obs}}) = \sigma^2 \left[1 - \frac{b \phi(b) - a \phi(a)}{\Phi(b) - \Phi(a)} - \left(\frac{\phi(a) - \phi(b)}{\Phi(b) - \Phi(a)}\right)^2 \right] + \text{boundary terms} \tag{37.2}\]

trong đó \(\phi\) và \(\Phi\) là hàm mật độ (PDF) và hàm phân phối tích lũy (CDF) của phân phối chuẩn tắc. Kết quả then chốt là \(\text{Var}(r^{\text{obs}}) < \sigma^2\) luôn đúng—kiểm duyệt đánh giá thấp phương sai một cách có hệ thống.

Simulate the variance bias from censoring under different volatility levels
def simulate_censored_variance(true_sigma, limit, n_sim=100000, mu=0):
    """Simulate observed vs true variance under censoring."""
    rng = np.random.default_rng(42)
    r_star = rng.normal(mu, true_sigma, n_sim)
    r_obs = np.clip(r_star, -limit, limit)
    
    var_true = np.var(r_star)
    var_obs = np.var(r_obs)
    
    pct_censored = ((r_star >= limit) | (r_star <= -limit)).mean()
    
    return {
        'true_sigma': true_sigma,
        'true_var': var_true,
        'obs_var': var_obs,
        'var_ratio': var_obs / var_true,
        'bias_pct': (1 - var_obs / var_true) * 100,
        'pct_censored': pct_censored * 100
    }

# Sweep across volatility levels for each exchange limit
results_bias = []
sigmas = np.linspace(0.005, 0.08, 50)

for limit_name, limit in [('HOSE $\pm$ 7%', 0.07), ('HNX $\pm$ 10%', 0.10),
                            ('UPCoM $\pm$ 15%', 0.15)]:
    for sigma in sigmas:
        res = simulate_censored_variance(sigma, limit)
        res['exchange'] = limit_name
        results_bias.append(res)

bias_df = pd.DataFrame(results_bias)
Plot variance bias across volatility levels
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

colors_exch = {'HOSE $\pm$ 7%': '#2C5F8A', 'HNX $\pm$ 10%': '#C0392B',
                'UPCoM $\pm$ 15%': '#27AE60'}

for exch in colors_exch:
    subset = bias_df[bias_df['exchange'] == exch]
    axes[0].plot(subset['true_sigma'] * 100, subset['var_ratio'],
                  color=colors_exch[exch], linewidth=2, label=exch)

axes[0].axhline(y=1, color='gray', linewidth=0.5, linestyle='--')
axes[0].set_xlabel('True Daily Volatility (%)')
axes[0].set_ylabel('Observed / True Variance')
axes[0].set_title('Panel A: Variance Ratio')
axes[0].legend()
axes[0].set_ylim([0.5, 1.05])

for exch in colors_exch:
    subset = bias_df[bias_df['exchange'] == exch]
    axes[1].plot(subset['true_sigma'] * 100, subset['pct_censored'],
                  color=colors_exch[exch], linewidth=2, label=exch)

axes[1].set_xlabel('True Daily Volatility (%)')
axes[1].set_ylabel('% of Returns Censored')
axes[1].set_title('Panel B: Censoring Rate')
axes[1].legend()

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

37.4.2 Độ chệch phương sai thực nghiệm theo quy mô

Estimate the empirical variance bias by comparing HOSE stocks that transferred from HNX
# Cross-listed stocks or transfer events provide a natural experiment:
# Same stock, different limit regime
# Alternative: compare variance of HOSE returns to variance of the same
# stock's returns implied from intraday data (not censored by closing limit)

# Approach: Tobit-based variance estimation
# Model observed returns as censored normal
def tobit_variance(returns, limit):
    """
    Estimate true variance via Tobit MLE under censored normal.
    """
    r = returns.dropna().values
    upper = limit
    lower = -limit
    
    # Classify observations
    at_upper = r >= (upper - 1e-6)
    at_lower = r <= (lower + 1e-6)
    interior = ~at_upper & ~at_lower
    
    if interior.sum() < 20:
        return np.nan, np.nan
    
    def neg_loglik(params):
        mu, log_sigma = params
        sigma = np.exp(log_sigma)
        
        ll = 0
        # Interior observations
        if interior.sum() > 0:
            ll += np.sum(stats.norm.logpdf(r[interior], mu, sigma))
        # Upper censored
        if at_upper.sum() > 0:
            ll += np.sum(np.log(1 - stats.norm.cdf(upper, mu, sigma) + 1e-15))
        # Lower censored
        if at_lower.sum() > 0:
            ll += np.sum(np.log(stats.norm.cdf(lower, mu, sigma) + 1e-15))
        
        return -ll
    
    # Initial values
    mu0 = r[interior].mean() if interior.sum() > 0 else 0
    sigma0 = r[interior].std() if interior.sum() > 0 else r.std()
    
    try:
        result = optimize.minimize(
            neg_loglik, [mu0, np.log(max(sigma0, 1e-6))],
            method='Nelder-Mead', options={'maxiter': 5000}
        )
        mu_hat = result.x[0]
        sigma_hat = np.exp(result.x[1])
        return mu_hat, sigma_hat
    except Exception:
        return np.nan, np.nan

# Estimate for each HOSE stock
hose_stocks = daily_hose.groupby('ticker').filter(
    lambda x: len(x) >= 250
)['ticker'].unique()

tobit_results = []
for ticker in hose_stocks[:500]:  # Sample for speed
    rets = daily_hose[daily_hose['ticker'] == ticker]['daily_return'].dropna()
    if len(rets) < 250:
        continue
    
    naive_sigma = rets.std()
    mu_hat, sigma_hat = tobit_variance(rets, 0.07)
    
    if np.isfinite(sigma_hat) and sigma_hat > 0:
        tobit_results.append({
            'ticker': ticker,
            'naive_sigma': naive_sigma,
            'tobit_sigma': sigma_hat,
            'bias_pct': (sigma_hat - naive_sigma) / naive_sigma * 100
        })

tobit_df = pd.DataFrame(tobit_results)

print("Tobit vs Naive Volatility Estimation (HOSE):")
print(f"  Mean naive σ:  {tobit_df['naive_sigma'].mean():.4f}")
print(f"  Mean Tobit σ:  {tobit_df['tobit_sigma'].mean():.4f}")
print(f"  Mean bias:     {tobit_df['bias_pct'].mean():.1f}%")
print(f"  Median bias:   {tobit_df['bias_pct'].median():.1f}%")
print(f"  Max bias:      {tobit_df['bias_pct'].max():.1f}%")
Plot Tobit vs naive volatility comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

axes[0].scatter(tobit_df['naive_sigma'] * 100,
                 tobit_df['tobit_sigma'] * 100,
                 s=15, alpha=0.5, color='#2C5F8A', edgecolors='none')
lim = max(tobit_df['tobit_sigma'].max(), tobit_df['naive_sigma'].max()) * 100 + 0.5
axes[0].plot([0, lim], [0, lim], 'k--', linewidth=1)
axes[0].set_xlabel('Naive σ (% daily)')
axes[0].set_ylabel('Tobit σ (% daily)')
axes[0].set_title('Panel A: Tobit vs Naive Volatility')

axes[1].hist(tobit_df['bias_pct'], bins=50, color='#C0392B',
             alpha=0.7, edgecolor='white', density=True)
axes[1].axvline(x=0, color='black', linewidth=1)
axes[1].axvline(x=tobit_df['bias_pct'].median(), color='#2C5F8A',
                linewidth=2, linestyle='--',
                label=f"Median: {tobit_df['bias_pct'].median():.1f}%")
axes[1].set_xlabel('Bias (%): (Tobit - Naive) / Naive')
axes[1].set_ylabel('Density')
axes[1].set_title('Panel B: Distribution of Correction')
axes[1].legend()

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

37.5 Ước lượng biến động dưới biên độ giá

37.5.1 Các bộ ước lượng dựa trên khoảng giá cao–thấp trong ngày (range-based)

Các bộ ước lượng biến động dựa trên khoảng giá cao–thấp trong ngày (range-based) dùng giá cao nhất và thấp nhất hằng ngày thay vì lợi nhuận đóng cửa–đóng cửa, nên vững một phần trước kiểm duyệt giá đóng cửa (vì giá trong ngày có thể tiến gần nhưng không bị kiểm duyệt tại cùng các điểm). Tuy nhiên, chúng bị chệch khi chính quỹ đạo giá trong ngày bị biên độ ràng buộc.

Implement range-based volatility estimators
def parkinson_vol(high, low, n_periods=20):
    """
    Parkinson (1980) range-based volatility estimator.
    σ² = (1/4ln2) * E[(ln(H/L))²]
    """
    log_hl = np.log(high / low)
    var = (1 / (4 * np.log(2))) * (log_hl ** 2)
    return np.sqrt(var.rolling(n_periods).mean())

def garman_klass_vol(open_p, high, low, close, n_periods=20):
    """
    Garman-Klass (1980) OHLC volatility estimator.
    More efficient than Parkinson by using open and close.
    """
    log_hl = np.log(high / low)
    log_co = np.log(close / open_p)
    var = 0.5 * log_hl ** 2 - (2 * np.log(2) - 1) * log_co ** 2
    return np.sqrt(var.rolling(n_periods).mean())

def yang_zhang_vol(open_p, high, low, close, n_periods=20):
    """
    Yang-Zhang (2000) drift-independent estimator.
    Combines overnight, Rogers-Satchell, and open-to-close components.
    """
    log_oc = np.log(open_p / close.shift(1))  # Overnight
    log_co = np.log(close / open_p)
    log_ho = np.log(high / open_p)
    log_lo = np.log(low / open_p)
    
    # Rogers-Satchell component
    rs = log_ho * (log_ho - log_co) + log_lo * (log_lo - log_co)
    
    k = 0.34 / (1.34 + (n_periods + 1) / (n_periods - 1))
    
    var_overnight = log_oc.rolling(n_periods).var()
    var_open_close = log_co.rolling(n_periods).var()
    var_rs = rs.rolling(n_periods).mean()
    
    var = var_overnight + k * var_open_close + (1 - k) * var_rs
    return np.sqrt(var.clip(lower=0))

# Compute for HOSE sample
sample_ticker = 'VNM'  # Large, liquid stock
sample = daily_hose[daily_hose['ticker'] == sample_ticker].copy()
sample = sample.sort_values('date').set_index('date')

# Close-to-close realized vol
sample['cc_vol'] = sample['daily_return'].rolling(20).std() * np.sqrt(252)

# Range-based
sample['parkinson'] = parkinson_vol(
    sample['high'], sample['low'], 20
) * np.sqrt(252)

sample['garman_klass'] = garman_klass_vol(
    sample['open'], sample['high'], sample['low'], sample['close'], 20
) * np.sqrt(252)

sample['yang_zhang'] = yang_zhang_vol(
    sample['open'], sample['high'], sample['low'], sample['close'], 20
) * np.sqrt(252)
Plot and compare volatility estimators over time
fig, ax = plt.subplots(figsize=(14, 5))

ax.plot(sample.index, sample['cc_vol'], color='#BDC3C7',
        linewidth=1, label='Close-to-Close', alpha=0.8)
ax.plot(sample.index, sample['parkinson'], color='#2C5F8A',
        linewidth=1.5, label='Parkinson')
ax.plot(sample.index, sample['yang_zhang'], color='#C0392B',
        linewidth=1.5, label='Yang-Zhang')

ax.set_ylabel('Annualized Volatility')
ax.set_title(f'Volatility Estimators: {sample_ticker}')
ax.legend(ncol=3)
ax.set_ylim([0, ax.get_ylim()[1]])

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

37.5.2 Mô hình GARCH với lợi nhuận bị kiểm duyệt

Các mô hình GARCH chuẩn giả định lợi nhuận được quan sát đầy đủ. Khi lợi nhuận bị kiểm duyệt, hàm log-hợp lý phải tính đến khối xác suất tại các giá trị biên. Chúng ta triển khai mô hình GARCH(1,1) có kiểm duyệt:

\[ r_t = \mu + \varepsilon_t, \quad \varepsilon_t = \sigma_t z_t, \quad z_t \sim N(0, 1) \tag{37.3}\]

\[ \sigma_t^2 = \omega + \alpha \varepsilon_{t-1}^2 + \beta \sigma_{t-1}^2 \tag{37.4}\]

Hàm log-hợp lý có kiểm duyệt thay thế mật độ chuẩn tắc cho các quan sát chạm biên:

\[ \ell_t = \begin{cases} \log \phi\left(\frac{r_t - \mu}{\sigma_t}\right) - \log \sigma_t & \text{if interior} \\ \log \Phi\left(\frac{\underline{L} - \mu}{\sigma_t}\right) & \text{if lower limit} \\ \log\left[1 - \Phi\left(\frac{\bar{L} - \mu}{\sigma_t}\right)\right] & \text{if upper limit} \end{cases} \tag{37.5}\]

Implement censored GARCH(1,1) estimation
def censored_garch11(returns, limit, max_iter=500):
    """
    GARCH(1,1) with censored normal likelihood.
    
    Parameters
    ----------
    returns : array-like
        Observed daily returns (censored at $\pm$ limit).
    limit : float
        Price limit (e.g., 0.07 for HOSE).
    
    Returns
    -------
    Dictionary with estimated parameters and conditional variances.
    """
    r = np.array(returns, dtype=float)
    T = len(r)
    upper = limit
    lower = -limit
    
    at_upper = r >= (upper - 1e-6)
    at_lower = r <= (lower + 1e-6)
    interior = ~at_upper & ~at_lower
    
    def neg_loglik(params):
        mu, omega, alpha, beta = params
        
        if omega <= 0 or alpha < 0 or beta < 0 or (alpha + beta) >= 1:
            return 1e10
        
        sigma2 = np.zeros(T)
        sigma2[0] = omega / (1 - alpha - beta) if (alpha + beta) < 1 else r.var()
        
        ll = 0
        for t in range(T):
            if t > 0:
                eps = r[t - 1] - mu
                sigma2[t] = omega + alpha * eps ** 2 + beta * sigma2[t - 1]
            
            sigma2[t] = max(sigma2[t], 1e-10)
            sigma = np.sqrt(sigma2[t])
            
            if interior[t]:
                ll += stats.norm.logpdf(r[t], mu, sigma)
            elif at_upper[t]:
                prob = 1 - stats.norm.cdf(upper, mu, sigma)
                ll += np.log(max(prob, 1e-15))
            elif at_lower[t]:
                prob = stats.norm.cdf(lower, mu, sigma)
                ll += np.log(max(prob, 1e-15))
        
        return -ll
    
    # Initial values from standard GARCH
    mu0 = r[interior].mean() if interior.any() else 0
    var0 = r[interior].var() if interior.any() else r.var()
    
    try:
        result = optimize.minimize(
            neg_loglik,
            [mu0, var0 * 0.05, 0.10, 0.85],
            method='Nelder-Mead',
            options={'maxiter': max_iter, 'xatol': 1e-8}
        )
        mu, omega, alpha, beta = result.x
        
        # Reconstruct conditional variance
        sigma2 = np.zeros(T)
        sigma2[0] = omega / max(1 - alpha - beta, 0.01)
        for t in range(1, T):
            eps = r[t - 1] - mu
            sigma2[t] = omega + alpha * eps ** 2 + beta * sigma2[t - 1]
        
        return {
            'mu': mu, 'omega': omega, 'alpha': alpha, 'beta': beta,
            'persistence': alpha + beta,
            'uncond_var': omega / max(1 - alpha - beta, 0.01),
            'sigma2': sigma2,
            'loglik': -result.fun,
            'converged': result.success,
            'n_censored': at_upper.sum() + at_lower.sum(),
            'pct_censored': (at_upper.sum() + at_lower.sum()) / T * 100
        }
    except Exception as e:
        return None

# Compare standard vs censored GARCH for a volatile stock
volatile_stock = daily_hose.groupby('ticker')['limit_up_hit'].mean()
volatile_stock = volatile_stock.sort_values(ascending=False).head(20)
test_ticker = volatile_stock.index[0]

test_returns = (
    daily_hose[daily_hose['ticker'] == test_ticker]
    .sort_values('date')['daily_return']
    .dropna()
    .values
)

# Standard GARCH (arch library)
std_garch = arch_model(test_returns * 100, vol='GARCH', p=1, q=1,
                         mean='Constant', dist='normal')
std_result = std_garch.fit(disp='off')

# Censored GARCH
cens_result = censored_garch11(test_returns, limit=0.07)

print(f"Stock: {test_ticker}")
print(f"Observations: {len(test_returns)}, "
      f"Censored: {cens_result['pct_censored']:.1f}%\n")

print(f"{'Parameter':<12} {'Standard':>12} {'Censored':>12}")
print("-" * 36)
print(f"{'μ':<12} {std_result.params['mu']/100:>12.6f} "
      f"{cens_result['mu']:>12.6f}")
print(f"{'ω':<12} {std_result.params['omega']/10000:>12.8f} "
      f"{cens_result['omega']:>12.8f}")
print(f"{'α':<12} {std_result.params['alpha[1]']:>12.4f} "
      f"{cens_result['alpha']:>12.4f}")
print(f"{'β':<12} {std_result.params['beta[1]']:>12.4f} "
      f"{cens_result['beta']:>12.4f}")
print(f"{'α+β':<12} "
      f"{std_result.params['alpha[1]']+std_result.params['beta[1]']:>12.4f} "
      f"{cens_result['persistence']:>12.4f}")
print(f"{'Uncond σ':<12} "
      f"{np.sqrt(std_result.params['omega']/(1-std_result.params['alpha[1]']-std_result.params['beta[1]'])/10000):>12.4f} "
      f"{np.sqrt(cens_result['uncond_var']):>12.4f}")
Plot standard vs censored GARCH conditional volatility
test_data = daily_hose[daily_hose['ticker'] == test_ticker].sort_values('date')
dates = test_data['date'].values[-len(test_returns):]

fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True,
                          gridspec_kw={'height_ratios': [1, 2]})

# Panel A: Returns with limit hits highlighted
axes[0].plot(dates, test_returns, color='#2C5F8A', linewidth=0.5, alpha=0.7)
limit_up_mask = test_returns >= 0.069
limit_down_mask = test_returns <= -0.069
axes[0].scatter(dates[limit_up_mask], test_returns[limit_up_mask],
                 color='#27AE60', s=10, zorder=3, label='Upper limit')
axes[0].scatter(dates[limit_down_mask], test_returns[limit_down_mask],
                 color='#C0392B', s=10, zorder=3, label='Lower limit')
axes[0].axhline(y=0.07, color='gray', linewidth=0.5, linestyle='--')
axes[0].axhline(y=-0.07, color='gray', linewidth=0.5, linestyle='--')
axes[0].set_ylabel('Return')
axes[0].set_title(f'Panel A: Daily Returns ({test_ticker})')
axes[0].legend(fontsize=8)

# Panel B: Conditional volatility
std_sigma = std_result.conditional_volatility / 100  # Convert from % to decimal
cens_sigma = np.sqrt(cens_result['sigma2'])

axes[1].plot(dates, std_sigma * np.sqrt(252), color='#BDC3C7',
             linewidth=1, label='Standard GARCH')
axes[1].plot(dates, cens_sigma * np.sqrt(252), color='#C0392B',
             linewidth=1.5, label='Censored GARCH')
axes[1].set_ylabel('Annualized Conditional σ')
axes[1].set_title('Panel B: Conditional Volatility')
axes[1].legend()

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

37.6 Tác động lên các kiểm định định giá tài sản

37.6.1 Sự suy giảm của beta

Biên độ giá làm suy giảm hiệp phương sai giữa lợi nhuận cổ phiếu và lợi nhuận nhân tố, khiến các ước lượng beta chệch về không. Trực giác rất đơn giản: vào những ngày thị trường biến động 3% nhưng lợi nhuận thực của một cổ phiếu lẽ ra là 6%, lợi nhuận quan sát được bị chặn ở 7%, làm đánh giá thấp độ nhạy của cổ phiếu.

Quantify beta attenuation from price limit censoring
# Compare betas estimated with all days vs excluding limit-hit days
# Also compare betas from HOSE stocks vs same stocks if they were on HNX

daily_hose_merged = daily_hose.merge(
    client.get_index_returns('VNINDEX', frequency='daily',
                              start_date='2008-01-01',
                              end_date='2024-12-31')[['date', 'return']],
    on='date', how='left'
).rename(columns={'return': 'mkt_return'})

# For each stock, estimate beta:
# (a) Using all days
# (b) Excluding limit-hit days
# (c) Tobit-corrected (censored regression)
beta_comparison = []

for ticker in hose_stocks[:300]:
    stock = daily_hose_merged[daily_hose_merged['ticker'] == ticker].dropna(
        subset=['daily_return', 'mkt_return']
    )
    if len(stock) < 250:
        continue
    
    # (a) All days
    X_all = sm.add_constant(stock['mkt_return'])
    model_all = sm.OLS(stock['daily_return'], X_all).fit()
    beta_all = model_all.params['mkt_return']
    
    # (b) Exclude limit-hit days
    interior = stock[~stock['limit_up_hit'] & ~stock['limit_down_hit']]
    if len(interior) < 200:
        continue
    X_int = sm.add_constant(interior['mkt_return'])
    model_int = sm.OLS(interior['daily_return'], X_int).fit()
    beta_interior = model_int.params['mkt_return']
    
    # Limit hit frequency for this stock
    pct_limit = (stock['limit_up_hit'].sum() + stock['limit_down_hit'].sum()) / len(stock) * 100
    
    beta_comparison.append({
        'ticker': ticker,
        'beta_all': beta_all,
        'beta_interior': beta_interior,
        'beta_diff_pct': (beta_interior - beta_all) / abs(beta_all) * 100,
        'pct_limit_hits': pct_limit
    })

beta_df = pd.DataFrame(beta_comparison)

print("Beta Attenuation from Price Limits:")
print(f"  Mean β (all days):      {beta_df['beta_all'].mean():.3f}")
print(f"  Mean β (interior only): {beta_df['beta_interior'].mean():.3f}")
print(f"  Mean difference:        {beta_df['beta_diff_pct'].mean():.1f}%")
print(f"  Correlation(diff, limit_freq): "
      f"{beta_df['beta_diff_pct'].corr(beta_df['pct_limit_hits']):.3f}")
Visualize beta attenuation
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

axes[0].scatter(beta_df['beta_all'], beta_df['beta_interior'],
                 s=15, alpha=0.5, color='#2C5F8A', edgecolors='none')
lim = max(beta_df['beta_all'].abs().max(),
           beta_df['beta_interior'].abs().max()) + 0.2
axes[0].plot([-0.5, lim], [-0.5, lim], 'k--', linewidth=1)
axes[0].set_xlabel('β (all days)')
axes[0].set_ylabel('β (interior only)')
axes[0].set_title('Panel A: Beta with vs without Limit Days')

axes[1].scatter(beta_df['pct_limit_hits'], beta_df['beta_diff_pct'],
                 s=15, alpha=0.5, color='#C0392B', edgecolors='none')
# Add regression line
z = np.polyfit(beta_df['pct_limit_hits'], beta_df['beta_diff_pct'], 1)
x_line = np.linspace(0, beta_df['pct_limit_hits'].max(), 100)
axes[1].plot(x_line, np.polyval(z, x_line), 'k-', linewidth=1.5)
axes[1].axhline(y=0, color='gray', linewidth=0.5)
axes[1].set_xlabel('Limit Hit Frequency (%)')
axes[1].set_ylabel('Beta Increase When Excluding Limit Days (%)')
axes[1].set_title('Panel B: Attenuation vs Limit Frequency')

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

37.6.2 Tác động lên phần bù nhân tố

Nếu beta bị suy giảm do kiểm duyệt, thì các ước lượng phần bù rủi ro Fama-MacBeth chéo bị chệch lên (vì mẫu số của hệ số độ dốc quá nhỏ). Chúng ta định lượng hiệu ứng này:

Compare factor premia with and without limit-hit days
# Monthly returns: compute with all days vs excluding limit-hit days
# Then construct factors under each definition

monthly_all = (
    daily_hose.groupby(['ticker', daily_hose['date'].dt.to_period('M')])
    .agg(
        ret_all=('daily_return', lambda x: (1 + x).prod() - 1),
        ret_interior=('daily_return',
                       lambda x: (1 + x[~x.name.map(
                           lambda idx: daily_hose.loc[idx, 'limit_up_hit'] |
                                        daily_hose.loc[idx, 'limit_down_hit']
                       ).values]).prod() - 1 if len(x) > 0 else np.nan),
        n_limit_days=('limit_up_hit',
                       lambda x: x.sum() + daily_hose.loc[x.index, 'limit_down_hit'].sum()),
        n_trading_days=('daily_return', 'count')
    )
    .reset_index()
)

print("Impact on Monthly Returns:")
print(f"  Mean monthly return (all days):     "
      f"{monthly_all['ret_all'].mean():.4f}")
print(f"  Mean monthly return (interior):     "
      f"{monthly_all['ret_interior'].mean():.4f}")
print(f"  Avg limit days per stock-month:     "
      f"{monthly_all['n_limit_days'].mean():.2f}")

37.7 Hiệu ứng nam châm

37.7.1 Biên độ có hút giá không?

Giả thuyết hiệu ứng nam châm cho rằng biên độ giá, thay vì làm nguội thị trường, thực ra hút giá về phía biên khi các nhà giao dịch vội vàng đặt lệnh trước khi cổ phiếu bị khóa (Cho và c.s. 2003). Nếu một cổ phiếu đang tiến gần giá trần, người mua đẩy nhanh việc đặt lệnh để tránh không khớp được lệnh, tạo ra một cuộc đổ xô tự thực hiện về phía biên.

Chúng ta kiểm định hiệu ứng nam châm bằng cách xem xét tốc độ giá di chuyển về phía biên, có điều kiện khi tiến gần biên:

Test for the magnet effect using intraday price trajectories
# Approach: for days that eventually hit the limit,
# compare the return in the last hour vs the first hour
# relative to non-limit days with similar initial trajectories

# Without intraday data, we use a cross-day approach:
# On day t, if the stock is within X% of the limit at some point,
# what is the probability of hitting the limit on day t vs day t+1?

# Simpler test: return continuation after near-limit days
def magnet_test(daily_df, limit, proximity_threshold=0.8):
    """
    Test for the magnet effect.
    
    For each stock-day, classify:
    - 'near_limit_up': return in [proximity_threshold * limit, limit)
    - 'near_limit_down': return in (-limit, -proximity_threshold * limit]
    - 'hit_limit_up': return = limit
    - 'hit_limit_down': return = -limit
    - 'normal': all others
    
    Then examine next-day behavior.
    """
    df = daily_df.copy()
    df['abs_ret'] = df['daily_return'].abs()
    
    df['near_up'] = (df['daily_return'] >= proximity_threshold * limit) & \
                     (df['daily_return'] < limit - 0.001)
    df['near_down'] = (df['daily_return'] <= -proximity_threshold * limit) & \
                       (df['daily_return'] > -limit + 0.001)
    
    df['next_return'] = df.groupby('ticker')['daily_return'].shift(-1)
    df['next_limit_up'] = df.groupby('ticker')['limit_up_hit'].shift(-1)
    df['next_limit_down'] = df.groupby('ticker')['limit_down_hit'].shift(-1)
    
    results = {}
    
    # Near upper limit
    near_up = df[df['near_up']]
    if len(near_up) > 100:
        results['near_up'] = {
            'n': len(near_up),
            'next_day_return': near_up['next_return'].mean(),
            'prob_next_limit_up': near_up['next_limit_up'].mean(),
            'prob_next_limit_down': near_up['next_limit_down'].mean()
        }
    
    # At upper limit
    at_up = df[df['limit_up_hit']]
    if len(at_up) > 100:
        results['at_up'] = {
            'n': len(at_up),
            'next_day_return': at_up['next_return'].mean(),
            'prob_next_limit_up': at_up['next_limit_up'].mean(),
            'prob_next_limit_down': at_up['next_limit_down'].mean()
        }
    
    # Near lower limit
    near_down = df[df['near_down']]
    if len(near_down) > 100:
        results['near_down'] = {
            'n': len(near_down),
            'next_day_return': near_down['next_return'].mean(),
            'prob_next_limit_up': near_down['next_limit_up'].mean(),
            'prob_next_limit_down': near_down['next_limit_down'].mean()
        }
    
    # At lower limit
    at_down = df[df['limit_down_hit']]
    if len(at_down) > 100:
        results['at_down'] = {
            'n': len(at_down),
            'next_day_return': at_down['next_return'].mean(),
            'prob_next_limit_up': at_down['next_limit_up'].mean(),
            'prob_next_limit_down': at_down['next_limit_down'].mean()
        }
    
    # Normal days (benchmark)
    normal = df[~df['near_up'] & ~df['near_down'] &
                 ~df['limit_up_hit'] & ~df['limit_down_hit']]
    results['normal'] = {
        'n': len(normal),
        'next_day_return': normal['next_return'].mean(),
        'prob_next_limit_up': normal['next_limit_up'].mean(),
        'prob_next_limit_down': normal['next_limit_down'].mean()
    }
    
    return pd.DataFrame(results).T

magnet = magnet_test(daily_hose, 0.07, proximity_threshold=0.8)
print("Magnet Effect Test (HOSE):")
print(magnet.round(4).to_string())
Visualize magnet effect evidence
# More granular: bin today's return and compute next-day statistics
bins = np.arange(-0.075, 0.08, 0.005)
daily_hose_next = daily_hose.copy()
daily_hose_next['next_return'] = (
    daily_hose_next.groupby('ticker')['daily_return'].shift(-1)
)
daily_hose_next['ret_bin'] = pd.cut(daily_hose_next['daily_return'],
                                      bins=bins, labels=False)

bin_stats = (
    daily_hose_next.dropna(subset=['ret_bin', 'next_return'])
    .groupby('ret_bin')
    .agg(
        mean_ret=('daily_return', 'mean'),
        next_ret=('next_return', 'mean'),
        n=('next_return', 'count')
    )
)

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

# Panel A: Next-day return by today's return
axes[0].bar(bin_stats['mean_ret'] * 100, bin_stats['next_ret'] * 100,
            width=0.4,
            color=np.where(bin_stats['next_ret'] > 0, '#27AE60', '#C0392B'),
            alpha=0.7, edgecolor='white')
axes[0].axhline(y=0, color='black', linewidth=0.5)
axes[0].axvline(x=7, color='gray', linewidth=1, linestyle='--')
axes[0].axvline(x=-7, color='gray', linewidth=1, linestyle='--')
axes[0].set_xlabel("Today's Return (%)")
axes[0].set_ylabel('Next-Day Return (%)')
axes[0].set_title('Panel A: Next-Day Return by Current Return')

# Panel B: Continuation probability
# Probability of same-direction move next day
daily_hose_next['continuation'] = (
    np.sign(daily_hose_next['daily_return']) ==
    np.sign(daily_hose_next['next_return'])
)

cont_by_bin = (
    daily_hose_next.dropna(subset=['ret_bin', 'continuation'])
    .groupby('ret_bin')
    .agg(
        mean_ret=('daily_return', 'mean'),
        cont_prob=('continuation', 'mean'),
        n=('continuation', 'count')
    )
)

axes[1].scatter(cont_by_bin['mean_ret'] * 100, cont_by_bin['cont_prob'] * 100,
                 color='#2C5F8A', s=40, alpha=0.7)
axes[1].axhline(y=50, color='gray', linewidth=0.5, linestyle='--')
axes[1].axvline(x=7, color='gray', linewidth=1, linestyle='--')
axes[1].axvline(x=-7, color='gray', linewidth=1, linestyle='--')
axes[1].set_xlabel("Today's Return (%)")
axes[1].set_ylabel('Continuation Probability (%)')
axes[1].set_title('Panel B: Same-Direction Move Next Day')

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

37.8 Giả thuyết khám phá giá bị trì hoãn

37.8.1 Lan tỏa biến động

Nếu biên độ ngăn cản việc điều chỉnh giá đầy đủ vào ngày \(t\), phần điều chỉnh còn lại tràn sang ngày \(t+1\) (và có thể xa hơn). Điều này dự báo biến động cao hơn vào ngày sau khi chạm biên, và tự tương quan lợi nhuận dương (tiếp diễn theo hướng của lần chạm biên). Kim và Rhee (1997) tìm thấy bằng chứng mạnh về điều này tại Sở Giao dịch Chứng khoán Tokyo.

Test for volatility spillover after limit hit days
# Compare volatility and return on limit-hit days vs day after
spillover_data = daily_hose.copy()
spillover_data['prev_limit_up'] = (
    spillover_data.groupby('ticker')['limit_up_hit'].shift(1)
)
spillover_data['prev_limit_down'] = (
    spillover_data.groupby('ticker')['limit_down_hit'].shift(1)
)
spillover_data['abs_return'] = spillover_data['daily_return'].abs()

# Classify days
conditions = {
    'After upper limit': spillover_data['prev_limit_up'] == True,
    'After lower limit': spillover_data['prev_limit_down'] == True,
    'Normal day': (spillover_data['prev_limit_up'] == False) &
                   (spillover_data['prev_limit_down'] == False)
}

print("Volatility Spillover After Limit Hits:")
print(f"{'Condition':<25} {'Mean |r|':>10} {'Mean r':>10} "
      f"{'σ(r)':>10} {'N':>12}")
print("-" * 67)

for label, mask in conditions.items():
    subset = spillover_data[mask].dropna(subset=['daily_return'])
    print(f"{label:<25} "
          f"{subset['abs_return'].mean()*100:>10.3f}% "
          f"{subset['daily_return'].mean()*100:>10.3f}% "
          f"{subset['daily_return'].std()*100:>10.3f}% "
          f"{len(subset):>12,}")

# Statistical test: is variance higher after limit days?
normal = spillover_data[conditions['Normal day']]['daily_return'].dropna()
after_up = spillover_data[conditions['After upper limit']]['daily_return'].dropna()
after_down = spillover_data[conditions['After lower limit']]['daily_return'].dropna()

f_up = after_up.var() / normal.var()
f_down = after_down.var() / normal.var()
print(f"\nVariance ratios (vs normal days):")
print(f"  After upper limit: {f_up:.3f} "
      f"(p = {1 - stats.f.cdf(f_up, len(after_up)-1, len(normal)-1):.4f})")
print(f"  After lower limit: {f_down:.3f} "
      f"(p = {1 - stats.f.cdf(f_down, len(after_down)-1, len(normal)-1):.4f})")

37.8.2 Tái dựng lợi nhuận nhiều ngày

Để khôi phục lợi nhuận “thực” lẽ ra đã xảy ra nếu không có biên độ giá, chúng ta có thể tính lợi nhuận lũy kế (gộp kép) qua các ngày chạm biên liên tiếp cho đến khi cổ phiếu giao dịch bình thường trở lại:

Reconstruct multi-day returns spanning limit-hit sequences
def reconstruct_returns(group, limit):
    """
    For each limit-hit sequence, compound returns until
    the stock resumes normal trading (first non-limit day).
    Returns the compound return and the number of days.
    """
    sequences = []
    in_sequence = False
    seq_start = None
    seq_returns = []
    seq_direction = None
    
    for _, row in group.iterrows():
        if row['limit_up_hit'] or row['limit_down_hit']:
            if not in_sequence:
                in_sequence = True
                seq_start = row['date']
                seq_returns = [row['daily_return']]
                seq_direction = 'up' if row['limit_up_hit'] else 'down'
            else:
                seq_returns.append(row['daily_return'])
        else:
            if in_sequence:
                # Include the first non-limit day (the "resolution" day)
                seq_returns.append(row['daily_return'])
                compound_ret = np.prod([1 + r for r in seq_returns]) - 1
                sequences.append({
                    'ticker': group.name if hasattr(group, 'name') else group['ticker'].iloc[0],
                    'start_date': seq_start,
                    'n_limit_days': len(seq_returns) - 1,
                    'compound_return': compound_ret,
                    'direction': seq_direction,
                    'limit_return': sum(seq_returns[:-1]),
                    'resolution_return': seq_returns[-1]
                })
                in_sequence = False
    
    return sequences

# Run for all HOSE stocks
all_sequences = []
for ticker, group in daily_hose.sort_values('date').groupby('ticker'):
    seqs = reconstruct_returns(group, 0.07)
    all_sequences.extend(seqs)

seq_df = pd.DataFrame(all_sequences)

if len(seq_df) > 0:
    print("Limit-Hit Sequence Analysis:")
    print(f"  Total sequences: {len(seq_df):,}")
    print(f"  Mean limit days: {seq_df['n_limit_days'].mean():.1f}")
    print(f"\nCompound Returns by Direction:")
    for direction in ['up', 'down']:
        subset = seq_df[seq_df['direction'] == direction]
        print(f"  {direction.upper()} sequences: {len(subset):,}")
        print(f"    Mean compound return: {subset['compound_return'].mean()*100:.2f}%")
        print(f"    Mean resolution-day return: "
              f"{subset['resolution_return'].mean()*100:.2f}%")
        print(f"    Max compound return: {subset['compound_return'].max()*100:.1f}%")

37.9 Câu đố biến động đặc thù dưới biên độ giá

Ang và c.s. (2006) ghi nhận rằng các cổ phiếu có biến động đặc thù cao đạt lợi nhuận thấp sau đó—câu đố IVOL. Tại Việt Nam, biên độ giá làm nhiễu việc ước lượng IVOL: các cổ phiếu thường xuyên chạm biên có IVOL bị đánh giá thấp (vì lợi nhuận của chúng bị kiểm duyệt), điều này có thể tạo ra mối quan hệ máy móc giữa IVOL đo được và lợi nhuận.

Test whether price limits affect the IVOL-return relation
# Compute monthly IVOL two ways:
# (a) Naive: std of daily residuals from FF3
# (b) Corrected: excluding limit-hit days

# Merge daily data with market returns for IVOL estimation
daily_ff = daily_hose_merged.copy()

monthly_ivol = []
for (ticker, month), group in daily_ff.groupby(
    ['ticker', daily_ff['date'].dt.to_period('M')]
):
    if len(group) < 15:
        continue
    
    y = group['daily_return'].dropna()
    x = group['mkt_return'].reindex(y.index).dropna()
    common = y.index.intersection(x.index)
    if len(common) < 15:
        continue
    
    # Naive IVOL
    model = sm.OLS(y[common], sm.add_constant(x[common])).fit()
    ivol_naive = model.resid.std() * np.sqrt(252)
    
    # Interior-only IVOL
    interior = group[~group['limit_up_hit'] & ~group['limit_down_hit']]
    y_int = interior['daily_return'].dropna()
    x_int = interior['mkt_return'].reindex(y_int.index).dropna()
    common_int = y_int.index.intersection(x_int.index)
    
    if len(common_int) >= 10:
        model_int = sm.OLS(y_int[common_int],
                            sm.add_constant(x_int[common_int])).fit()
        ivol_corrected = model_int.resid.std() * np.sqrt(252)
    else:
        ivol_corrected = np.nan
    
    n_limit = group['limit_up_hit'].sum() + group['limit_down_hit'].sum()
    
    monthly_ivol.append({
        'ticker': ticker,
        'month': month.to_timestamp(),
        'ivol_naive': ivol_naive,
        'ivol_corrected': ivol_corrected,
        'n_limit_days': n_limit,
        'pct_limit': n_limit / len(group) * 100,
        'next_return': group['daily_return'].iloc[-1]  # Placeholder
    })

ivol_df = pd.DataFrame(monthly_ivol)

print("IVOL Estimation: Naive vs Corrected:")
print(f"  Mean naive IVOL:     {ivol_df['ivol_naive'].mean():.4f}")
print(f"  Mean corrected IVOL: {ivol_df['ivol_corrected'].mean():.4f}")
print(f"  Mean difference:     "
      f"{(ivol_df['ivol_corrected'] - ivol_df['ivol_naive']).mean():.4f}")
print(f"  Correlation:         "
      f"{ivol_df['ivol_naive'].corr(ivol_df['ivol_corrected']):.3f}")

37.10 Khuyến nghị thực hành

Đối với các nhà nghiên cứu làm việc với dữ liệu cổ phiếu Việt Nam:

Luôn báo cáo tần suất chạm biên. Bất kỳ nghiên cứu nào dùng lợi nhuận hằng ngày của Việt Nam đều nên ghi nhận tỷ lệ quan sát nằm tại biên độ giá, chia theo sở giao dịch và ngũ phân vị vốn hóa thị trường. Điều này cho người đọc biết mức độ nghiêm trọng của vấn đề kiểm duyệt trong mẫu cụ thể.

Dùng ước lượng phương sai hiệu chỉnh Tobit. Với các phân tích liên quan đến biến động (phân loại theo IVOL, GARCH, mô hình hóa rủi ro), phương sai mẫu thô đánh giá thấp phương sai thực 5–20% tùy theo tần suất chạm biên của cổ phiếu. Ước lượng hợp lý cực đại (MLE) Tobit cung cấp một bộ ước lượng nhất quán dưới giả định phân phối chuẩn bị kiểm duyệt.

Cân nhắc các bộ ước lượng range-based. Bộ ước lượng Yang và Zhang (2000) dùng giá OHLC vững một phần trước kiểm duyệt giá đóng cửa và không đòi hỏi giả định về phân phối. Đây là lựa chọn mặc định tốt để ước lượng biến động của từng cổ phiếu.

Loại các ngày chạm biên khi ước lượng beta. Beta chỉ dùng các ngày nằm trong biên ít chệch hơn beta dùng toàn bộ các ngày, dù nhiễu hơn. Hãy báo cáo cả hai và thảo luận sự khác biệt. Với các cổ phiếu có tần suất chạm biên >5%, mức suy giảm có ý nghĩa kinh tế.

Tính lợi nhuận lũy kế nhiều ngày cho các nghiên cứu sự kiện. Khi nghiên cứu các sự kiện trùng với việc chạm biên (công bố lợi nhuận, M&A, thay đổi quy định), hãy dùng lợi nhuận lũy kế từ đầu chuỗi chạm biên đến ngày đầu tiên không chạm biên. Lợi nhuận một ngày bị kiểm duyệt và đánh giá thấp phản ứng của thị trường.

Thận trọng khi diễn giải khả năng dự báo lợi nhuận ngắn hạn. Hiệu ứng khám phá giá bị trì hoãn tạo ra tự tương quan lợi nhuận dương ở tần suất hằng ngày. Đây là hệ quả máy móc của kiểm duyệt, không phải sự phi hiệu quả của thị trường. Lợi nhuận hằng tháng phần lớn không chịu ảnh hưởng này vì kiểm duyệt trong một tháng được bù trừ khi lấy trung bình.

Kiểm tra độ vững với HNX và UPCoM. Nếu một kết quả bị chi phối bởi các méo mó liên quan đến biên độ, nó sẽ xuất hiện khác đi (hoặc không xuất hiện) trên HNX (\(\pm\) 10%) và UPCoM (\(\pm\) 15%). So sánh chéo giữa các sở giao dịch là một kiểm định giả dược (placebo) tự nhiên.

37.11 Tóm tắt

Bảng 37.2: Tóm tắt các tác động của biên độ giá lên các ước lượng thực nghiệm.
Vấn đề Hướng chệch Mức độ (HOSE) Cách khắc phục khuyến nghị
Phương sai lợi nhuận Bị đánh giá thấp 5–20% với cổ phiếu biến động mạnh Tobit MLE hoặc range-based
Biến động GARCH Bị đánh giá thấp 10–30% trong khủng hoảng GARCH có kiểm duyệt
Beta thị trường Bị suy giảm (về 0) 3–10% với cổ phiếu vốn hóa nhỏ Ước lượng chỉ dùng các ngày trong biên
IVOL Bị đánh giá thấp Thay đổi; tương quan với quy mô IVOL đã hiệu chỉnh
Tự tương quan lợi nhuận Dương (giả tạo) Có ý nghĩa ở tần suất hằng ngày Dùng dữ liệu tuần/tháng
CAR của nghiên cứu sự kiện Bị đánh giá thấp Lên đến 50% hiệu ứng thực Lũy kế nhiều ngày
Hình dạng phân phối Dồn lại tại biên 2–5% quan sát nằm tại biên Thừa nhận hoặc hiệu chỉnh

Biên độ giá không phải là một chi tiết thể chế nhỏ—chúng là một quá trình sinh dữ liệu lan rộng, ảnh hưởng đến hầu hết mọi đại lượng thực nghiệm tính từ lợi nhuận hằng ngày của Việt Nam. Các phương pháp hiệu chỉnh được phát triển trong chương này—ước lượng phương sai Tobit, GARCH có kiểm duyệt, beta chỉ dùng các ngày trong biên, biến động range-based và tính lợi nhuận lũy kế nhiều ngày—tạo thành một bộ công cụ nên được áp dụng thường xuyên. Bỏ qua kiểm duyệt không làm nó biến mất; nó chỉ khiến các ước lượng thu được sai một cách âm thầm.