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 pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import stats, optimizefrom arch import arch_modelimport warningswarnings.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 DataCoreClientclient = DataCoreClient()# Daily data with high, low, open, close, volume, and limit indicatorsdaily = 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 returnsdaily['daily_return'] = daily.groupby('ticker')['adjusted_close'].pct_change()# Flag limit hits from price data if not providedif'limit_up_hit'notin 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 limitsexchange_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
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.
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à:
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:
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.
Đá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.
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
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.
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\):
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
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.
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:
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 HNXdaily_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'] )iflen(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']]iflen(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.2axes[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 linez = 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 definitionmonthly_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() -1iflen(x) >0else 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 daysdef 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']]iflen(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']]iflen(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']]iflen(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']]iflen(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).Tmagnet = magnet_test(daily_hose, 0.07, proximity_threshold=0.8)print("Magnet Effect Test (HOSE):")print(magnet.round(4).to_string())
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 afterspillover_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 daysconditions = {'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:
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 =Nonefor _, row in group.iterrows():if row['limit_up_hit'] or row['limit_down_hit']:ifnot 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 ifhasattr(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 =Falsereturn sequences# Run for all HOSE stocksall_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)iflen(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 estimationdaily_ff = daily_hose_merged.copy()monthly_ivol = []for (ticker, month), group in daily_ff.groupby( ['ticker', daily_ff['date'].dt.to_period('M')]):iflen(group) <15:continue y = group['daily_return'].dropna() x = group['mkt_return'].reindex(y.index).dropna() common = y.index.intersection(x.index)iflen(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)iflen(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.
Ang, Andrew, Robert J Hodrick, Yuhang Xing, và Xiaoyan Zhang. 2006. “The cross-section of volatility and expected returns”. The journal of finance 61 (1): 259–99.
Brennan, Michael J. 1986. “A theory of price limits in futures markets”. Journal of Financial Economics 16 (2): 213–33.
Cho, David D, Jeffrey Russell, George C Tiao, và Ruey Tsay. 2003. “The magnet effect of price limits: evidence from high-frequency data on Taiwan Stock Exchange”. Journal of Empirical Finance 10 (1-2): 133–68.
Yang, Dennis, và Qiang Zhang. 2000. “Drift-independent volatility estimation based on high, low, open, and close prices”. The Journal of Business 73 (3): 477–92.
# Biên độ giá và biến động::: callout-noteTrong 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 [@brennan1986theory]. 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.## Chế độ biên độ giá của Việt Nam {#sec-price-limit-regime}### Chi tiết thể chế```{python}#| label: setup#| code-summary: "Import libraries and configure environment"import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import stats, optimizefrom arch import arch_modelimport warningswarnings.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% @tbl-price-limit-limits. 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).| 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 |: Chế độ biên độ giá hằng ngày của Việt Nam theo sở giao dịch. {#tbl-price-limit-limits}Đ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.```{python}#| label: data-load#| eval: false#| code-summary: "Load daily price data with limit hit indicators"from datacore import DataCoreClientclient = DataCoreClient()# Daily data with high, low, open, close, volume, and limit indicatorsdaily = 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 returnsdaily['daily_return'] = daily.groupby('ticker')['adjusted_close'].pct_change()# Flag limit hits from price data if not providedif'limit_up_hit'notin 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 limitsexchange_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()}")```## Mức độ phổ biến của việc chạm biên độ {#sec-price-limit-prevalence}### Tần suất tổng hợpCá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.```{python}#| label: limit-frequency#| eval: false#| code-summary: "Compute limit hit frequencies by exchange and over time"# Overall frequencieslimit_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 limitsdaily['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()``````{python}#| label: fig-limit-timeseries#| eval: false#| fig-cap: "Chuỗi thời gian tần suất chạm biên độ giá hằng ngày trên HOSE. Panel A cho thấy tỷ lệ phần trăm cổ phiếu–ngày chạm giá trần (xanh lá) và giá sàn (đỏ) mỗi tháng. Panel B chồng lợi nhuận VN-Index lên. Các lần chạm biên độ tập trung trong giai đoạn thị trường căng thẳng (2008–2009, 2011–2012, tháng 3/2020, 2022) và trong các đợt tăng giá hưng phấn (2006–2007, cuối 2021). Sự bất đối xứng giữa lần chạm trần và chạm sàn cho thấy thiên hướng của thị trường."#| code-summary: "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 contextvnindex = 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()```### Theo vốn hóa thị trường```{python}#| label: fig-price-limit-by-size#| fig-cap: "Tần suất chạm biên độ giá theo ngũ phân vị vốn hóa thị trường trên HOSE"#| fig-alt: "Tần suất chạm biên độ giá theo ngũ phân vị vốn hóa thị trường trên HOSE. Các cổ phiếu nhỏ hơn chạm biên độ thường xuyên hơn nhiều so với cổ phiếu vốn hóa lớn, phản ánh biến động cao hơn, chênh lệch giá mua–bán rộng hơn và sổ lệnh mỏng hơn. Biên độ 7% của HOSE về cơ bản không ràng buộc đối với nhóm lớn nhất nhưng kiểm duyệt một tỷ lệ đáng kể lợi nhuận của nhóm nhỏ nhất."#| eval: false#| code-summary: "Compute limit hit rates by size quintile"# Merge with lagged market capmonthly_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 monthmonthly_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 dailydaily_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']] *=100fig, ax = plt.subplots(figsize=(10, 5))x = np.arange(len(size_limit))width =0.35ax.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()```### Các ngày chạm biên độ liên tiếpMộ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.```{python}#| label: consecutive-limits#| eval: false#| code-summary: "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 =0for _, row in group.iterrows():if row['limit_up_hit']: up_count +=1if down_count >0: down_runs.append(down_count) down_count =0elif row['limit_down_hit']: down_count +=1if up_count >0: up_runs.append(up_count) up_count =0else:if up_count >0: up_runs.append(up_count)if down_count >0: down_runs.append(down_count) up_count =0 down_count =0if 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 stockshose_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)]:ifnot 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()}")```## Sự méo mó của phân phối lợi nhuận {#sec-price-limit-distortion}### Cơ chế kiểm duyệtBiê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}$$ {#eq-censoring}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.```{python}#| label: fig-return-distribution#| eval: false#| fig-cap: "Phân phối lợi nhuận hằng ngày trên HOSE. Panel A cho thấy toàn bộ biểu đồ tần suất với các đỉnh nhọn thấy rõ tại 7% (biên độ giá). Panel B phóng to phần đuôi để cho thấy sự gián đoạn tại các giá trị biên. Sự dồn lại đúng tại 7% là bằng chứng trực tiếp của kiểm duyệt—các quan sát này đại diện cho những cổ phiếu có lợi nhuận cân bằng vượt quá biên độ."#| code-summary: "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 errorsfig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Full distributionaxes[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 tailsbins_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 spiken_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%})")```### So sánh HOSE, HNX và UPCoMBa 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.```{python}#| label: fig-cross-exchange#| eval: false#| fig-cap: "Phân phối lợi nhuận hằng ngày giữa các sở giao dịch Việt Nam với độ rộng biên độ giá khác nhau. HOSE (7%) cho thấy sự dồn lại rõ rệt nhất tại biên. HNX (10%) có ít dồn lại hơn. UPCoM (15%) về cơ bản không bị kiểm duyệt—hầu như không có quan sát nào chạm biên. So sánh chéo giữa các sở giao dịch này cung cấp một chuẩn cho hình dạng phân phối của HOSE nếu không bị kiểm duyệt."#| code-summary: "Compare return distributions across exchanges"fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))for i, (exchange, limit, color) inenumerate([ ('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()```## Độ chệch phương sai do kiểm duyệt {#sec-price-limit-variance-bias}### Độ chệch giải tíchNế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}$$ {#eq-censored-variance}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.```{python}#| label: variance-bias-simulation#| eval: false#| code-summary: "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 limitresults_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)``````{python}#| label: fig-variance-bias#| eval: false#| fig-cap: "Độ chệch phương sai do kiểm duyệt biên độ giá như một hàm của biến động hằng ngày thực. Panel A cho thấy tỷ số giữa phương sai quan sát được và phương sai thực: giá trị dưới 1,0 cho thấy bị đánh giá thấp. Panel B cho thấy tỷ lệ phần trăm lợi nhuận bị kiểm duyệt. Với một cổ phiếu HOSE điển hình có biến động hằng ngày 2,5%, phương sai bị đánh giá thấp khoảng 3–5%. Với các cổ phiếu vốn hóa nhỏ biến động mạnh có biến động hằng ngày trên 5%, độ chệch vượt 15%, khiến các ước lượng phương sai thô gây hiểu lầm nghiêm trọng."#| code-summary: "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()```### Độ chệch phương sai thực nghiệm theo quy mô```{python}#| label: empirical-bias#| eval: false#| code-summary: "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 normaldef 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_lowerif interior.sum() <20:return np.nan, np.nandef neg_loglik(params): mu, log_sigma = params sigma = np.exp(log_sigma) ll =0# Interior observationsif interior.sum() >0: ll += np.sum(stats.norm.logpdf(r[interior], mu, sigma))# Upper censoredif at_upper.sum() >0: ll += np.sum(np.log(1- stats.norm.cdf(upper, mu, sigma) +1e-15))# Lower censoredif 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() >0else0 sigma0 = r[interior].std() if interior.sum() >0else 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_hatexceptException:return np.nan, np.nan# Estimate for each HOSE stockhose_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()iflen(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}%")``````{python}#| label: fig-tobit-vs-naive#| eval: false#| fig-cap: "Ước lượng biến động hiệu chỉnh Tobit so với ước lượng thô cho các cổ phiếu HOSE. Panel A là biểu đồ phân tán trong đó các điểm nằm trên đường 45 độ cho thấy những cổ phiếu có biến động thực cao hơn mức mà ước lượng thô gợi ý. Panel B cho thấy phân phối của phần trăm hiệu chỉnh. Hiệu chỉnh Tobit nhỏ đối với các cổ phiếu vốn hóa lớn biến động thấp (nơi kiểm duyệt hiếm khi xảy ra) nhưng đáng kể đối với các cổ phiếu vốn hóa nhỏ biến động cao."#| code-summary: "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.5axes[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()```## Ước lượng biến động dưới biên độ giá {#sec-price-limit-vol-estimation}### 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.```{python}#| label: range-estimators#| eval: false#| code-summary: "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 **2return 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_rsreturn np.sqrt(var.clip(lower=0))# Compute for HOSE samplesample_ticker ='VNM'# Large, liquid stocksample = daily_hose[daily_hose['ticker'] == sample_ticker].copy()sample = sample.sort_values('date').set_index('date')# Close-to-close realized volsample['cc_vol'] = sample['daily_return'].rolling(20).std() * np.sqrt(252)# Range-basedsample['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)``````{python}#| label: fig-vol-estimators#| eval: false#| fig-cap: "So sánh các bộ ước lượng biến động cho VNM (Vinamilk). Biến động đóng cửa–đóng cửa (CC) chịu độ chệch do kiểm duyệt khi cổ phiếu chạm biên độ giá. Các bộ ước lượng range-based (Parkinson, Garman-Klass, Yang-Zhang) dùng giá cao–thấp ít bị ảnh hưởng bởi kiểm duyệt giá đóng cửa. Trong các giai đoạn biến động cao (vùng tô bóng), các bộ ước lượng range-based thường cho ước lượng cao hơn CC, phù hợp với độ chệch do kiểm duyệt."#| code-summary: "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()```### Mô hình GARCH với lợi nhuận bị kiểm duyệtCá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)$$ {#eq-garch-mean}$$\sigma_t^2 = \omega + \alpha \varepsilon_{t-1}^2 + \beta \sigma_{t-1}^2$$ {#eq-garch-variance}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}$$ {#eq-censored-likelihood}```{python}#| label: censored-garch#| eval: false#| code-summary: "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_lowerdef neg_loglik(params): mu, omega, alpha, beta = paramsif omega <=0or alpha <0or beta <0or (alpha + beta) >=1:return1e10 sigma2 = np.zeros(T) sigma2[0] = omega / (1- alpha - beta) if (alpha + beta) <1else r.var() ll =0for t inrange(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() else0 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 inrange(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 }exceptExceptionas e:returnNone# Compare standard vs censored GARCH for a volatile stockvolatile_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 GARCHcens_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}")``````{python}#| label: fig-garch-comparison#| eval: false#| fig-cap: "Biến động có điều kiện từ GARCH(1,1) chuẩn so với GARCH(1,1) có kiểm duyệt cho một cổ phiếu HOSE thường xuyên chạm biên. Mô hình có kiểm duyệt luôn ước lượng biến động có điều kiện cao hơn vì nhận ra rằng lợi nhuận chạm biên đánh giá thấp biến động giá thực. Sự khác biệt rõ rệt nhất trong các giai đoạn biến động cao khi kiểm duyệt xảy ra thường xuyên nhất."#| code-summary: "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 highlightedaxes[0].plot(dates, test_returns, color='#2C5F8A', linewidth=0.5, alpha=0.7)limit_up_mask = test_returns >=0.069limit_down_mask = test_returns <=-0.069axes[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 volatilitystd_sigma = std_result.conditional_volatility /100# Convert from % to decimalcens_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()```## Tác động lên các kiểm định định giá tài sản {#sec-price-limit-asset-pricing}### Sự suy giảm của betaBiê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.```{python}#| label: beta-attenuation#| eval: false#| code-summary: "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 HNXdaily_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'] )iflen(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']]iflen(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}")``````{python}#| label: fig-beta-attenuation#| eval: false#| fig-cap: "Sự suy giảm beta do kiểm duyệt biên độ giá. Panel A vẽ phân tán beta toàn bộ các ngày so với beta chỉ dùng các ngày nằm trong biên (loại trừ các ngày chạm biên). Các điểm nằm trên đường 45 độ là những cổ phiếu có beta *cao hơn* khi ước lượng không dùng các quan sát bị kiểm duyệt. Panel B cho thấy mức độ suy giảm tăng theo tần suất chạm biên của cổ phiếu—những cổ phiếu chạm biên thường xuyên hơn có beta bị suy giảm nghiêm trọng hơn."#| code-summary: "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.2axes[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 linez = 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()```### 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:```{python}#| label: factor-premium-bias#| eval: false#| code-summary: "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 definitionmonthly_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() -1iflen(x) >0else 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}")```## Hiệu ứng nam châm {#sec-price-limit-magnet}### 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 [@cho2003magnet]. 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:```{python}#| label: magnet-effect#| eval: false#| code-summary: "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 daysdef 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']]iflen(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']]iflen(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']]iflen(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']]iflen(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).Tmagnet = magnet_test(daily_hose, 0.07, proximity_threshold=0.8)print("Magnet Effect Test (HOSE):")print(magnet.round(4).to_string())``````{python}#| label: fig-magnet#| eval: false#| fig-cap: "Bằng chứng về hiệu ứng nam châm. Panel A cho thấy lợi nhuận ngày hôm sau có điều kiện theo mức độ gần biên của lợi nhuận hôm nay. Nếu biên độ làm nguội thị trường, lợi nhuận ngày hôm sau sẽ đảo chiều (âm sau khi gần giá trần, dương sau khi gần giá sàn). Nếu hiệu ứng nam châm chiếm ưu thế, xu hướng tiếp diễn sẽ thắng thế. Panel B cho thấy xác suất chạm biên vào ngày hôm sau có điều kiện theo mức độ gần biên hôm nay. Xác suất tăng dần khi cổ phiếu tiến gần biên phù hợp với hiệu ứng nam châm."#| code-summary: "Visualize magnet effect evidence"# More granular: bin today's return and compute next-day statisticsbins = 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 returnaxes[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 daydaily_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()```## Giả thuyết khám phá giá bị trì hoãn {#sec-price-limit-delayed}### Lan tỏa biến độngNế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). @kim1997price 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.```{python}#| label: spillover#| eval: false#| code-summary: "Test for volatility spillover after limit hit days"# Compare volatility and return on limit-hit days vs day afterspillover_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 daysconditions = {'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})")```### 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:```{python}#| label: return-reconstruction#| eval: false#| code-summary: "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 =Nonefor _, row in group.iterrows():if row['limit_up_hit'] or row['limit_down_hit']:ifnot 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 ifhasattr(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 =Falsereturn sequences# Run for all HOSE stocksall_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)iflen(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}%")```## Câu đố biến động đặc thù dưới biên độ giá {#sec-price-limit-ivol}@ang2006cross 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.```{python}#| label: ivol-puzzle#| eval: false#| code-summary: "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 estimationdaily_ff = daily_hose_merged.copy()monthly_ivol = []for (ticker, month), group in daily_ff.groupby( ['ticker', daily_ff['date'].dt.to_period('M')]):iflen(group) <15:continue y = group['daily_return'].dropna() x = group['mkt_return'].reindex(y.index).dropna() common = y.index.intersection(x.index)iflen(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)iflen(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}")```## Khuyến nghị thực hành {#sec-price-limit-recommendations}Đố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 @yang2000drift 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.## Tóm tắt {#sec-price-limit-summary}| 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 |: 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. {#tbl-price-limit-summary}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.```{=html}<!-- ## Exercises {#sec-price-limit-exercises}1. **Natural experiment from limit widening.** HOSE widened its limit from $\pm$ 5% to $\pm$ 7% in June 2013. Using a difference-in-differences design with HNX as a control group (whose limits did not change), test whether the widening reduced: (a) the frequency of limit hits, (b) next-day volatility spillover, (c) return autocorrelation. This is one of the cleanest identification strategies available for testing the cooling hypothesis.2. **Censored EGARCH.** Extend the censored GARCH to the @nelson1991conditional EGARCH specification, which allows asymmetric volatility responses (leverage effect). Does the asymmetry parameter differ between the standard and censored specifications? The leverage effect in Vietnam may be understated by standard models because limit-down hits are disproportionately censored.3. **Lottery demand and limit hits.** @bali2011maxing show that stocks with high maximum daily returns (MAX) earn low subsequent returns, interpreted as lottery demand. In Vietnam, MAX is mechanically bounded by the price limit. Construct the MAX anomaly using (a) the raw observed MAX and (b) the Tobit-corrected MAX. How does the correction affect the premium?4. **Limit-hit-adjusted Fama-French factors.** Reconstruct the Vietnamese SMB, HML, and WML factors using only interior (non-limit) daily returns to compute monthly portfolio returns. Compare the resulting factor premia and Sharpe ratios to the standard construction. Quantify the "censoring premium"—the component of the factor return that comes from limit-day returns.5. **Foreign investor response to limits.** Using foreign ownership data, test whether foreign investors systematically avoid stocks that frequently hit price limits. If foreign investors are more sophisticated, their avoidance of limit-prone stocks could explain part of the foreign ownership premium.6. **Intraday volatility estimation.** If intraday data are available, compute realized variance from 5-minute returns. Compare this to close-to-close variance on limit-hit days versus non-limit days. The ratio reveals how much of the true volatility is captured by the closing price.7. **Optimal limit design.** Using the Vietnamese data, estimate the "optimal" price limit width that minimizes next-day volatility spillover while minimizing the censoring rate. Plot the trade-off curve between censoring (too narrow) and insufficient cooling (too wide). Where does the current $\pm$ 7% HOSE limit sit on this curve?8. **Sentiment and limit hits.** Construct a market-level sentiment indicator based on the ratio of upper-limit hits to lower-limit hits. Test whether this indicator predicts subsequent market returns, analogous to @baker2006investor. A high up/down ratio may signal overheating, while a low ratio may signal panic. -->```