33.1 Sở hữu tổ chức tại Việt Nam: một bức tranh khác biệt
Thị trường cổ phiếu Việt Nam có bức tranh sở hữu tổ chức khác biệt căn bản so với các thị trường phát triển như Mỹ, châu Âu hay Nhật Bản. Kể từ khi Trung tâm Giao dịch Chứng khoán Thành phố Hồ Chí Minh (nay là HOSE) khai trương ngày 28 tháng 7 năm 2000 với chỉ hai cổ phiếu niêm yết, thị trường đã tăng trưởng lên hơn 1.700 công ty niêm yết trên ba sàn giao dịch (HOSE, HNX và UPCOM) với tổng vốn hóa thị trường vượt 200 tỷ USD. Tuy nhiên, cấu trúc sở hữu vẫn mang những nét riêng biệt ở nhiều khía cạnh quan trọng:
Sự thống trị của nhà đầu tư cá nhân. Nhà đầu tư cá nhân chiếm khoảng 85% giá trị giao dịch trên các sàn giao dịch Việt Nam, vượt xa tỷ trọng của các tổ chức. Điều này tương phản rõ rệt với Mỹ, nơi các nhà đầu tư tổ chức chi phối cả sở hữu lẫn giao dịch (Bao Dinh và Tran 2024). Các hệ quả đối với hiệu quả thị trường, quá trình khám phá giá và biến động là rất sâu rộng.
State ownership legacy. Vietnam’s equitization (privatization) program, initiated under Đổi Mới reforms in 1986, means that the state remains a significant or controlling shareholder in many listed companies. As of 2022, SOEs (firms with state ownership > 50%) account for approximately 30% of total market capitalization despite representing less than 10% of listed firms (Huang, Liu, và Shu 2023). State ownership introduces unique agency problems, governance dynamics, and liquidity constraints.
Giới hạn sở hữu nước ngoài (FOL). Việt Nam áp đặt mức trần theo ngành đối với tổng tỷ lệ sở hữu nước ngoài, thường là 49% đối với hầu hết các ngành, 30% đối với ngân hàng, và các mức giới hạn khác nhau đối với hàng không, truyền thông và viễn thông. Khi một cổ phiếu chạm FOL, nhà đầu tư nước ngoài chỉ có thể mua từ những người bán là nhà đầu tư nước ngoài khác, tạo ra một thị trường bị phân khúc với động lực hình thành giá riêng và một “phần bù FOL” đã được ghi nhận rõ (Vo 2015).
Chế độ công bố thông tin. Khác với hệ thống báo cáo 13F hằng quý của Mỹ, việc công bố thông tin sở hữu tại Việt Nam diễn ra theo sự kiện và theo định kỳ. Cổ đông lớn (≥5%) phải công bố trong vòng 7 ngày làm việc kể từ khi vượt các ngưỡng sở hữu. Báo cáo thường niên chứa danh sách cổ đông chi tiết. Báo cáo quỹ bán niên cung cấp ảnh chụp danh mục. Điều này tạo ra một bức tranh chắp vá về tần suất công bố, đòi hỏi phải xử lý cẩn thận.
33.2 Hạ tầng dữ liệu: DataCore.vn
DataCore.vn là một nền tảng dữ liệu tài chính toàn diện của Việt Nam, cung cấp các bộ dữ liệu đạt chuẩn học thuật cho thị trường Việt Nam. Xuyên suốt chương này, chúng ta giả định toàn bộ dữ liệu được lấy riêng từ DataCore.vn, nền tảng cung cấp:
Bảng 33.1: Các bảng dữ liệu DataCore.vn sử dụng trong chương này
class DataCoreReader:""" Unified data reader for DataCore.vn datasets. Assumes data has been downloaded from DataCore.vn and stored locally. Supports both Parquet (recommended for performance) and CSV formats. Parameters ---------- data_dir : str or Path Root directory containing DataCore.vn data files file_format : str 'parquet' or 'csv' (default: 'parquet') """# Expected file names in the data directory FILE_MAP = {'prices': 'stock_prices','ownership': 'ownership_structure','major_shareholders': 'major_shareholders','corporate_actions': 'corporate_actions','company_profile': 'company_profile','financials': 'financial_statements','foreign_ownership': 'foreign_ownership_daily','fund_holdings': 'fund_holdings', }def__init__(self, data_dir: Union[str, Path], file_format: str='parquet'):self.data_dir = Path(data_dir)self.fmt = file_formatself._cache = {}# Verify data directory existsifnotself.data_dir.exists():raiseFileNotFoundError(f"Data directory not found: {self.data_dir}\n"f"Please download data from DataCore.vn and place it in this directory." )print(f"DataCore.vn reader initialized: {self.data_dir}") available = [f.stem for f inself.data_dir.glob(f'*.{self.fmt}')]print(f"Available datasets: {available}")def _read(self, key: str) -> pd.DataFrame:"""Read and cache a dataset."""if key inself._cache:returnself._cache[key] fname =self.FILE_MAP.get(key, key) filepath =self.data_dir /f"{fname}.{self.fmt}"ifnot filepath.exists():raiseFileNotFoundError(f"Dataset not found: {filepath}\n"f"Expected file: {fname}.{self.fmt} in {self.data_dir}" )ifself.fmt =='parquet': df = pd.read_parquet(filepath)else: df = pd.read_csv(filepath, parse_dates=True)# Auto-detect and parse date columnsfor col in df.columns:if'date'in col.lower() or col.lower() in ['period', 'ex_date', 'record_date']:try: df[col] = pd.to_datetime(df[col])except (ValueError, TypeError):passself._cache[key] = dfprint(f"Loaded {key}: {len(df):,} rows, {len(df.columns)} columns")return df@propertydef prices(self) -> pd.DataFrame:returnself._read('prices')@propertydef ownership(self) -> pd.DataFrame:returnself._read('ownership')@propertydef major_shareholders(self) -> pd.DataFrame:returnself._read('major_shareholders')@propertydef corporate_actions(self) -> pd.DataFrame:returnself._read('corporate_actions')@propertydef company_profile(self) -> pd.DataFrame:returnself._read('company_profile')@propertydef financials(self) -> pd.DataFrame:returnself._read('financials')@propertydef foreign_ownership(self) -> pd.DataFrame:returnself._read('foreign_ownership')@propertydef fund_holdings(self) -> pd.DataFrame:returnself._read('fund_holdings')def clear_cache(self):"""Clear all cached datasets to free memory."""self._cache.clear()# Initialize reader — adjust path to your local DataCore.vn data# dc = DataCoreReader('/path/to/datacore_data', file_format='parquet')
Chương này được tổ chức như sau. Mục 33.3 xây dựng quy trình dữ liệu hoàn chỉnh từ các bản trích xuất thô của DataCore.vn đến các bộ dữ liệu sạch, sẵn sàng phân tích, đặc biệt chú ý đến việc điều chỉnh theo sự kiện doanh nghiệp. Mục 33.4 định nghĩa hệ phân loại sở hữu đặc thù của Việt Nam. Mục 33.5 tính tỷ lệ sở hữu tổ chức, mức độ tập trung và độ rộng cho thị trường Việt Nam. Mục 33.6 phát triển các phân tích chuyên biệt về sở hữu nước ngoài, gồm mức sử dụng FOL và phần bù room. Mục 33.7 suy ra các giao dịch của tổ chức từ các ảnh chụp công bố sở hữu. Mục 33.8 tính dòng vốn và tỷ lệ vòng quay ở cấp quỹ. Mục 33.9 phân tích động lực của sở hữu nhà nước. Mục 33.10 giới thiệu phân tích mạng lưới, phân loại bằng học máy và các khung nghiên cứu sự kiện. Mục 33.11 trình bày các ứng dụng thực nghiệm hoàn chỉnh, và Mục 33.12 kết luận chương.
33.3 Quy trình dữ liệu
33.3.1 Dữ liệu giá cổ phiếu và điều chỉnh theo sự kiện doanh nghiệp
Thị trường cổ phiếu Việt Nam nổi tiếng với các sự kiện doanh nghiệp diễn ra thường xuyên, đặc biệt là cổ tức bằng cổ phiếu và phát hành cổ phiếu thưởng, làm thay đổi mạnh số lượng cổ phiếu. Một công ty chi trả cổ tức bằng cổ phiếu 30% nghĩa là cứ 100 cổ phiếu trở thành 130 cổ phiếu, và giá tham chiếu được điều chỉnh giảm tương ứng. Việc không điều chỉnh đúng số cổ phiếu và giá lịch sử cho các sự kiện này là nguồn sai sót phổ biến nhất trong nghiên cứu thị trường cổ phiếu Việt Nam.
Build Cumulative Adjustment Factors from Corporate Actions
# ============================================================================# Step 1: Corporate Action Adjustment Factors# ============================================================================def build_adjustment_factors(corporate_actions: pd.DataFrame) -> pd.DataFrame:""" Build cumulative adjustment factors from the corporate actions history. In Vietnam, the most common share-altering corporate actions are: 1. Stock dividends (cổ tức bằng cổ phiếu): e.g., 30% → ratio = 0.30 Effect: shares × (1 + 0.30), price × (1 / 1.30) 2. Bonus shares (thưởng cổ phiếu): mechanically identical to stock dividends 3. Stock splits (chia tách): e.g., 2:1 → ratio = 2.0 Effect: shares × 2, price × 0.5 4. Rights issues (phát hành thêm): dilutive, but not all shareholders exercise We approximate with the subscription ratio 5. Reverse splits (gộp cổ phiếu): rare in Vietnam Effect: shares ÷ ratio, price × ratio We construct a FORWARD-LOOKING cumulative adjustment factor such that: adjusted_shares = raw_shares × cum_adj_factor(from_date, to_date) adjusted_price = raw_price / cum_adj_factor(from_date, to_date) This is analogous to CRSP's cfacshr in the US context. Parameters ---------- corporate_actions : pd.DataFrame DataCore.vn corporate actions with columns: ticker, ex_date, action_type, ratio action_type values: - 'stock_dividend': ratio = dividend rate (e.g., 0.30 for 30%) - 'bonus_shares': ratio = bonus rate (e.g., 0.20 for 20%) - 'stock_split': ratio = split factor (e.g., 2.0 for 2:1) - 'reverse_split': ratio = merge factor (e.g., 5.0 for 5:1 merge) - 'rights_issue': ratio = subscription rate (e.g., 0.10 for 10:1) - 'cash_dividend': ratio = VND per share (no share adjustment needed) Returns ------- pd.DataFrame Adjustment factors: ticker, ex_date, point_factor, cum_factor """# Filter to share-altering events only share_events = ['stock_dividend', 'bonus_shares', 'stock_split', 'reverse_split', 'rights_issue'] ca = corporate_actions[ corporate_actions['action_type'].isin(share_events) ].copy()iflen(ca) ==0:print("No share-altering corporate actions found.")return pd.DataFrame(columns=['ticker', 'ex_date', 'point_factor', 'cum_factor'])# Compute point adjustment factor for each eventdef compute_point_factor(row): atype = row['action_type'] ratio = row['ratio']if atype in ['stock_dividend', 'bonus_shares']:# 30% stock dividend: 100 shares → 130 sharesreturn1+ ratioelif atype =='stock_split':# 2:1 split: 100 shares → 200 sharesreturn ratioelif atype =='reverse_split':# 5:1 reverse: 500 shares → 100 sharesreturn1.0/ ratioelif atype =='rights_issue':# Approximate: assume all rights exercised# In practice, this overestimates the adjustmentreturn1+ ratioelse:return1.0 ca['point_factor'] = ca.apply(compute_point_factor, axis=1)# Sort chronologically within each ticker ca = ca.sort_values(['ticker', 'ex_date']).reset_index(drop=True)# Cumulative factor: product of all point factors from listing to date# This gives us a running "total adjustment" for each ticker ca['cum_factor'] = ca.groupby('ticker')['point_factor'].cumprod()# Summary statistics n_tickers = ca['ticker'].nunique() n_events =len(ca) avg_events = n_events / n_tickers if n_tickers >0else0print(f"Corporate action adjustment factors built:")print(f" Tickers with adjustments: {n_tickers:,}")print(f" Total share-altering events: {n_events:,}")print(f" Average events per ticker: {avg_events:.1f}")print(f"\nEvent type distribution:")print(ca['action_type'].value_counts().to_string())return ca[['ticker', 'ex_date', 'action_type', 'ratio', 'point_factor', 'cum_factor']]def adjust_shares(shares: float, ticker: str, from_date, to_date, adj_factors: pd.DataFrame) ->float:""" Adjust a share count from one date to another for corporate actions. Example: If a company had a 30% stock dividend with ex_date between from_date and to_date, then 1000 shares at from_date = 1300 shares at to_date. Parameters ---------- shares : float Number of shares at from_date ticker : str Stock ticker from_date, to_date : pd.Timestamp Period for adjustment adj_factors : pd.DataFrame Output of build_adjustment_factors() Returns ------- float Adjusted shares at to_date """ events = adj_factors[ (adj_factors['ticker'] == ticker) & (adj_factors['ex_date'] > pd.Timestamp(from_date)) & (adj_factors['ex_date'] <= pd.Timestamp(to_date)) ]iflen(events) ==0:return shares total_factor = events['point_factor'].prod()return shares * total_factor# Example usage:# adj_factors = build_adjustment_factors(dc.corporate_actions)
Vấn đề cổ tức bằng cổ phiếu tại Việt Nam
Các công ty Việt Nam chi trả cổ tức bằng cổ phiếu với tần suất đáng kể, nhiều công ty tăng trưởng làm như vậy 2-3 lần mỗi năm. Hãy xem xét Vinhomes (VHM) hoặc FPT Corporation: số lượng cổ phiếu của họ có thể tăng gấp đôi hoặc gấp ba trong vòng 5 năm chỉ nhờ cổ tức bằng cổ phiếu. Nếu bạn so sánh số cổ phiếu sở hữu thô từ năm 2019 đến 2024 mà không điều chỉnh, bạn sẽ thu được các tỷ lệ sở hữu vô nghĩa. Mọi phân tích chuỗi thời gian về dữ liệu sở hữu của Việt Nam đều phải sử dụng số cổ phiếu đã điều chỉnh. Đây là vấn đề tương đương với hệ số điều chỉnh cfacshr của CRSP trong dữ liệu Mỹ, nhưng nghiêm trọng hơn vì các sự kiện xảy ra thường xuyên hơn và có quy mô lớn hơn.
Process DataCore.vn Price Data with Adjustments
# ============================================================================# Step 2: Process Stock Price Data# ============================================================================def process_price_data(prices: pd.DataFrame, adj_factors: pd.DataFrame, company_profile: pd.DataFrame) -> pd.DataFrame:""" Process DataCore.vn stock price data: 1. Align dates to month-end and quarter-end 2. Merge company metadata (exchange, sector, FOL limit) 3. Compute adjusted prices and shares outstanding 4. Compute market capitalization 5. Create quarter-end snapshots Parameters ---------- prices : pd.DataFrame Daily/monthly price data from DataCore.vn adj_factors : pd.DataFrame Corporate action adjustment factors company_profile : pd.DataFrame Company metadata including exchange, sector, FOL Returns ------- pd.DataFrame Quarter-end processed stock data """ df = prices.copy()# Standardize date df['date'] = pd.to_datetime(df['date']) df['month_end'] = df['date'] + pd.offsets.MonthEnd(0) df['quarter_end'] = df['date'] + pd.offsets.QuarterEnd(0)# Merge company profile profile_cols = ['ticker', 'exchange', 'industry_code', 'fol_limit', 'listing_date', 'company_name'] profile_cols = [c for c in profile_cols if c in company_profile.columns] df = df.merge(company_profile[profile_cols], on='ticker', how='left')# Build cumulative adjustment factor for each ticker-date# For each observation, compute the total adjustment from listing to that date df = df.sort_values(['ticker', 'date'])# Merge adjustment events# For each ticker-date, find the cumulative factor as of that datedef get_cum_factor_at_date(group): ticker = group.name ticker_adj = adj_factors[adj_factors['ticker'] == ticker].copy()iflen(ticker_adj) ==0: group['cum_adj_factor'] =1.0return group# For each date, find cumulative factor (product of all events up to that date) group = group.sort_values('date') group['cum_adj_factor'] =1.0for _, event in ticker_adj.iterrows(): mask = group['date'] >= event['ex_date'] group.loc[mask, 'cum_adj_factor'] *= event['point_factor']return group df = df.groupby('ticker', group_keys=False).apply(get_cum_factor_at_date)# Adjusted price and shares# adjusted_close should already be provided by DataCore.vn# But we compute our own for consistencyif'adjusted_close'notin df.columns: df['adjusted_close'] = df['close'] / df['cum_adj_factor']# Adjusted shares outstanding df['adjusted_shares'] = df['shares_outstanding'] * df['cum_adj_factor']# Market capitalization (in billion VND) df['market_cap'] = df['close'] * df['shares_outstanding'] /1e9# Monthly returns df = df.sort_values(['ticker', 'date']) df['ret'] = df.groupby('ticker')['adjusted_close'].pct_change()# Keep quarter-end observations# For daily data: keep last trading day of each quarter df_quarterly = (df.sort_values(['ticker', 'quarter_end', 'date']) .groupby(['ticker', 'quarter_end']) .last() .reset_index())print(f"Processed price data:")print(f" Total records (daily): {len(df):,}")print(f" Quarter-end records: {len(df_quarterly):,}")print(f" Unique tickers: {df_quarterly['ticker'].nunique():,}")print(f" Date range: {df_quarterly['quarter_end'].min()} to "f"{df_quarterly['quarter_end'].max()}")print(f"\nExchange distribution:")print(df_quarterly.groupby('exchange')['ticker'].nunique().to_string())return df_quarterly# prices_q = process_price_data(dc.prices, adj_factors, dc.company_profile)
33.3.2 Dữ liệu cơ cấu sở hữu
Dữ liệu sở hữu của Việt Nam ghi nhận cơ cấu cổ đông như được công bố trong báo cáo thường niên, báo cáo bán niên và các công bố theo sự kiện. Điểm khác biệt chính so với dữ liệu 13F của Mỹ là các công bố của Việt Nam cung cấp sự phân rã sở hữu đầy đủ, không chỉ các vị thế mua của tổ chức, mà là toàn bộ cơ cấu gồm sở hữu nhà nước, tổ chức, nước ngoài và cá nhân.
Process and Standardize Ownership Structure Data
# ============================================================================# Step 3: Process Ownership Structure Data# ============================================================================class OwnershipType:""" Vietnam's ownership taxonomy. Unlike the US where 13F captures only institutional long positions, Vietnamese disclosure provides a complete ownership decomposition. We classify shareholders into five mutually exclusive categories. """ STATE ='state'# Nhà nước (government entities, SOE parents) FOREIGN_INST ='foreign_inst'# Tổ chức nước ngoài DOMESTIC_INST ='domestic_inst'# Tổ chức trong nước (non-state) INDIVIDUAL ='individual'# Cá nhân TREASURY ='treasury'# Cổ phiếu quỹ ALL_TYPES = [STATE, FOREIGN_INST, DOMESTIC_INST, INDIVIDUAL, TREASURY] INSTITUTIONAL = [STATE, FOREIGN_INST, DOMESTIC_INST] FOREIGN = [FOREIGN_INST] # Can be expanded if foreign individuals are trackeddef classify_shareholders(ownership: pd.DataFrame) -> pd.DataFrame:""" Classify shareholders into Vietnam's ownership taxonomy. DataCore.vn may provide a `shareholder_type` field, but naming conventions vary. This function standardizes the classification using a combination of provided flags and name-based heuristics. The classification challenge in Vietnam (noted by @huang2023factors): DataCore.vn may not always cleanly separate institution types, so we use a cascading approach: 1. Use explicit flags (is_state, is_foreign, is_institution) if available 2. Apply name-based heuristics for Vietnamese entity names 3. Default to 'individual' for unclassified shareholders Parameters ---------- ownership : pd.DataFrame Raw ownership data from DataCore.vn Returns ------- pd.DataFrame Ownership data with standardized `owner_type` column """ df = ownership.copy()# --- Method 1: Use explicit flags if available ---ifall(col in df.columns for col in ['is_state', 'is_foreign', 'is_institution']): conditions = [ (df['is_state'] ==True), (df['is_foreign'] ==True) & (df['is_institution'] ==True), (df['is_foreign'] ==True) & (df['is_institution'] !=True), (df['is_institution'] ==True) & (df['is_state'] !=True) & (df['is_foreign'] !=True), ] choices = [ OwnershipType.STATE, OwnershipType.FOREIGN_INST, OwnershipType.FOREIGN_INST, # Foreign individuals often grouped OwnershipType.DOMESTIC_INST, ] df['owner_type'] = np.select(conditions, choices, default=OwnershipType.INDIVIDUAL)# --- Method 2: Name-based heuristics ---elif'shareholder_name'in df.columns: name = df['shareholder_name'].str.lower().fillna('')# State entities: government ministries, SCIC, state corporations state_keywords = ['bộ tài chính', 'tổng công ty đầu tư', 'scic', 'ủy ban nhân dân', 'nhà nước', 'state capital','tổng công ty', 'vốn nhà nước', 'bộ công thương','bộ quốc phòng', 'bộ giao thông', 'vinashin', ] is_state = name.apply(lambda x: any(kw in x for kw in state_keywords) )# Foreign entities: common fund names, foreign company patterns foreign_keywords = ['fund', 'investment', 'capital', 'limited', 'ltd', 'inc','corporation', 'holdings', 'asset management', 'pte','gmbh', 'management', 'partners', 'advisors','dragon capital', 'vinacapital', 'templeton', 'blackrock', 'jpmorgan', 'samsung', 'mirae', ]# Also check for non-Vietnamese characters as a heuristic is_foreign_name = name.apply(lambda x: any(kw in x for kw in foreign_keywords) )# Domestic institutions: Vietnamese bank, securities, insurance names domestic_inst_keywords = ['ngân hàng', 'chứng khoán', 'bảo hiểm', 'quỹ đầu tư','công ty quản lý', 'bảo việt', 'techcombank', 'vietcombank','bidv', 'vietinbank', 'vpbank', 'mb bank', 'ssi', 'hsc','vcsc', 'vndirect', 'fpt capital', 'manulife', ] is_domestic_inst = name.apply(lambda x: any(kw in x for kw in domestic_inst_keywords) )# Treasury shares is_treasury = name.str.contains('cổ phiếu quỹ|treasury', case=False)# Apply classification cascade df['owner_type'] = OwnershipType.INDIVIDUAL # Default df.loc[is_domestic_inst, 'owner_type'] = OwnershipType.DOMESTIC_INST df.loc[is_foreign_name, 'owner_type'] = OwnershipType.FOREIGN_INST df.loc[is_state, 'owner_type'] = OwnershipType.STATE df.loc[is_treasury, 'owner_type'] = OwnershipType.TREASURY# --- Method 3: Use shareholder_type directly ---elif'shareholder_type'in df.columns: type_map = {'state': OwnershipType.STATE,'foreign_institution': OwnershipType.FOREIGN_INST,'foreign_individual': OwnershipType.FOREIGN_INST,'domestic_institution': OwnershipType.DOMESTIC_INST,'individual': OwnershipType.INDIVIDUAL,'treasury': OwnershipType.TREASURY, } df['owner_type'] = df['shareholder_type'].str.lower().map(type_map) df['owner_type'] = df['owner_type'].fillna(OwnershipType.INDIVIDUAL)else:raiseValueError("Cannot classify shareholders. Expected one of:\n"" 1. Columns: is_state, is_foreign, is_institution\n"" 2. Column: shareholder_name (for heuristic classification)\n"" 3. Column: shareholder_type (pre-classified)" )# Summaryprint("Ownership classification results:")print(df['owner_type'].value_counts().to_string())return df# ownership_classified = classify_shareholders(dc.ownership)
33.4 Hệ phân loại sở hữu của Việt Nam
33.4.1 Năm nhóm sở hữu
Cơ cấu sở hữu của Việt Nam được phân rã thành năm nhóm loại trừ lẫn nhau, có tổng bằng 100% số cổ phiếu đang lưu hành:
Retail investors (both Vietnamese and foreign individuals)
~55-65%
Treasury
Cổ phiếu quỹ
Company’s own repurchased shares
~0-2%
Hệ phân loại này khác căn bản với khung 13F của Mỹ ở nhiều điểm:
Tính đầy đủ: Chúng ta quan sát được 100% sở hữu, chứ không chỉ các vị thế mua của tổ chức có tài sản quản lý trên $100 triệu.
Nhà nước là một nhóm riêng: Sở hữu nhà nước là một nhóm phân tích độc lập, không bị gộp vào “Các nhóm khác” như trong hệ mã loại hình của LSEG.
Khả năng quan sát cá nhân: Chúng ta quan sát trực tiếp tổng sở hữu của cá nhân, trong khi ở Mỹ, sở hữu cá nhân chỉ đơn thuần là phần còn lại (100% − sở hữu tổ chức).
Không có sự mơ hồ về vị thế bán khống: Thị trường Việt Nam có hạ tầng bán khống rất hạn chế, nên dữ liệu sở hữu thực sự phản ánh các vị thế mua.
Compute Full Ownership Decomposition for Each Stock-Period
# ============================================================================# Step 4: Compute Ownership Decomposition# ============================================================================def compute_ownership_decomposition(ownership: pd.DataFrame, prices_q: pd.DataFrame) -> pd.DataFrame:""" Compute the full ownership decomposition for each stock at each disclosure date. For each stock-date combination, aggregates shares held by each ownership category and computes ownership ratios relative to total shares outstanding. Parameters ---------- ownership : pd.DataFrame Classified ownership data (output of classify_shareholders) prices_q : pd.DataFrame Quarter-end price data with shares_outstanding Returns ------- pd.DataFrame Stock-period level ownership decomposition with columns for each ownership type's share count and percentage """# Aggregate shares by ticker, date, and owner type agg = (ownership.groupby(['ticker', 'date', 'owner_type'])['shares_held'] .sum() .reset_index())# Pivot to wide format: one column per ownership type wide = agg.pivot_table( index=['ticker', 'date'], columns='owner_type', values='shares_held', fill_value=0 ).reset_index()# Rename columns type_cols = [c for c in wide.columns if c in OwnershipType.ALL_TYPES] rename_map = {t: f'shares_{t}'for t in type_cols} wide = wide.rename(columns=rename_map)# Total institutional shares inst_cols = [f'shares_{t}'for t in OwnershipType.INSTITUTIONAL iff'shares_{t}'in wide.columns] wide['shares_institutional'] = wide[inst_cols].sum(axis=1)# Total foreign shares (for FOL tracking) foreign_cols = [f'shares_{t}'for t in OwnershipType.FOREIGN iff'shares_{t}'in wide.columns] wide['shares_foreign_total'] = wide[foreign_cols].sum(axis=1)# Align with quarter-end dates for merging with price data wide['quarter_end'] = wide['date'] + pd.offsets.QuarterEnd(0)# Merge with price data to get shares outstanding merged = wide.merge( prices_q[['ticker', 'quarter_end', 'shares_outstanding', 'adjusted_shares', 'market_cap', 'exchange', 'industry_code', 'fol_limit', 'close']], on=['ticker', 'quarter_end'], how='left' )# Compute ownership ratios tso = merged['shares_outstanding']for col in merged.columns:if col.startswith('shares_') and col !='shares_outstanding': ratio_col = col.replace('shares_', 'pct_') merged[ratio_col] = merged[col] / tso merged.loc[tso <=0, ratio_col] = np.nan# Derived measures merged['pct_free_float'] =1- merged.get('pct_state', 0) - merged.get('pct_treasury', 0)# SOE flag: state ownership > 50% merged['is_soe'] = (merged.get('pct_state', 0) >0.50).astype(int)# FOL utilizationif'fol_limit'in merged.columns and'pct_foreign_total'in merged.columns: merged['fol_utilization'] = merged['pct_foreign_total'] / merged['fol_limit'] merged['foreign_room'] = merged['fol_limit'] - merged['pct_foreign_total'] merged.loc[merged['fol_limit'] <=0, ['fol_utilization', 'foreign_room']] = np.nan# Number of institutional owners (breadth) n_owners = (ownership[ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL)] .groupby(['ticker', 'date'])['shareholder_name'] .nunique() .reset_index() .rename(columns={'shareholder_name': 'n_inst_owners'})) n_foreign_owners = (ownership[ownership['owner_type'] == OwnershipType.FOREIGN_INST] .groupby(['ticker', 'date'])['shareholder_name'] .nunique() .reset_index() .rename(columns={'shareholder_name': 'n_foreign_owners'})) merged = merged.merge(n_owners, on=['ticker', 'date'], how='left') merged = merged.merge(n_foreign_owners, on=['ticker', 'date'], how='left') merged[['n_inst_owners', 'n_foreign_owners']] = ( merged[['n_inst_owners', 'n_foreign_owners']].fillna(0) )print(f"Ownership decomposition computed:")print(f" Stock-period observations: {len(merged):,}")print(f" Unique tickers: {merged['ticker'].nunique():,}")print(f"\nMean ownership structure:") pct_cols = [c for c in merged.columns if c.startswith('pct_')]print(merged[pct_cols].mean().round(4).to_string())return merged# ownership_decomp = compute_ownership_decomposition(# ownership_classified, prices_q# )
33.5 Các thước đo sở hữu tổ chức
33.5.1 Tỷ lệ sở hữu
Tỷ lệ sở hữu tổ chức (IOR) của cổ phiếu \(i\) tại thời điểm \(t\) ở Việt Nam là:
trong đó \(S_{i,t}^{type}\) ký hiệu số cổ phiếu đã điều chỉnh do mỗi nhóm sở hữu nắm giữ và \(TSO_{i,t}\) là tổng số cổ phiếu đang lưu hành. Khác với Mỹ, nơi IOR có thể vượt 100% do báo cáo chỉ các vị thế mua và do bán khống, IOR của Việt Nam bị chặn theo cấu trúc trong khoảng \([0, 1]\) vì chúng ta quan sát được toàn bộ sự phân rã sở hữu.
Chúng ta cũng tính các tỷ lệ sở hữu theo từng nhóm:
Ở Việt Nam, HHI đặc biệt hữu ích vì nó phản ánh sự chi phối của các cổ đông nhà nước. Một công ty mà Chính phủ nắm 65% sẽ có HHI cao một cách cơ học, ngay cả khi 35% còn lại được nắm giữ phân tán.
Do đó, chúng ta tính các thước đo HHI riêng biệt cho các nhóm sở hữu khác nhau:
trong đó \(N_{i,t}^{cont}\) chỉ đếm các tổ chức xuất hiện trong tập hợp công bố ở cả hai kỳ \(t\) và \(t-1\), theo thuật toán của Lehavy và Sloan (2008). Điều chỉnh này đặc biệt quan trọng ở Việt Nam vì:
Các quỹ mới được thành lập thường xuyên (đặc biệt là các ETF bám sát VN30)
Các quỹ nước ngoài gia nhập và rút khỏi thị trường
Các công ty chứng khoán trong nước hợp nhất hoặc tách các mảng quản lý tài sản
Compute All Institutional Ownership Metrics for Vietnam
# ============================================================================# Step 5: Compute All IO Metrics# ============================================================================def compute_io_metrics_vietnam(ownership: pd.DataFrame, ownership_decomp: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Compute security-level institutional ownership metrics adapted for Vietnam. Computes: 1. Ownership ratios by category (state, foreign, domestic inst, individual) 2. HHI concentration (total, non-state, foreign-only) 3. Number of institutional owners (total, foreign, domestic) 4. Change in breadth (Lehavy-Sloan adjusted) 5. FOL-related metrics (utilization, room, near-cap indicator) Parameters ---------- ownership : pd.DataFrame Classified ownership data with individual shareholder records ownership_decomp : pd.DataFrame Aggregated ownership decomposition (output of compute_ownership_decomposition) adj_factors : pd.DataFrame Corporate action adjustment factors Returns ------- pd.DataFrame Stock-period level metrics """# Start with the ownership decomposition metrics = ownership_decomp.copy()# --- HHI Concentration ---# Total HHI: across all institutional shareholders inst_ownership = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy()def compute_hhi_group(group):"""Compute HHI for a group of shareholders.""" total = group['shares_held'].sum()if total <=0:return np.nan weights = group['shares_held'] / totalreturn (weights **2).sum()# Total institutional HHI hhi_total = (inst_ownership.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_institutional')) metrics = metrics.merge(hhi_total, on=['ticker', 'date'], how='left')# Non-state HHI (exclude state shareholders) non_state = ownership[ ownership['owner_type'].isin([OwnershipType.FOREIGN_INST, OwnershipType.DOMESTIC_INST]) ] hhi_nonstate = (non_state.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_non_state')) metrics = metrics.merge(hhi_nonstate, on=['ticker', 'date'], how='left')# Foreign-only HHI foreign_only = ownership[ownership['owner_type'] == OwnershipType.FOREIGN_INST] hhi_foreign = (foreign_only.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_foreign')) metrics = metrics.merge(hhi_foreign, on=['ticker', 'date'], how='left')# --- Change in Breadth (Lehavy-Sloan Algorithm) --- metrics = metrics.sort_values(['ticker', 'date'])# Get list of all institutions filing in each period inst_by_period = (inst_ownership.groupby('date')['shareholder_name'] .apply(set) .to_dict())# For each stock-period: count continuing institutionsdef compute_breadth_change(group): group = group.sort_values('date').reset_index(drop=True) group['dbreadth'] = np.nanfor i inrange(1, len(group)): current_date = group.loc[i, 'date'] prev_date = group.loc[i-1, 'date']# Institutions in universe for both periods current_universe = inst_by_period.get(current_date, set()) prev_universe = inst_by_period.get(prev_date, set()) continuing_universe = current_universe & prev_universeiflen(prev_universe) ==0:continue# Count continuing institutions holding this stock in each period ticker = group.loc[i, 'ticker'] current_holders =set( inst_ownership[ (inst_ownership['ticker'] == ticker) & (inst_ownership['date'] == current_date) ]['shareholder_name'] ) prev_holders =set( inst_ownership[ (inst_ownership['ticker'] == ticker) & (inst_ownership['date'] == prev_date) ]['shareholder_name'] )# Count only continuing institutions n_current_cont =len(current_holders & continuing_universe) n_prev_cont =len(prev_holders & continuing_universe) group.loc[i, 'dbreadth'] = ( (n_current_cont - n_prev_cont) /len(prev_universe) )return group metrics = metrics.groupby('ticker', group_keys=False).apply(compute_breadth_change)# --- FOL Indicators ---if'fol_utilization'in metrics.columns: metrics['near_fol_cap'] = (metrics['fol_utilization'] >0.90).astype(int) metrics['at_fol_cap'] = (metrics['fol_utilization'] >0.98).astype(int)print(f"IO metrics computed for Vietnam:")print(f" Observations: {len(metrics):,}")print(f"\nKey metric distributions:") summary_cols = ['pct_institutional', 'pct_state', 'pct_foreign_total','hhi_institutional', 'n_inst_owners', 'dbreadth'] summary_cols = [c for c in summary_cols if c in metrics.columns]print(metrics[summary_cols].describe().round(4).to_string())return metrics# io_metrics = compute_io_metrics_vietnam(# ownership_classified, ownership_decomp, adj_factors# )
Giới hạn sở hữu nước ngoài của Việt Nam tạo ra sự phân khúc thị trường đặc thù. Khi một cổ phiếu chạm FOL, cách duy nhất để một nhà đầu tư nước ngoài mới mua được là một chủ sở hữu nước ngoài hiện hữu bán ra. Điều này tạo ra một thị trường “chỉ dành cho người nước ngoài” trên thực tế đối với các cổ phiếu bị ràng buộc bởi FOL, với các phần bù giá đã được ghi nhận (Vo 2015).
Tỷ lệ sử dụng FOL của cổ phiếu \(i\) tại thời điểm \(t\) là:
Ở Mỹ, các giao dịch của tổ chức được suy ra từ các ảnh chụp danh mục nắm giữ 13F hằng quý. Ở Việt Nam, thách thức còn gay gắt hơn vì tần suất công bố không đồng nhất:
Cổ đông lớn (\(\ge\)5%): Phải công bố trong vòng 7 ngày làm việc kể từ khi vượt các ngưỡng sở hữu (5%, 10%, 15%, 20%, 25%, 50%, 65%, 75%)
Báo cáo danh mục của quỹ: Bắt buộc công bố bán niên; một số quỹ báo cáo hằng quý
Báo cáo thường niên: Cung cấp danh sách cổ đông đầy đủ nhưng chỉ một lần mỗi năm
Sở hữu nước ngoài hằng ngày: HOSE/HNX công bố dữ liệu tổng hợp mua/bán của nhà đầu tư nước ngoài hằng ngày
Chúng ta suy ra các giao dịch từ thay đổi sở hữu giữa các ngày công bố liên tiếp, áp dụng cùng logic với thuật toán Ben-David và c.s. (2013) của Mỹ nhưng điều chỉnh cho phù hợp với các khoảng cách công bố không đều của Việt Nam.
Cần lưu ý rằng mọi bài tập suy luận giao dịch dựa trên danh mục nắm giữ được công bố đều có một hạn chế: bản thân các công bố có thể mang tính chiến lược. Ngay cả trong chế độ 13F hằng quý bắt buộc của Mỹ, Cao và c.s. (2026) ghi nhận rằng các quỹ phòng hộ đôi khi khai sai báo cáo gốc và lặng lẽ sửa lại sau đó thông qua các bản điều chỉnh (phổ biến ngang với các báo cáo bảo mật và ảnh hưởng đến số cổ phiếu nhiều gấp khoảng ba lần), với các mô thức lợi nhuận cho thấy việc khai sai che giấu ý định giao dịch trong lúc các vị thế đang được xây dựng. Các công bố sở hữu của Việt Nam (theo sự kiện, ít chuẩn hóa hơn và ít bị giám sát chặt chẽ hơn so với báo cáo 13F) đáng được hoài nghi ít nhất ở mức tương đương: hãy coi các ảnh chụp là những báo cáo có thể được chọn thời điểm một cách chiến lược hoặc chưa đầy đủ thay vì là sự thật tuyệt đối, và đối chiếu chéo chúng với các nguồn độc lập như dữ liệu dòng vốn nước ngoài hằng ngày và danh sách cổ đông thường niên bất cứ khi nào có thể.
Derive Institutional Trades from Vietnamese Ownership Disclosures
# ============================================================================# Step 7: Derive Institutional Trades# ============================================================================def derive_trades_vietnam(ownership: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Derive institutional trades from changes in ownership disclosures. Adapted from Ben-David, Franzoni, and Moussawi (2012) for Vietnam's irregular disclosure frequency. Key differences from US approach: 1. Disclosure intervals are irregular (not always quarterly) 2. We observe ALL institutional types, not just 13F filers 3. No $100M AUM threshold (we see all institutional holders) 4. Must adjust for corporate actions between disclosure dates Trade types: +1: Initiating Buy (new position) +2: Incremental Buy (increased existing position) -1: Terminating Sale (fully exited position) -2: Incremental Sale (reduced existing position) Parameters ---------- ownership : pd.DataFrame Classified ownership with: ticker, date, shareholder_name, shares_held, owner_type adj_factors : pd.DataFrame Corporate action adjustment factors Returns ------- pd.DataFrame Trade-level data: date, shareholder_name, ticker, trade, buysale, owner_type """# Focus on institutional shareholders only inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy() inst = inst.sort_values(['shareholder_name', 'ticker', 'date']).reset_index(drop=True) trades_list = []for (shareholder, ticker), group in inst.groupby(['shareholder_name', 'ticker']): group = group.reset_index(drop=True)for i inrange(len(group)): current = group.iloc[i] current_date = current['date'] current_shares = current['shares_held'] owner_type = current['owner_type']if i ==0:# First observation: if institution appears, it's an initiating buy# (we don't know if they held before our data starts)# Skip the very first observation to avoid false initiating buyscontinue prev = group.iloc[i -1] prev_date = prev['date'] prev_shares = prev['shares_held']# Adjust previous shares for corporate actions between dates prev_shares_adj = adjust_shares( prev_shares, ticker, prev_date, current_date, adj_factors )# Compute trade (in adjusted shares) trade = current_shares - prev_shares_adj# Classify trade typeifabs(trade) <1: # De minimis thresholdcontinueif prev_shares_adj <=0and current_shares >0: buysale =1# Initiating buyelif prev_shares_adj >0and current_shares <=0: buysale =-1# Terminating saleelif trade >0: buysale =2# Incremental buyelse: buysale =-2# Incremental sale trades_list.append({'date': current_date,'shareholder_name': shareholder,'ticker': ticker,'trade': trade,'prev_shares_adj': prev_shares_adj,'current_shares': current_shares,'buysale': buysale,'owner_type': owner_type,'days_between': (current_date - prev_date).days, }) trades = pd.DataFrame(trades_list)iflen(trades) >0:print(f"Trades derived: {len(trades):,}")print(f"\nTrade type distribution:") labels = {1: 'Initiating Buy', 2: 'Incremental Buy',-1: 'Terminating Sale', -2: 'Incremental Sale'}for bs, label insorted(labels.items()): n = (trades['buysale'] == bs).sum()print(f" {label}: {n:,} ({n/len(trades):.1%})")print(f"\nBy owner type:")print(trades.groupby('owner_type')['trade'].agg(['count', 'mean', 'median']) .round(0).to_string())return trades# trades = derive_trades_vietnam(ownership_classified, adj_factors)
Điều chỉnh theo sự kiện doanh nghiệp khi suy ra giao dịch
Khi tính giao dịch dưới dạng \(\Delta Shares = Shares_t - Shares_{t-1}\), số cổ phiếu của kỳ trước phải được điều chỉnh theo mọi sự kiện doanh nghiệp xảy ra giữa \(t-1\) và \(t\). Nếu VNM phát hành cổ tức bằng cổ phiếu 20% giữa hai ngày công bố, thì 1.000 cổ phiếu tại \(t-1\) cần được so sánh với 1.200 cổ phiếu đã điều chỉnh, chứ không phải 1.000 cổ phiếu thô. Việc không thực hiện điều chỉnh này sẽ tạo ra một giao dịch “mua” ảo 200 cổ phiếu chưa từng thực sự xảy ra.
Vectorized Trade Derivation for Large Datasets
def derive_trades_vectorized_vietnam(ownership: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Vectorized version of Vietnamese trade derivation. Uses pandas groupby and vectorized operations instead of Python loops. Approximately 20-50x faster for large datasets. Note: Corporate action adjustment is applied per-group, which still requires some iteration but is much faster than row-by-row. """ inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) & (ownership['shares_held'] >0) ].copy() inst = inst.sort_values(['shareholder_name', 'ticker', 'date']).reset_index(drop=True)# Lagged values inst['prev_date'] = inst.groupby(['shareholder_name', 'ticker'])['date'].shift(1) inst['prev_shares'] = inst.groupby(['shareholder_name', 'ticker'])['shares_held'].shift(1) inst['is_first'] = inst['prev_date'].isna()# Remove first observations (no prior to compare) inst = inst[~inst['is_first']].copy()# Adjust previous shares for corporate actions# Vectorized: for each row, apply adjustment between prev_date and datedef adjust_row(row):return adjust_shares( row['prev_shares'], row['ticker'], row['prev_date'], row['date'], adj_factors ) inst['prev_shares_adj'] = inst.apply(adjust_row, axis=1)# Compute trade inst['trade'] = inst['shares_held'] - inst['prev_shares_adj'] inst['days_between'] = (inst['date'] - inst['prev_date']).dt.days# Classify trade type inst['buysale'] = np.select( [ (inst['prev_shares_adj'] <=0) & (inst['shares_held'] >0), (inst['prev_shares_adj'] >0) & (inst['shares_held'] <=0), inst['trade'] >0, inst['trade'] <0, ], [1, -1, 2, -2], default=0 )# Remove zero trades trades = inst[inst['buysale'] !=0].copy() trades = trades[['date', 'shareholder_name', 'ticker', 'trade', 'buysale', 'owner_type', 'days_between','prev_shares_adj', 'shares_held']].copy() trades = trades.rename(columns={'shares_held': 'current_shares'})print(f"Vectorized trades: {len(trades):,}")return trades# trades = derive_trades_vectorized_vietnam(ownership_classified, adj_factors)
33.8 Dòng vốn và tỷ lệ vòng quay ở cấp quỹ
33.8.1 Tài sản và lợi nhuận của danh mục từ dữ liệu nắm giữ của quỹ
Sử dụng dữ liệu danh mục nắm giữ của quỹ từ DataCore.vn, chúng ta tính các phân tích danh mục ở cấp quỹ tương tự cách tiếp cận 13F của Mỹ:
33.9.1 Cổ phần hóa và sự suy giảm của sở hữu nhà nước
Vietnam’s equitization (cổ phần hóa) program has been a defining feature of the market since the early 2000s. The program converts state-owned enterprises into joint-stock companies, typically with the state retaining a controlling or significant minority stake that is then gradually reduced through secondary offerings.
Analyze State Ownership Dynamics and Equitization Trends
# ============================================================================# Step 9: State Ownership Analysis# ============================================================================def analyze_state_ownership(metrics: pd.DataFrame) -> Dict:""" Comprehensive analysis of state ownership in Vietnam. Computes: 1. Aggregate state ownership trends 2. SOE population dynamics (entry/exit from SOE classification) 3. Equitization event detection (large drops in state ownership) 4. State ownership by sector and size 5. Governance implications (state as blockholder) """ df = metrics.copy()# --- 1. Aggregate Trends --- ts = df.groupby('quarter_end').agg( n_soe=('is_soe', 'sum'), n_total=('ticker', 'nunique'), pct_soe=('is_soe', 'mean'), mean_state_pct=('pct_state', 'mean'), median_state_pct=('pct_state', 'median'),# Market cap share of SOEs soe_mktcap=('market_cap', lambda x: x[df.loc[x.index, 'is_soe'] ==1].sum()), total_mktcap=('market_cap', 'sum'), ).reset_index() ts['soe_mktcap_share'] = ts['soe_mktcap'] / ts['total_mktcap']# --- 2. Equitization Events ---# Detect large drops in state ownership (>10 percentage points) df_sorted = df.sort_values(['ticker', 'quarter_end']) df_sorted['state_change'] = df_sorted.groupby('ticker')['pct_state'].diff() equitization_events = df_sorted[ df_sorted['state_change'] <-0.10# > 10pp drop ][['ticker', 'quarter_end', 'pct_state', 'state_change', 'market_cap']].copy()# --- 3. By Sector ---if'industry_code'in df.columns: by_sector = df.groupby('industry_code').agg( mean_state=('pct_state', 'mean'), pct_soe=('is_soe', 'mean'), n_firms=('ticker', 'nunique'), ).sort_values('mean_state', ascending=False)else: by_sector =Noneprint(f"State Ownership Analysis:")print(f" Current SOE count: {ts.iloc[-1]['n_soe']:.0f} / {ts.iloc[-1]['n_total']:.0f}")print(f" SOE market cap share: {ts.iloc[-1]['soe_mktcap_share']:.1%}")print(f" Mean state ownership: {ts.iloc[-1]['mean_state_pct']:.1%}")print(f"\nEquitization events detected: {len(equitization_events):,}")return {'trends': ts,'equitization_events': equitization_events,'by_sector': by_sector, }# state_analysis = analyze_state_ownership(io_metrics)
Hiện mã
def plot_state_ownership(state_analysis: Dict, metrics: pd.DataFrame):"""Plot state ownership dynamics.""" fig, axes = plt.subplots(2, 1, figsize=(12, 10)) ts = state_analysis['trends']# Panel A: SOE trends ax = axes[0] ax.plot(ts['quarter_end'], ts['pct_soe'] *100, label='% of Firms that are SOEs', linewidth=2, color='#d62728') ax.plot(ts['quarter_end'], ts['soe_mktcap_share'] *100, label='SOE Market Cap Share (%)', linewidth=2, color='#1f77b4') ax.plot(ts['quarter_end'], ts['mean_state_pct'] *100, label='Mean State Ownership (%)', linewidth=2, color='#2ca02c', linestyle='--') ax.set_ylabel('Percentage') ax.set_title('Panel A: State Ownership and SOE Prevalence Over Time') ax.legend(frameon=True, framealpha=0.9)# Panel B: Distribution ax = axes[1]# Use most recent period latest = metrics[metrics['quarter_end'] == metrics['quarter_end'].max()] state_pct = latest['pct_state'].dropna() *100 ax.hist(state_pct, bins=50, color='#d62728', alpha=0.7, edgecolor='black') ax.axvline(x=50, color='black', linestyle='--', alpha=0.7, label='50% (SOE threshold)') ax.set_xlabel('State Ownership (%)') ax.set_ylabel('Number of Companies') ax.set_title('Panel B: Distribution of State Ownership (Most Recent Quarter)') ax.legend() plt.tight_layout() plt.savefig('fig_state_ownership.png', dpi=300, bbox_inches='tight') plt.show()# plot_state_ownership(state_analysis, io_metrics)
Hình 33.4
33.10 Các mở rộng hiện đại
33.10.1 Phân tích mạng lưới đồng sở hữu
Các mạng lưới đồng sở hữu của tổ chức cho thấy các cổ phiếu được kết nối với nhau như thế nào thông qua các nhà đầu tư chung. Ở Việt Nam, các mạng lưới này tiết lộ cấu trúc ảnh hưởng của các tập đoàn lớn trong nước (ví dụ: Vingroup, Masan, FPT) và sự chồng lấn giữa các danh mục của quỹ nước ngoài.
Construct Co-Ownership Network for Vietnamese Stocks
def construct_stock_coownership_network(ownership: pd.DataFrame, period: str, min_overlap: int=3) -> Dict:""" Construct a stock-level co-ownership network. Two stocks are connected if they share institutional investors. Edge weight = number of shared institutional investors. This is particularly informative in Vietnam where: - Foreign fund portfolios concentrate on the same blue-chips - Conglomerate cross-holdings create explicit linkages - State ownership creates implicit connections (SCIC holds multiple stocks) Parameters ---------- ownership : pd.DataFrame Classified ownership data period : str Analysis date min_overlap : int Minimum shared investors to create an edge Returns ------- dict with network statistics and adjacency data """import networkx as nx date = pd.Timestamp(period)# Get institutional holders for this period inst = ownership[ (ownership['date'] == date) & (ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL)) ][['ticker', 'shareholder_name', 'owner_type']].copy()# Create bipartite mapping: institution → set of stocks held inst_to_stocks = inst.groupby('shareholder_name')['ticker'].apply(set).to_dict()# Stock → set of institutions stock_to_inst = inst.groupby('ticker')['shareholder_name'].apply(set).to_dict()# Build stock-level network stocks =list(stock_to_inst.keys()) G = nx.Graph()for i inrange(len(stocks)):for j inrange(i +1, len(stocks)): shared = stock_to_inst[stocks[i]] & stock_to_inst[stocks[j]]iflen(shared) >= min_overlap: G.add_edge(stocks[i], stocks[j], weight=len(shared), shared_investors=list(shared)[:5]) # Store sample# Add node attributesfor stock in stocks:if stock in G.nodes: G.nodes[stock]['n_inst_holders'] =len(stock_to_inst[stock])# Network statistics stats = {'n_nodes': G.number_of_nodes(),'n_edges': G.number_of_edges(),'density': nx.density(G) if G.number_of_nodes() >1else0,'avg_clustering': nx.average_clustering(G, weight='weight') if G.number_of_nodes() >0else0,'n_components': nx.number_connected_components(G), }# Centrality measuresif G.number_of_nodes() >0: degree_cent = nx.degree_centrality(G) stats['most_connected'] =sorted(degree_cent.items(), key=lambda x: x[1], reverse=True)[:10]if G.number_of_nodes() >2:try: eigen_cent = nx.eigenvector_centrality_numpy(G, weight='weight') stats['most_central'] =sorted(eigen_cent.items(), key=lambda x: x[1], reverse=True)[:10]exceptException: stats['most_central'] = []print(f"Co-Ownership Network ({period}):")for k, v in stats.items():if k notin ['most_connected', 'most_central']:print(f" {k}: {v}")if'most_connected'in stats:print(f"\nMost connected stocks:")for stock, cent in stats['most_connected'][:5]:print(f" {stock}: {cent:.3f}")return {'graph': G, 'stats': stats}# network = construct_stock_coownership_network(# ownership_classified, '2024-06-30'# )
33.10.2 Phân loại nhà đầu tư nâng cao bằng học máy
Thách thức phân loại nhà đầu tư của Việt Nam khác với Mỹ. Trong khi Mỹ có phân loại Bushee dựa trên tỷ lệ vòng quay và mức độ tập trung của danh mục, Việt Nam đòi hỏi phân loại cả loại hình nhà đầu tư (khi không được gắn nhãn rõ ràng) lẫn hành vi nhà đầu tư (chủ động so với thụ động, ngắn hạn so với dài hạn).
Machine Learning Investor Classification for Vietnam
def classify_investors_vietnam(ownership: pd.DataFrame, prices_q: pd.DataFrame, n_clusters: int=4) -> pd.DataFrame:""" ML-based classification of Vietnamese institutional investors. Features adapted for Vietnam's market: 1. Portfolio concentration (HHI of holdings) 2. Holding duration (average time in positions) 3. Size preference (average market cap of holdings) 4. Sector concentration 5. Foreign/domestic indicator 6. Trading frequency (inverse of average days between disclosures) Expected clusters for Vietnam: - Passive State Holders: SOE parents, SCIC - low turnover, concentrated - Active Foreign Funds: Dragon Capital, VinaCapital - moderate turnover - Domestic Securities Firms: SSI, VNDirect - high turnover, diversified - Long-Term Foreign: Pension funds, sovereign wealth - low turnover """from sklearn.cluster import KMeansfrom sklearn.preprocessing import StandardScaler inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy()# Merge with price data inst = inst.merge( prices_q[['ticker', 'quarter_end', 'close', 'market_cap']], left_on=['ticker', 'date'], right_on=['ticker', 'quarter_end'], how='left' ) inst['holding_value'] = inst['shares_held'] * inst['close'].fillna(0)# Compute features per investor-period features = inst.groupby(['shareholder_name', 'date']).agg( n_stocks=('ticker', 'nunique'), total_value=('holding_value', 'sum'), hhi_portfolio=('holding_value', lambda x: ((x/x.sum())**2).sum() if x.sum() >0else np.nan), avg_mktcap=('market_cap', 'mean'), is_foreign=('owner_type', lambda x: (x == OwnershipType.FOREIGN_INST).any().astype(int)), is_state=('owner_type', lambda x: (x == OwnershipType.STATE).any().astype(int)), ).reset_index()# Average across all periods per investor investor_features = features.groupby('shareholder_name').agg( avg_n_stocks=('n_stocks', 'mean'), avg_hhi=('hhi_portfolio', 'mean'), avg_mktcap=('avg_mktcap', 'mean'), avg_total_value=('total_value', 'mean'), is_foreign=('is_foreign', 'max'), is_state=('is_state', 'max'), n_periods=('date', 'nunique'), ).dropna()# Feature matrix feature_cols = ['avg_n_stocks', 'avg_hhi', 'avg_mktcap', 'avg_total_value'] X = investor_features[feature_cols].copy()# Log-transformfor col in feature_cols: X[col] = np.log1p(X[col].clip(lower=0))# Add binary features X['is_foreign'] = investor_features['is_foreign'] X['is_state'] = investor_features['is_state']# Standardize scaler = StandardScaler() X_scaled = scaler.fit_transform(X)# K-means kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=20) investor_features['cluster'] = kmeans.fit_predict(X_scaled)# Label clusters cluster_profiles = investor_features.groupby('cluster').agg({'avg_n_stocks': 'mean','avg_hhi': 'mean','avg_total_value': 'mean','is_foreign': 'mean','is_state': 'mean','shareholder_name': 'count', }).rename(columns={'shareholder_name': 'n_investors'})print("Investor Clusters:")print(cluster_profiles.round(3).to_string())return investor_features# investor_classes = classify_investors_vietnam(ownership_classified, prices_q)
33.10.3 Nghiên cứu sự kiện: các cú sốc công bố sở hữu
Việc công bố cổ đông lớn dựa trên ngưỡng của Việt Nam tạo ra các sự kiện tự nhiên để nghiên cứu tác động lên giá của những thay đổi sở hữu.
Event Study Framework for Ownership Disclosure Events
def ownership_event_study(major_shareholders: pd.DataFrame, prices: pd.DataFrame, event_window: Tuple[int, int] = (-5, 20), estimation_window: int=120) -> pd.DataFrame:""" Event study of ownership disclosure announcements. Vietnam requires major shareholders (≥5%) to disclose within 7 business days of crossing ownership thresholds. These disclosures can be informationally significant, especially: 1. Foreign fund accumulation (signal of quality) 2. State divestiture (equitization signal) 3. Insider purchases (management confidence signal) Uses market model for expected returns: E[R_i,t] = α_i + β_i × R_m,t Parameters ---------- major_shareholders : pd.DataFrame Disclosure events from DataCore.vn prices : pd.DataFrame Daily stock prices event_window : tuple (pre_event_days, post_event_days) estimation_window : int Days before event window for market model estimation """ events = major_shareholders.copy() events = events.sort_values(['ticker', 'date'])# Identify significant ownership changes events['ownership_change'] = events.groupby( ['ticker', 'shareholder_name'] )['ownership_pct'].diff() significant_events = events[ events['ownership_change'].abs() >0.01# > 1 percentage point ].copy() significant_events['event_type'] = np.where( significant_events['ownership_change'] >0, 'accumulation', 'divestiture' )# Merge with daily prices prices_daily = prices[['ticker', 'date', 'ret']].copy() prices_daily = prices_daily.sort_values(['ticker', 'date'])# VN-Index as market return (ticker code depends on data provider)if'VNINDEX'in prices_daily['ticker'].values: market_ret = prices_daily[prices_daily['ticker'] =='VNINDEX'][['date', 'ret']].copy() market_ret = market_ret.rename(columns={'ret': 'mkt_ret'})else:# Use equal-weighted market return as proxy market_ret = (prices_daily.groupby('date')['ret'] .mean() .reset_index() .rename(columns={'ret': 'mkt_ret'}))# For each event, compute abnormal returns results = [] pre, post = event_windowfor _, event in significant_events.iterrows(): ticker = event['ticker'] event_date = event['date']# Get stock returns around the event stock_ret = prices_daily[prices_daily['ticker'] == ticker].copy() stock_ret = stock_ret.merge(market_ret, on='date', how='left') stock_ret = stock_ret.sort_values('date').reset_index(drop=True)# Find event date index event_idx = stock_ret[stock_ret['date'] >= event_date].indexiflen(event_idx) ==0:continue event_idx = event_idx[0]# Estimation window est_start =max(0, event_idx - estimation_window + pre) est_end = event_idx + pre est_data = stock_ret.iloc[est_start:est_end].dropna(subset=['ret', 'mkt_ret'])iflen(est_data) <30:continue# Market model X = sm.add_constant(est_data['mkt_ret']) y = est_data['ret']try: model = sm.OLS(y, X).fit()exceptException:continue# Event window abnormal returns ew_start = event_idx + pre ew_end =min(event_idx + post +1, len(stock_ret)) event_data = stock_ret.iloc[ew_start:ew_end].copy()iflen(event_data) ==0:continue event_data['expected_ret'] = (model.params['const'] + model.params['mkt_ret'] * event_data['mkt_ret']) event_data['abnormal_ret'] = event_data['ret'] - event_data['expected_ret'] event_data['car'] = event_data['abnormal_ret'].cumsum() event_data['event_day'] =range(pre, pre +len(event_data)) event_data['ticker'] = ticker event_data['event_date'] = event_date event_data['event_type'] = event['event_type'] event_data['ownership_change'] = event['ownership_change'] event_data['shareholder_name'] = event['shareholder_name'] results.append(event_data)if results: all_results = pd.concat(results, ignore_index=True)# Average CARs by event type avg_car = (all_results.groupby(['event_type', 'event_day'])['car'] .agg(['mean', 'std', 'count']) .reset_index()) avg_car['t_stat'] = avg_car['mean'] / (avg_car['std'] / np.sqrt(avg_car['count']))print(f"Event Study Results:")print(f" Total events: {significant_events['event_type'].value_counts().to_string()}")# CAR at event day 0, +5, +10, +20for et in ['accumulation', 'divestiture']:print(f"\n{et.title()} Events:") subset = avg_car[avg_car['event_type'] == et]for day in [0, 5, 10, 20]: row = subset[subset['event_day'] == day]iflen(row) >0:print(f" CAR({day:+d}): {row.iloc[0]['mean']:.4f} "f"(t={row.iloc[0]['t_stat']:.2f})")return all_resultsreturn pd.DataFrame()# event_results = ownership_event_study(dc.major_shareholders, dc.prices)
33.11 Các ứng dụng thực nghiệm
33.11.1 Ứng dụng 1: Sở hữu nước ngoài và lợi nhuận cổ phiếu tại Việt Nam
Liệu sở hữu tổ chức nước ngoài có dự báo được lợi nhuận tại Việt Nam không? Huang, Liu, và Shu (2023) tìm thấy bằng chứng phù hợp với giả thuyết lợi thế thông tin.
Test Foreign Ownership-Return Predictability
def test_foreign_io_returns(metrics: pd.DataFrame) -> pd.DataFrame:""" Test whether changes in foreign institutional ownership predict future stock returns in Vietnam. Methodology: 1. Sort stocks into quintiles by change in foreign IO 2. Compute equal-weighted and VN-Index-adjusted returns 3. Report portfolio returns and long-short spread This adapts the Chen, Hong, and Stein (2002) breadth test specifically for Vietnam's foreign ownership component. """ df = metrics.copy() df = df.sort_values(['ticker', 'quarter_end'])# Change in foreign IO df['delta_foreign'] = df.groupby('ticker')['pct_foreign_total'].diff()# Forward quarterly return df['fwd_ret'] = df.groupby('ticker')['ret'].shift(-1)# Drop missing df = df.dropna(subset=['delta_foreign', 'fwd_ret'])# Quintile portfolios each quarter df['foreign_quintile'] = df.groupby('quarter_end')['delta_foreign'].transform(lambda x: pd.qcut(x, 5, labels=[1, 2, 3, 4, 5], duplicates='drop') )# Portfolio returns port_ret = (df.groupby(['quarter_end', 'foreign_quintile'])['fwd_ret'] .mean() .reset_index()) port_wide = port_ret.pivot(index='quarter_end', columns='foreign_quintile', values='fwd_ret') port_wide['LS'] = port_wide[5] - port_wide[1]# Test significance results = {}for q in [1, 2, 3, 4, 5, 'LS']: data = port_wide[q].dropna() mean_ret = data.mean() t_stat = mean_ret / (data.std() / np.sqrt(len(data))) results[q] = {'Mean Return (%)': mean_ret *100,'t-statistic': t_stat,'N quarters': len(data), } results_df = pd.DataFrame(results).T results_df.index.name ='ΔForeign IO Quintile'print("Foreign Ownership Change and Future Returns (Vietnam)")print("="*60)print(results_df.round(3).to_string())return results_df# foreign_return_results = test_foreign_io_returns(io_metrics)
33.11.2 Ứng dụng 2: Thoái vốn nhà nước và tạo lập giá trị
Analyze Value Impact of State Ownership Reduction
def analyze_equitization_value(metrics: pd.DataFrame, state_analysis: Dict) -> pd.DataFrame:""" Test whether reductions in state ownership are associated with subsequent value creation (higher returns, improved governance). Hypothesis: State divestiture reduces agency costs, improves operational efficiency, and attracts institutional investors, leading to positive abnormal returns. Uses a difference-in-differences approach: Treatment: Firms experiencing >10pp drop in state ownership Control: Matched firms with stable state ownership """ df = metrics.copy() events = state_analysis['equitization_events']iflen(events) ==0:print("No equitization events detected.")return pd.DataFrame()# Get treated firms and their event quarters treated = events[['ticker', 'quarter_end']].drop_duplicates() treated['treated'] =1# Merge with metrics df = df.merge(treated, on=['ticker', 'quarter_end'], how='left') df['treated'] = df['treated'].fillna(0)# Pre/post comparison for treated firms treated_tickers = treated['ticker'].unique() results = []for ticker in treated_tickers: firm = df[df['ticker'] == ticker].sort_values('quarter_end') event_row = firm[firm['treated'] ==1]iflen(event_row) ==0:continue event_q = event_row.iloc[0]['quarter_end']# Pre-event (4 quarters before) pre = firm[firm['quarter_end'] < event_q].tail(4)# Post-event (4 quarters after) post = firm[firm['quarter_end'] > event_q].head(4)iflen(pre) <2orlen(post) <2:continue results.append({'ticker': ticker,'event_quarter': event_q,'state_pct_pre': pre['pct_state'].mean(),'state_pct_post': post['pct_state'].mean(),'foreign_pct_pre': pre['pct_foreign_total'].mean(),'foreign_pct_post': post['pct_foreign_total'].mean(),'n_inst_pre': pre['n_inst_owners'].mean(),'n_inst_post': post['n_inst_owners'].mean(),'ret_pre': pre['ret'].mean(),'ret_post': post['ret'].mean(), })if results: results_df = pd.DataFrame(results)# Paired t-testsprint("Equitization Value Analysis")print("="*60)for metric in ['state_pct', 'foreign_pct', 'n_inst', 'ret']: pre_col =f'{metric}_pre' post_col =f'{metric}_post' diff = results_df[post_col] - results_df[pre_col] t_stat, p_val = stats.ttest_1samp(diff.dropna(), 0)print(f" Δ{metric}: {diff.mean():.4f} (t={t_stat:.2f}, p={p_val:.3f})")return results_dfreturn pd.DataFrame()# equitization_results = analyze_equitization_value(io_metrics, state_analysis)
33.11.3 Ứng dụng 3: Hành vi bầy đàn của tổ chức tại Việt Nam
Compute LSV Herding Measure for Vietnamese Market
def compute_herding_vietnam(trades: pd.DataFrame, owner_types: Optional[List[str]] =None) -> pd.DataFrame:""" Compute the Lakonishok, Shleifer, and Vishny (1992) herding measure adapted for the Vietnamese market. Can be computed separately for: - All institutional investors - Foreign institutions only - Domestic institutions only The herding measure captures whether institutions systematically trade in the same direction beyond what chance would predict. """from scipy.stats import binom t = trades.copy()if owner_types: t = t[t['owner_type'].isin(owner_types)] t['is_buy'] = (t['trade'] >0).astype(int)# For each stock-period stock_trades = t.groupby(['ticker', 'date']).agg( n_traders=('shareholder_name', 'nunique'), n_buyers=('is_buy', 'sum'), ).reset_index()# Minimum traders threshold stock_trades = stock_trades[stock_trades['n_traders'] >=3] stock_trades['p_buy'] = stock_trades['n_buyers'] / stock_trades['n_traders']# Expected proportion per period E_p = stock_trades.groupby('date').apply(lambda g: g['n_buyers'].sum() / g['n_traders'].sum() ).reset_index(name='E_p') stock_trades = stock_trades.merge(E_p, on='date')# Adjustment factordef expected_abs_dev(n, p): k = np.arange(0, n +1) probs = binom.pmf(k, n, p)return np.sum(probs * np.abs(k / n - p)) stock_trades['adj_factor'] = stock_trades.apply(lambda r: expected_abs_dev(int(r['n_traders']), r['E_p']), axis=1 ) stock_trades['hm'] = (np.abs(stock_trades['p_buy'] - stock_trades['E_p']) - stock_trades['adj_factor']) stock_trades['buy_herd'] = np.where( stock_trades['p_buy'] > stock_trades['E_p'], stock_trades['hm'], np.nan ) stock_trades['sell_herd'] = np.where( stock_trades['p_buy'] < stock_trades['E_p'], stock_trades['hm'], np.nan )# Time series of herding ts_herding = stock_trades.groupby('date').agg( mean_hm=('hm', 'mean'), mean_buy_herd=('buy_herd', 'mean'), mean_sell_herd=('sell_herd', 'mean'), pct_herding=('hm', lambda x: (x >0).mean()), n_stocks=('ticker', 'nunique'), ).reset_index()print(f"Herding Analysis ({owner_types or'All Institutions'}):")print(f" Mean HM: {stock_trades['hm'].mean():.4f}")print(f" Mean Buy Herding: {stock_trades['buy_herd'].mean():.4f}")print(f" Mean Sell Herding: {stock_trades['sell_herd'].mean():.4f}")print(f" % stocks with herding: {(stock_trades['hm'] >0).mean():.1%}")return stock_trades, ts_herding# herding_all, herding_ts = compute_herding_vietnam(trades)# herding_foreign, _ = compute_herding_vietnam(# trades, owner_types=[OwnershipType.FOREIGN_INST]# )
33.12 Kết luận và khuyến nghị thực tiễn
33.12.1 Tóm tắt các thước đo
Bảng 33.5 tóm tắt tất cả các thước đo sở hữu tổ chức được phát triển trong chương này cho thị trường Việt Nam.
Bảng 33.5: Tóm tắt tất cả các thước đo sở hữu cho Việt Nam
Thước đo
Định nghĩa
Điều chỉnh chính cho Việt Nam
Hàm Python
Tỷ lệ IO
Cổ phiếu tổ chức / TSO
Phân rã thành nhà nước, nước ngoài, trong nước
compute_ownership_decomposition()
Mức tập trung HHI
\(\sum w_j^2\)
HHI riêng cho tổng, phi nhà nước, nước ngoài
compute_io_metrics_vietnam()
ΔĐộ rộng
Điều chỉnh theo Lehavy-Sloan
Áp dụng cho các khoảng công bố không đều
compute_io_metrics_vietnam()
Mức sử dụng FOL
% nước ngoài / giới hạn FOL
Đặc thù Việt Nam; không có tương đương ở Mỹ
FOLAnalyzer
Phần bù FOL
Tác động lên giá của mức tiệm cận FOL
Cách tiếp cận hồi quy mặt cắt ngang
FOLAnalyzer.estimate_fol_premium()
Giao dịch
ΔShares (đã điều chỉnh sự kiện doanh nghiệp)
Quan trọng: điều chỉnh theo cổ tức bằng cổ phiếu
derive_trades_vectorized_vietnam()
Vòng quay quỹ
min(B,S)/avg(A)
Tần suất bán niên; quy đổi theo năm
compute_fund_analytics()
Tình trạng SOE
Sở hữu nhà nước > 50%
Theo dõi chương trình cổ phần hóa
analyze_state_ownership()
Hành vi bầy đàn LSV
\(|p - E[p]| - E[|p - E[p]|]\)
Tách riêng hành vi bầy đàn của nước ngoài và trong nước
compute_herding_vietnam()
Mạng lưới đồng sở hữu
Các cổ đông tổ chức chung
Làm lộ các mối liên kết tập đoàn
construct_stock_coownership_network()
33.12.2 Danh mục kiểm tra chất lượng dữ liệu cho Việt Nam
Danh mục kiểm tra chất lượng dữ liệu Việt Nam
33.12.3 So sánh với khung của Mỹ
Bảng 33.6: So sánh khung sở hữu tổ chức giữa Mỹ và Việt Nam
Khía cạnh
Mỹ (WRDS/13F)
Việt Nam (DataCore.vn)
Công bố
13F hằng quý (bắt buộc)
Báo cáo thường niên + theo sự kiện
Phạm vi
Tổ chức có AUM > $100 triệu
Tất cả cổ đông trong báo cáo thường niên
Sở hữu quan sát được
Chỉ các vị thế mua
Phân rã đầy đủ
IO có thể vượt 100%
Có (do bán khống)
Không (theo cấu trúc)
Mã định danh cố định
CRSP PERMNO
Mã cổ phiếu (cần theo dõi thủ công các thay đổi)
Hệ số điều chỉnh
CRSP cfacshr
Phải xây dựng từ các sự kiện doanh nghiệp
Phân loại nhà đầu tư
LSEG typecode / Bushee
Nhà nước/Nước ngoài/Trong nước/Cá nhân
Bán khống
Không có trong 13F; tồn tại trên thị trường
Rất hạn chế; không đáng lo ngại
Đặc điểm riêng
—
FOL, sở hữu của SOE, tần suất cổ tức bằng cổ phiếu
Bao Dinh, Ngoc, và Van Nguyen Hong Tran. 2024. “Institutional ownership and stock liquidity: evidence from an emerging market”. SAGE Open 14 (1): 21582440241239116.
Ben-David, ITZHAK, Francesco Franzoni, Augustin Landier, và Rabih Moussawi. 2013. “Do hedge funds manipulate stock prices?”The Journal of Finance 68 (6): 2383–2434.
Cao, Sean, Zhi Da, Daniel Jiang, và Baozhong Yang. 2026. “Do hedge funds strategically misreport their holdings? Evidence from 13F restatements”. Management Science. https://doi.org/10.1287/mnsc.2024.08833.
Chen, Joseph, Harrison Hong, và Jeremy C Stein. 2002. “Breadth of ownership and stock returns”. Journal of financial Economics 66 (2-3): 171–205. https://doi.org/10.1016/S0304-405X(02)00223-4.
Huang, Xiangqian, Clark Liu, và Tao Shu. 2023. “Factors and anomalies in the Vietnamese stock market”. Pacific-Basin Finance Journal 82: 102176.
Lehavy, Reuven, và Richard G Sloan. 2008. “Investor recognition and stock returns”. Review of Accounting Studies 13 (2): 327–61.
Vo, Xuan Vinh. 2015. “Foreign ownership and stock return volatility–Evidence from Vietnam”. Journal of Multinational Financial Management 30: 101–9.
# Phân tích sở hữu tổ chức tại Việt Nam```{python}#| label: setup#| echo: false#| eval: true# ============================================================================# Global Setup and Configuration# ============================================================================import warningswarnings.filterwarnings('ignore')import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport matplotlib.ticker as mtickerimport matplotlib.dates as mdatesimport seaborn as snsfrom datetime import datetime, timedeltafrom dateutil.relativedelta import relativedeltaimport statsmodels.api as smfrom scipy import statsfrom scipy.spatial.distance import pdist, squareformimport itertoolsfrom pathlib import Pathfrom typing import Optional, Union, Dict, List, Tuple# Plotting configuration — academic styleplt.rcParams.update({'figure.figsize': (10, 6),'figure.dpi': 150,'font.family': 'serif','font.serif': ['Times New Roman', 'DejaVu Serif'],'font.size': 11,'axes.titlesize': 13,'axes.labelsize': 11,'xtick.labelsize': 10,'ytick.labelsize': 10,'legend.fontsize': 10,'figure.titlesize': 14,'axes.spines.top': False,'axes.spines.right': False,'axes.grid': True,'grid.alpha': 0.3,'grid.linestyle': '--',})# Color palette for ownership typesOWNER_COLORS = {'State': '#d62728','Foreign Institutional': '#1f77b4','Domestic Institutional': '#2ca02c','Individual': '#ff7f0e','Treasury': '#9467bd','Total Institutional': '#333333',}# Exchange colorsEXCHANGE_COLORS = {'HOSE': '#1f77b4', 'HNX': '#ff7f0e', 'UPCOM': '#2ca02c'}pd.set_option('display.max_columns', 20)pd.set_option('display.width', 120)pd.set_option('display.float_format', '{:.4f}'.format)```## Sở hữu tổ chức tại Việt Nam: một bức tranh khác biệtThị trường cổ phiếu Việt Nam có bức tranh sở hữu tổ chức khác biệt căn bản so với các thị trường phát triển như Mỹ, châu Âu hay Nhật Bản. Kể từ khi Trung tâm Giao dịch Chứng khoán Thành phố Hồ Chí Minh (nay là HOSE) khai trương ngày 28 tháng 7 năm 2000 với chỉ hai cổ phiếu niêm yết, thị trường đã tăng trưởng lên hơn 1.700 công ty niêm yết trên ba sàn giao dịch (HOSE, HNX và UPCOM) với tổng vốn hóa thị trường vượt 200 tỷ USD. Tuy nhiên, cấu trúc sở hữu vẫn mang những nét riêng biệt ở nhiều khía cạnh quan trọng:- **Sự thống trị của nhà đầu tư cá nhân.** Nhà đầu tư cá nhân chiếm khoảng 85% giá trị giao dịch trên các sàn giao dịch Việt Nam, vượt xa tỷ trọng của các tổ chức. Điều này tương phản rõ rệt với Mỹ, nơi các nhà đầu tư tổ chức chi phối cả sở hữu lẫn giao dịch [@bao2024institutional]. Các hệ quả đối với hiệu quả thị trường, quá trình khám phá giá và biến động là rất sâu rộng.- **State ownership legacy.** Vietnam's equitization (privatization) program, initiated under Đổi Mới reforms in 1986, means that the state remains a significant or controlling shareholder in many listed companies. As of 2022, SOEs (firms with state ownership \> 50%) account for approximately 30% of total market capitalization despite representing less than 10% of listed firms [@huang2023factors]. State ownership introduces unique agency problems, governance dynamics, and liquidity constraints.- **Giới hạn sở hữu nước ngoài (FOL).** Việt Nam áp đặt mức trần theo ngành đối với tổng tỷ lệ sở hữu nước ngoài, thường là 49% đối với hầu hết các ngành, 30% đối với ngân hàng, và các mức giới hạn khác nhau đối với hàng không, truyền thông và viễn thông. Khi một cổ phiếu chạm FOL, nhà đầu tư nước ngoài chỉ có thể mua từ những người bán là nhà đầu tư nước ngoài khác, tạo ra một thị trường bị phân khúc với động lực hình thành giá riêng và một "phần bù FOL" đã được ghi nhận rõ [@vo2015foreign].- **Chế độ công bố thông tin.** Khác với hệ thống báo cáo 13F hằng quý của Mỹ, việc công bố thông tin sở hữu tại Việt Nam diễn ra theo sự kiện và theo định kỳ. Cổ đông lớn (≥5%) phải công bố trong vòng 7 ngày làm việc kể từ khi vượt các ngưỡng sở hữu. Báo cáo thường niên chứa danh sách cổ đông chi tiết. Báo cáo quỹ bán niên cung cấp ảnh chụp danh mục. Điều này tạo ra một bức tranh chắp vá về tần suất công bố, đòi hỏi phải xử lý cẩn thận.## Hạ tầng dữ liệu: DataCore.vn {#sec-datacore}**DataCore.vn** là một nền tảng dữ liệu tài chính toàn diện của Việt Nam, cung cấp các bộ dữ liệu đạt chuẩn học thuật cho thị trường Việt Nam. Xuyên suốt chương này, chúng ta giả định toàn bộ dữ liệu được lấy riêng từ DataCore.vn, nền tảng cung cấp:| Bộ dữ liệu DataCore.vn | Nội dung | Biến chính ||:------------------------|:------------------|:---------------------------|| **Giá cổ phiếu** | OHLCV hằng ngày/hằng tháng của HOSE, HNX, UPCOM |`ticker`, `date`, `close`, `adjusted_close`, `volume`, `shares_outstanding`|| **Cơ cấu sở hữu** | Ảnh chụp cơ cấu cổ đông |`ticker`, `date`, `shareholder_name`, `shares_held`, `ownership_pct`, `shareholder_type`|| **Cổ đông lớn** | Chi tiết các cổ đông nắm giữ ≥5% |`ticker`, `date`, `shareholder_name`, `shares_held`, `is_foreign`, `is_state`, `is_institution`|| **Sự kiện doanh nghiệp** | Cổ tức, chia tách cổ phiếu, cổ phiếu thưởng, phát hành quyền mua |`ticker`, `ex_date`, `action_type`, `ratio`, `record_date`|| **Hồ sơ công ty** | Ngành, sàn giao dịch, ngày niêm yết, vốn điều lệ |`ticker`, `exchange`, `industry_code`, `listing_date`, `fol_limit`|| **Báo cáo tài chính** | Báo cáo tài chính quý/năm |`ticker`, `period`, `revenue`, `net_income`, `total_assets`, `equity`|| **Sở hữu nước ngoài** | Theo dõi sở hữu nước ngoài hằng ngày |`ticker`, `date`, `foreign_shares`, `foreign_pct`, `fol_limit`, `foreign_room`|| **Danh mục nắm giữ của quỹ** | Công bố danh mục quỹ bán niên |`fund_name`, `report_date`, `ticker`, `shares_held`, `market_value`|: Các bảng dữ liệu DataCore.vn sử dụng trong chương này {#tbl-datacore-tables}```{python}#| label: datacore-reader#| code-summary: "DataCore.vn Unified Data Reader"#| eval: falseclass DataCoreReader:""" Unified data reader for DataCore.vn datasets. Assumes data has been downloaded from DataCore.vn and stored locally. Supports both Parquet (recommended for performance) and CSV formats. Parameters ---------- data_dir : str or Path Root directory containing DataCore.vn data files file_format : str 'parquet' or 'csv' (default: 'parquet') """# Expected file names in the data directory FILE_MAP = {'prices': 'stock_prices','ownership': 'ownership_structure','major_shareholders': 'major_shareholders','corporate_actions': 'corporate_actions','company_profile': 'company_profile','financials': 'financial_statements','foreign_ownership': 'foreign_ownership_daily','fund_holdings': 'fund_holdings', }def__init__(self, data_dir: Union[str, Path], file_format: str='parquet'):self.data_dir = Path(data_dir)self.fmt = file_formatself._cache = {}# Verify data directory existsifnotself.data_dir.exists():raiseFileNotFoundError(f"Data directory not found: {self.data_dir}\n"f"Please download data from DataCore.vn and place it in this directory." )print(f"DataCore.vn reader initialized: {self.data_dir}") available = [f.stem for f inself.data_dir.glob(f'*.{self.fmt}')]print(f"Available datasets: {available}")def _read(self, key: str) -> pd.DataFrame:"""Read and cache a dataset."""if key inself._cache:returnself._cache[key] fname =self.FILE_MAP.get(key, key) filepath =self.data_dir /f"{fname}.{self.fmt}"ifnot filepath.exists():raiseFileNotFoundError(f"Dataset not found: {filepath}\n"f"Expected file: {fname}.{self.fmt} in {self.data_dir}" )ifself.fmt =='parquet': df = pd.read_parquet(filepath)else: df = pd.read_csv(filepath, parse_dates=True)# Auto-detect and parse date columnsfor col in df.columns:if'date'in col.lower() or col.lower() in ['period', 'ex_date', 'record_date']:try: df[col] = pd.to_datetime(df[col])except (ValueError, TypeError):passself._cache[key] = dfprint(f"Loaded {key}: {len(df):,} rows, {len(df.columns)} columns")return df@propertydef prices(self) -> pd.DataFrame:returnself._read('prices')@propertydef ownership(self) -> pd.DataFrame:returnself._read('ownership')@propertydef major_shareholders(self) -> pd.DataFrame:returnself._read('major_shareholders')@propertydef corporate_actions(self) -> pd.DataFrame:returnself._read('corporate_actions')@propertydef company_profile(self) -> pd.DataFrame:returnself._read('company_profile')@propertydef financials(self) -> pd.DataFrame:returnself._read('financials')@propertydef foreign_ownership(self) -> pd.DataFrame:returnself._read('foreign_ownership')@propertydef fund_holdings(self) -> pd.DataFrame:returnself._read('fund_holdings')def clear_cache(self):"""Clear all cached datasets to free memory."""self._cache.clear()# Initialize reader — adjust path to your local DataCore.vn data# dc = DataCoreReader('/path/to/datacore_data', file_format='parquet')```Chương này được tổ chức như sau. @sec-data-pipeline xây dựng quy trình dữ liệu hoàn chỉnh từ các bản trích xuất thô của DataCore.vn đến các bộ dữ liệu sạch, sẵn sàng phân tích, đặc biệt chú ý đến việc điều chỉnh theo sự kiện doanh nghiệp. @sec-ownership-taxonomy định nghĩa hệ phân loại sở hữu đặc thù của Việt Nam. @sec-ownership-metrics tính tỷ lệ sở hữu tổ chức, mức độ tập trung và độ rộng cho thị trường Việt Nam. @sec-foreign-ownership phát triển các phân tích chuyên biệt về sở hữu nước ngoài, gồm mức sử dụng FOL và phần bù room. @sec-trades suy ra các giao dịch của tổ chức từ các ảnh chụp công bố sở hữu. @sec-flows-turnover tính dòng vốn và tỷ lệ vòng quay ở cấp quỹ. @sec-state-ownership phân tích động lực của sở hữu nhà nước. @sec-modern-extensions giới thiệu phân tích mạng lưới, phân loại bằng học máy và các khung nghiên cứu sự kiện. @sec-empirical-applications trình bày các ứng dụng thực nghiệm hoàn chỉnh, và @sec-conclusion kết luận chương.## Quy trình dữ liệu {#sec-data-pipeline}### Dữ liệu giá cổ phiếu và điều chỉnh theo sự kiện doanh nghiệp {#sec-price-pipeline}Thị trường cổ phiếu Việt Nam nổi tiếng với các sự kiện doanh nghiệp diễn ra thường xuyên, đặc biệt là cổ tức bằng cổ phiếu và phát hành cổ phiếu thưởng, làm thay đổi mạnh số lượng cổ phiếu. Một công ty chi trả cổ tức bằng cổ phiếu 30% nghĩa là cứ 100 cổ phiếu trở thành 130 cổ phiếu, và giá tham chiếu được điều chỉnh giảm tương ứng. Việc không điều chỉnh đúng số cổ phiếu và giá lịch sử cho các sự kiện này là nguồn sai sót phổ biến nhất trong nghiên cứu thị trường cổ phiếu Việt Nam.```{python}#| label: corporate-actions#| code-summary: "Build Cumulative Adjustment Factors from Corporate Actions"#| eval: false# ============================================================================# Step 1: Corporate Action Adjustment Factors# ============================================================================def build_adjustment_factors(corporate_actions: pd.DataFrame) -> pd.DataFrame:""" Build cumulative adjustment factors from the corporate actions history. In Vietnam, the most common share-altering corporate actions are: 1. Stock dividends (cổ tức bằng cổ phiếu): e.g., 30% → ratio = 0.30 Effect: shares × (1 + 0.30), price × (1 / 1.30) 2. Bonus shares (thưởng cổ phiếu): mechanically identical to stock dividends 3. Stock splits (chia tách): e.g., 2:1 → ratio = 2.0 Effect: shares × 2, price × 0.5 4. Rights issues (phát hành thêm): dilutive, but not all shareholders exercise We approximate with the subscription ratio 5. Reverse splits (gộp cổ phiếu): rare in Vietnam Effect: shares ÷ ratio, price × ratio We construct a FORWARD-LOOKING cumulative adjustment factor such that: adjusted_shares = raw_shares × cum_adj_factor(from_date, to_date) adjusted_price = raw_price / cum_adj_factor(from_date, to_date) This is analogous to CRSP's cfacshr in the US context. Parameters ---------- corporate_actions : pd.DataFrame DataCore.vn corporate actions with columns: ticker, ex_date, action_type, ratio action_type values: - 'stock_dividend': ratio = dividend rate (e.g., 0.30 for 30%) - 'bonus_shares': ratio = bonus rate (e.g., 0.20 for 20%) - 'stock_split': ratio = split factor (e.g., 2.0 for 2:1) - 'reverse_split': ratio = merge factor (e.g., 5.0 for 5:1 merge) - 'rights_issue': ratio = subscription rate (e.g., 0.10 for 10:1) - 'cash_dividend': ratio = VND per share (no share adjustment needed) Returns ------- pd.DataFrame Adjustment factors: ticker, ex_date, point_factor, cum_factor """# Filter to share-altering events only share_events = ['stock_dividend', 'bonus_shares', 'stock_split', 'reverse_split', 'rights_issue'] ca = corporate_actions[ corporate_actions['action_type'].isin(share_events) ].copy()iflen(ca) ==0:print("No share-altering corporate actions found.")return pd.DataFrame(columns=['ticker', 'ex_date', 'point_factor', 'cum_factor'])# Compute point adjustment factor for each eventdef compute_point_factor(row): atype = row['action_type'] ratio = row['ratio']if atype in ['stock_dividend', 'bonus_shares']:# 30% stock dividend: 100 shares → 130 sharesreturn1+ ratioelif atype =='stock_split':# 2:1 split: 100 shares → 200 sharesreturn ratioelif atype =='reverse_split':# 5:1 reverse: 500 shares → 100 sharesreturn1.0/ ratioelif atype =='rights_issue':# Approximate: assume all rights exercised# In practice, this overestimates the adjustmentreturn1+ ratioelse:return1.0 ca['point_factor'] = ca.apply(compute_point_factor, axis=1)# Sort chronologically within each ticker ca = ca.sort_values(['ticker', 'ex_date']).reset_index(drop=True)# Cumulative factor: product of all point factors from listing to date# This gives us a running "total adjustment" for each ticker ca['cum_factor'] = ca.groupby('ticker')['point_factor'].cumprod()# Summary statistics n_tickers = ca['ticker'].nunique() n_events =len(ca) avg_events = n_events / n_tickers if n_tickers >0else0print(f"Corporate action adjustment factors built:")print(f" Tickers with adjustments: {n_tickers:,}")print(f" Total share-altering events: {n_events:,}")print(f" Average events per ticker: {avg_events:.1f}")print(f"\nEvent type distribution:")print(ca['action_type'].value_counts().to_string())return ca[['ticker', 'ex_date', 'action_type', 'ratio', 'point_factor', 'cum_factor']]def adjust_shares(shares: float, ticker: str, from_date, to_date, adj_factors: pd.DataFrame) ->float:""" Adjust a share count from one date to another for corporate actions. Example: If a company had a 30% stock dividend with ex_date between from_date and to_date, then 1000 shares at from_date = 1300 shares at to_date. Parameters ---------- shares : float Number of shares at from_date ticker : str Stock ticker from_date, to_date : pd.Timestamp Period for adjustment adj_factors : pd.DataFrame Output of build_adjustment_factors() Returns ------- float Adjusted shares at to_date """ events = adj_factors[ (adj_factors['ticker'] == ticker) & (adj_factors['ex_date'] > pd.Timestamp(from_date)) & (adj_factors['ex_date'] <= pd.Timestamp(to_date)) ]iflen(events) ==0:return shares total_factor = events['point_factor'].prod()return shares * total_factor# Example usage:# adj_factors = build_adjustment_factors(dc.corporate_actions)```::: {.callout-important title="Vấn đề cổ tức bằng cổ phiếu tại Việt Nam"}Các công ty Việt Nam chi trả cổ tức bằng cổ phiếu với tần suất đáng kể, nhiều công ty tăng trưởng làm như vậy 2-3 lần mỗi năm. Hãy xem xét **Vinhomes (VHM)** hoặc **FPT Corporation**: số lượng cổ phiếu của họ có thể tăng gấp đôi hoặc gấp ba trong vòng 5 năm chỉ nhờ cổ tức bằng cổ phiếu. Nếu bạn so sánh số cổ phiếu sở hữu thô từ năm 2019 đến 2024 mà không điều chỉnh, bạn sẽ thu được các tỷ lệ sở hữu vô nghĩa. **Mọi phân tích chuỗi thời gian về dữ liệu sở hữu của Việt Nam đều phải sử dụng số cổ phiếu đã điều chỉnh.** Đây là vấn đề tương đương với hệ số điều chỉnh cfacshr của CRSP trong dữ liệu Mỹ, nhưng nghiêm trọng hơn vì các sự kiện xảy ra thường xuyên hơn và có quy mô lớn hơn.:::```{python}#| label: price-processing#| code-summary: "Process DataCore.vn Price Data with Adjustments"#| eval: false# ============================================================================# Step 2: Process Stock Price Data# ============================================================================def process_price_data(prices: pd.DataFrame, adj_factors: pd.DataFrame, company_profile: pd.DataFrame) -> pd.DataFrame:""" Process DataCore.vn stock price data: 1. Align dates to month-end and quarter-end 2. Merge company metadata (exchange, sector, FOL limit) 3. Compute adjusted prices and shares outstanding 4. Compute market capitalization 5. Create quarter-end snapshots Parameters ---------- prices : pd.DataFrame Daily/monthly price data from DataCore.vn adj_factors : pd.DataFrame Corporate action adjustment factors company_profile : pd.DataFrame Company metadata including exchange, sector, FOL Returns ------- pd.DataFrame Quarter-end processed stock data """ df = prices.copy()# Standardize date df['date'] = pd.to_datetime(df['date']) df['month_end'] = df['date'] + pd.offsets.MonthEnd(0) df['quarter_end'] = df['date'] + pd.offsets.QuarterEnd(0)# Merge company profile profile_cols = ['ticker', 'exchange', 'industry_code', 'fol_limit', 'listing_date', 'company_name'] profile_cols = [c for c in profile_cols if c in company_profile.columns] df = df.merge(company_profile[profile_cols], on='ticker', how='left')# Build cumulative adjustment factor for each ticker-date# For each observation, compute the total adjustment from listing to that date df = df.sort_values(['ticker', 'date'])# Merge adjustment events# For each ticker-date, find the cumulative factor as of that datedef get_cum_factor_at_date(group): ticker = group.name ticker_adj = adj_factors[adj_factors['ticker'] == ticker].copy()iflen(ticker_adj) ==0: group['cum_adj_factor'] =1.0return group# For each date, find cumulative factor (product of all events up to that date) group = group.sort_values('date') group['cum_adj_factor'] =1.0for _, event in ticker_adj.iterrows(): mask = group['date'] >= event['ex_date'] group.loc[mask, 'cum_adj_factor'] *= event['point_factor']return group df = df.groupby('ticker', group_keys=False).apply(get_cum_factor_at_date)# Adjusted price and shares# adjusted_close should already be provided by DataCore.vn# But we compute our own for consistencyif'adjusted_close'notin df.columns: df['adjusted_close'] = df['close'] / df['cum_adj_factor']# Adjusted shares outstanding df['adjusted_shares'] = df['shares_outstanding'] * df['cum_adj_factor']# Market capitalization (in billion VND) df['market_cap'] = df['close'] * df['shares_outstanding'] /1e9# Monthly returns df = df.sort_values(['ticker', 'date']) df['ret'] = df.groupby('ticker')['adjusted_close'].pct_change()# Keep quarter-end observations# For daily data: keep last trading day of each quarter df_quarterly = (df.sort_values(['ticker', 'quarter_end', 'date']) .groupby(['ticker', 'quarter_end']) .last() .reset_index())print(f"Processed price data:")print(f" Total records (daily): {len(df):,}")print(f" Quarter-end records: {len(df_quarterly):,}")print(f" Unique tickers: {df_quarterly['ticker'].nunique():,}")print(f" Date range: {df_quarterly['quarter_end'].min()} to "f"{df_quarterly['quarter_end'].max()}")print(f"\nExchange distribution:")print(df_quarterly.groupby('exchange')['ticker'].nunique().to_string())return df_quarterly# prices_q = process_price_data(dc.prices, adj_factors, dc.company_profile)```### Dữ liệu cơ cấu sở hữu {#sec-ownership-data}Dữ liệu sở hữu của Việt Nam ghi nhận cơ cấu cổ đông như được công bố trong báo cáo thường niên, báo cáo bán niên và các công bố theo sự kiện. Điểm khác biệt chính so với dữ liệu 13F của Mỹ là các công bố của Việt Nam cung cấp **sự phân rã sở hữu đầy đủ**, không chỉ các vị thế mua của tổ chức, mà là toàn bộ cơ cấu gồm sở hữu nhà nước, tổ chức, nước ngoài và cá nhân.```{python}#| label: ownership-processing#| code-summary: "Process and Standardize Ownership Structure Data"#| eval: false# ============================================================================# Step 3: Process Ownership Structure Data# ============================================================================class OwnershipType:""" Vietnam's ownership taxonomy. Unlike the US where 13F captures only institutional long positions, Vietnamese disclosure provides a complete ownership decomposition. We classify shareholders into five mutually exclusive categories. """ STATE ='state'# Nhà nước (government entities, SOE parents) FOREIGN_INST ='foreign_inst'# Tổ chức nước ngoài DOMESTIC_INST ='domestic_inst'# Tổ chức trong nước (non-state) INDIVIDUAL ='individual'# Cá nhân TREASURY ='treasury'# Cổ phiếu quỹ ALL_TYPES = [STATE, FOREIGN_INST, DOMESTIC_INST, INDIVIDUAL, TREASURY] INSTITUTIONAL = [STATE, FOREIGN_INST, DOMESTIC_INST] FOREIGN = [FOREIGN_INST] # Can be expanded if foreign individuals are trackeddef classify_shareholders(ownership: pd.DataFrame) -> pd.DataFrame:""" Classify shareholders into Vietnam's ownership taxonomy. DataCore.vn may provide a `shareholder_type` field, but naming conventions vary. This function standardizes the classification using a combination of provided flags and name-based heuristics. The classification challenge in Vietnam (noted by @huang2023factors): DataCore.vn may not always cleanly separate institution types, so we use a cascading approach: 1. Use explicit flags (is_state, is_foreign, is_institution) if available 2. Apply name-based heuristics for Vietnamese entity names 3. Default to 'individual' for unclassified shareholders Parameters ---------- ownership : pd.DataFrame Raw ownership data from DataCore.vn Returns ------- pd.DataFrame Ownership data with standardized `owner_type` column """ df = ownership.copy()# --- Method 1: Use explicit flags if available ---ifall(col in df.columns for col in ['is_state', 'is_foreign', 'is_institution']): conditions = [ (df['is_state'] ==True), (df['is_foreign'] ==True) & (df['is_institution'] ==True), (df['is_foreign'] ==True) & (df['is_institution'] !=True), (df['is_institution'] ==True) & (df['is_state'] !=True) & (df['is_foreign'] !=True), ] choices = [ OwnershipType.STATE, OwnershipType.FOREIGN_INST, OwnershipType.FOREIGN_INST, # Foreign individuals often grouped OwnershipType.DOMESTIC_INST, ] df['owner_type'] = np.select(conditions, choices, default=OwnershipType.INDIVIDUAL)# --- Method 2: Name-based heuristics ---elif'shareholder_name'in df.columns: name = df['shareholder_name'].str.lower().fillna('')# State entities: government ministries, SCIC, state corporations state_keywords = ['bộ tài chính', 'tổng công ty đầu tư', 'scic', 'ủy ban nhân dân', 'nhà nước', 'state capital','tổng công ty', 'vốn nhà nước', 'bộ công thương','bộ quốc phòng', 'bộ giao thông', 'vinashin', ] is_state = name.apply(lambda x: any(kw in x for kw in state_keywords) )# Foreign entities: common fund names, foreign company patterns foreign_keywords = ['fund', 'investment', 'capital', 'limited', 'ltd', 'inc','corporation', 'holdings', 'asset management', 'pte','gmbh', 'management', 'partners', 'advisors','dragon capital', 'vinacapital', 'templeton', 'blackrock', 'jpmorgan', 'samsung', 'mirae', ]# Also check for non-Vietnamese characters as a heuristic is_foreign_name = name.apply(lambda x: any(kw in x for kw in foreign_keywords) )# Domestic institutions: Vietnamese bank, securities, insurance names domestic_inst_keywords = ['ngân hàng', 'chứng khoán', 'bảo hiểm', 'quỹ đầu tư','công ty quản lý', 'bảo việt', 'techcombank', 'vietcombank','bidv', 'vietinbank', 'vpbank', 'mb bank', 'ssi', 'hsc','vcsc', 'vndirect', 'fpt capital', 'manulife', ] is_domestic_inst = name.apply(lambda x: any(kw in x for kw in domestic_inst_keywords) )# Treasury shares is_treasury = name.str.contains('cổ phiếu quỹ|treasury', case=False)# Apply classification cascade df['owner_type'] = OwnershipType.INDIVIDUAL # Default df.loc[is_domestic_inst, 'owner_type'] = OwnershipType.DOMESTIC_INST df.loc[is_foreign_name, 'owner_type'] = OwnershipType.FOREIGN_INST df.loc[is_state, 'owner_type'] = OwnershipType.STATE df.loc[is_treasury, 'owner_type'] = OwnershipType.TREASURY# --- Method 3: Use shareholder_type directly ---elif'shareholder_type'in df.columns: type_map = {'state': OwnershipType.STATE,'foreign_institution': OwnershipType.FOREIGN_INST,'foreign_individual': OwnershipType.FOREIGN_INST,'domestic_institution': OwnershipType.DOMESTIC_INST,'individual': OwnershipType.INDIVIDUAL,'treasury': OwnershipType.TREASURY, } df['owner_type'] = df['shareholder_type'].str.lower().map(type_map) df['owner_type'] = df['owner_type'].fillna(OwnershipType.INDIVIDUAL)else:raiseValueError("Cannot classify shareholders. Expected one of:\n"" 1. Columns: is_state, is_foreign, is_institution\n"" 2. Column: shareholder_name (for heuristic classification)\n"" 3. Column: shareholder_type (pre-classified)" )# Summaryprint("Ownership classification results:")print(df['owner_type'].value_counts().to_string())return df# ownership_classified = classify_shareholders(dc.ownership)```------------------------------------------------------------------------## Hệ phân loại sở hữu của Việt Nam {#sec-ownership-taxonomy}### Năm nhóm sở hữuCơ cấu sở hữu của Việt Nam được phân rã thành năm nhóm loại trừ lẫn nhau, có tổng bằng 100% số cổ phiếu đang lưu hành:| Category | Vietnamese Term | Description | Typical Share (2020s) ||:-----------------|:-----------------|:-----------------|:------------------|| **State** | Sở hữu Nhà nước | Government entities, SCIC, SOE parent companies | \~15-25% of market cap || **Foreign Institutional** | Tổ chức nước ngoài | Foreign funds, banks, corporations | \~15-20% || **Domestic Institutional** | Tổ chức trong nước | Vietnamese funds, banks, insurance, securities firms | \~5-10% || **Individual** | Cá nhân | Retail investors (both Vietnamese and foreign individuals) | \~55-65% || **Treasury** | Cổ phiếu quỹ | Company's own repurchased shares | \~0-2% |: Hệ phân loại sở hữu của Việt Nam {#tbl-ownership-taxonomy}Hệ phân loại này khác căn bản với khung 13F của Mỹ ở nhiều điểm:1. **Tính đầy đủ:** Chúng ta quan sát được 100% sở hữu, chứ không chỉ các vị thế mua của tổ chức có tài sản quản lý trên \$100 triệu.2. **Nhà nước là một nhóm riêng:** Sở hữu nhà nước là một nhóm phân tích độc lập, không bị gộp vào "Các nhóm khác" như trong hệ mã loại hình của LSEG.3. **Khả năng quan sát cá nhân:** Chúng ta quan sát trực tiếp tổng sở hữu của cá nhân, trong khi ở Mỹ, sở hữu cá nhân chỉ đơn thuần là phần còn lại (100% − sở hữu tổ chức).4. **Không có sự mơ hồ về vị thế bán khống:** Thị trường Việt Nam có hạ tầng bán khống rất hạn chế, nên dữ liệu sở hữu thực sự phản ánh các vị thế mua.```{python}#| label: ownership-decomposition#| code-summary: "Compute Full Ownership Decomposition for Each Stock-Period"#| eval: false# ============================================================================# Step 4: Compute Ownership Decomposition# ============================================================================def compute_ownership_decomposition(ownership: pd.DataFrame, prices_q: pd.DataFrame) -> pd.DataFrame:""" Compute the full ownership decomposition for each stock at each disclosure date. For each stock-date combination, aggregates shares held by each ownership category and computes ownership ratios relative to total shares outstanding. Parameters ---------- ownership : pd.DataFrame Classified ownership data (output of classify_shareholders) prices_q : pd.DataFrame Quarter-end price data with shares_outstanding Returns ------- pd.DataFrame Stock-period level ownership decomposition with columns for each ownership type's share count and percentage """# Aggregate shares by ticker, date, and owner type agg = (ownership.groupby(['ticker', 'date', 'owner_type'])['shares_held'] .sum() .reset_index())# Pivot to wide format: one column per ownership type wide = agg.pivot_table( index=['ticker', 'date'], columns='owner_type', values='shares_held', fill_value=0 ).reset_index()# Rename columns type_cols = [c for c in wide.columns if c in OwnershipType.ALL_TYPES] rename_map = {t: f'shares_{t}'for t in type_cols} wide = wide.rename(columns=rename_map)# Total institutional shares inst_cols = [f'shares_{t}'for t in OwnershipType.INSTITUTIONAL iff'shares_{t}'in wide.columns] wide['shares_institutional'] = wide[inst_cols].sum(axis=1)# Total foreign shares (for FOL tracking) foreign_cols = [f'shares_{t}'for t in OwnershipType.FOREIGN iff'shares_{t}'in wide.columns] wide['shares_foreign_total'] = wide[foreign_cols].sum(axis=1)# Align with quarter-end dates for merging with price data wide['quarter_end'] = wide['date'] + pd.offsets.QuarterEnd(0)# Merge with price data to get shares outstanding merged = wide.merge( prices_q[['ticker', 'quarter_end', 'shares_outstanding', 'adjusted_shares', 'market_cap', 'exchange', 'industry_code', 'fol_limit', 'close']], on=['ticker', 'quarter_end'], how='left' )# Compute ownership ratios tso = merged['shares_outstanding']for col in merged.columns:if col.startswith('shares_') and col !='shares_outstanding': ratio_col = col.replace('shares_', 'pct_') merged[ratio_col] = merged[col] / tso merged.loc[tso <=0, ratio_col] = np.nan# Derived measures merged['pct_free_float'] =1- merged.get('pct_state', 0) - merged.get('pct_treasury', 0)# SOE flag: state ownership > 50% merged['is_soe'] = (merged.get('pct_state', 0) >0.50).astype(int)# FOL utilizationif'fol_limit'in merged.columns and'pct_foreign_total'in merged.columns: merged['fol_utilization'] = merged['pct_foreign_total'] / merged['fol_limit'] merged['foreign_room'] = merged['fol_limit'] - merged['pct_foreign_total'] merged.loc[merged['fol_limit'] <=0, ['fol_utilization', 'foreign_room']] = np.nan# Number of institutional owners (breadth) n_owners = (ownership[ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL)] .groupby(['ticker', 'date'])['shareholder_name'] .nunique() .reset_index() .rename(columns={'shareholder_name': 'n_inst_owners'})) n_foreign_owners = (ownership[ownership['owner_type'] == OwnershipType.FOREIGN_INST] .groupby(['ticker', 'date'])['shareholder_name'] .nunique() .reset_index() .rename(columns={'shareholder_name': 'n_foreign_owners'})) merged = merged.merge(n_owners, on=['ticker', 'date'], how='left') merged = merged.merge(n_foreign_owners, on=['ticker', 'date'], how='left') merged[['n_inst_owners', 'n_foreign_owners']] = ( merged[['n_inst_owners', 'n_foreign_owners']].fillna(0) )print(f"Ownership decomposition computed:")print(f" Stock-period observations: {len(merged):,}")print(f" Unique tickers: {merged['ticker'].nunique():,}")print(f"\nMean ownership structure:") pct_cols = [c for c in merged.columns if c.startswith('pct_')]print(merged[pct_cols].mean().round(4).to_string())return merged# ownership_decomp = compute_ownership_decomposition(# ownership_classified, prices_q# )```------------------------------------------------------------------------## Các thước đo sở hữu tổ chức {#sec-ownership-metrics}### Tỷ lệ sở hữu {#sec-io-ratio}**Tỷ lệ sở hữu tổ chức (IOR)** của cổ phiếu $i$ tại thời điểm $t$ ở Việt Nam là:$$IOR_{i,t} = \frac{S_{i,t}^{state} + S_{i,t}^{foreign\_inst} + S_{i,t}^{domestic\_inst}}{TSO_{i,t}}$$ {#eq-ior-vn}trong đó $S_{i,t}^{type}$ ký hiệu số cổ phiếu đã điều chỉnh do mỗi nhóm sở hữu nắm giữ và $TSO_{i,t}$ là tổng số cổ phiếu đang lưu hành. Khác với Mỹ, nơi IOR có thể vượt 100% do báo cáo chỉ các vị thế mua và do bán khống, IOR của Việt Nam bị chặn theo cấu trúc trong khoảng $[0, 1]$ vì chúng ta quan sát được toàn bộ sự phân rã sở hữu.Chúng ta cũng tính các tỷ lệ sở hữu theo từng nhóm:$$\begin{aligned}IOR_{i,t}^{foreign} &= \frac{S_{i,t}^{foreign\_inst}}{TSO_{i,t}},\\IOR_{i,t}^{state} &= \frac{S_{i,t}^{state}}{TSO_{i,t}},\\IOR_{i,t}^{domestic} &= \frac{S_{i,t}^{domestic\_inst}}{TSO_{i,t}}\end{aligned}$$ {#eq-ior-components}### Mức độ tập trung: chỉ số Herfindahl-Hirschman {#sec-hhi}**Mức độ tập trung sở hữu tổ chức** theo chỉ số Herfindahl-Hirschman là:$$IOC_{i,t}^{HHI} = \sum_{j=1}^{N_{i,t}} \left(\frac{S_{i,j,t}}{\sum_{k=1}^{N_{i,t}} S_{i,k,t}}\right)^2$$ {#eq-hhi-vn}Ở Việt Nam, HHI đặc biệt hữu ích vì nó phản ánh sự chi phối của các cổ đông nhà nước. Một công ty mà Chính phủ nắm 65% sẽ có HHI cao một cách cơ học, ngay cả khi 35% còn lại được nắm giữ phân tán.Do đó, chúng ta tính **các thước đo HHI riêng biệt** cho các nhóm sở hữu khác nhau:$$HHI_{i,t}^{total} = \sum_{j} w_{i,j,t}^2, \quadHHI_{i,t}^{non-state} = \sum_{j \notin state} \left(\frac{S_{i,j,t}}{\sum_{k \notin state} S_{i,k,t}}\right)^2$$ {#eq-hhi-decomposed}HHI phi nhà nước có tính so sánh cao hơn với HHI tổ chức của Mỹ, vì nó phản ánh mức độ tập trung giữa các nhà đầu tư hoạt động theo cơ chế thị trường.### Độ rộng sở hữu {#sec-breadth}Theo @chen2002breadth, **độ rộng tổ chức** ($N_{i,t}$) là số nhà đầu tư tổ chức nắm giữ cổ phiếu $i$ trong kỳ $t$. **Thay đổi độ rộng** là:$$\Delta Breadth_{i,t} = \frac{N_{i,t}^{cont} - N_{i,t-1}^{cont}}{TotalInstitutions_{t-1}}$$ {#eq-dbreadth-vn}trong đó $N_{i,t}^{cont}$ chỉ đếm các tổ chức xuất hiện trong tập hợp công bố ở cả hai kỳ $t$ và $t-1$, theo thuật toán của @lehavy2008investor. Điều chỉnh này đặc biệt quan trọng ở Việt Nam vì:- Các quỹ mới được thành lập thường xuyên (đặc biệt là các ETF bám sát VN30)- Các quỹ nước ngoài gia nhập và rút khỏi thị trường- Các công ty chứng khoán trong nước hợp nhất hoặc tách các mảng quản lý tài sản```{python}#| label: compute-all-metrics#| code-summary: "Compute All Institutional Ownership Metrics for Vietnam"#| eval: false# ============================================================================# Step 5: Compute All IO Metrics# ============================================================================def compute_io_metrics_vietnam(ownership: pd.DataFrame, ownership_decomp: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Compute security-level institutional ownership metrics adapted for Vietnam. Computes: 1. Ownership ratios by category (state, foreign, domestic inst, individual) 2. HHI concentration (total, non-state, foreign-only) 3. Number of institutional owners (total, foreign, domestic) 4. Change in breadth (Lehavy-Sloan adjusted) 5. FOL-related metrics (utilization, room, near-cap indicator) Parameters ---------- ownership : pd.DataFrame Classified ownership data with individual shareholder records ownership_decomp : pd.DataFrame Aggregated ownership decomposition (output of compute_ownership_decomposition) adj_factors : pd.DataFrame Corporate action adjustment factors Returns ------- pd.DataFrame Stock-period level metrics """# Start with the ownership decomposition metrics = ownership_decomp.copy()# --- HHI Concentration ---# Total HHI: across all institutional shareholders inst_ownership = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy()def compute_hhi_group(group):"""Compute HHI for a group of shareholders.""" total = group['shares_held'].sum()if total <=0:return np.nan weights = group['shares_held'] / totalreturn (weights **2).sum()# Total institutional HHI hhi_total = (inst_ownership.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_institutional')) metrics = metrics.merge(hhi_total, on=['ticker', 'date'], how='left')# Non-state HHI (exclude state shareholders) non_state = ownership[ ownership['owner_type'].isin([OwnershipType.FOREIGN_INST, OwnershipType.DOMESTIC_INST]) ] hhi_nonstate = (non_state.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_non_state')) metrics = metrics.merge(hhi_nonstate, on=['ticker', 'date'], how='left')# Foreign-only HHI foreign_only = ownership[ownership['owner_type'] == OwnershipType.FOREIGN_INST] hhi_foreign = (foreign_only.groupby(['ticker', 'date']) .apply(compute_hhi_group) .reset_index(name='hhi_foreign')) metrics = metrics.merge(hhi_foreign, on=['ticker', 'date'], how='left')# --- Change in Breadth (Lehavy-Sloan Algorithm) --- metrics = metrics.sort_values(['ticker', 'date'])# Get list of all institutions filing in each period inst_by_period = (inst_ownership.groupby('date')['shareholder_name'] .apply(set) .to_dict())# For each stock-period: count continuing institutionsdef compute_breadth_change(group): group = group.sort_values('date').reset_index(drop=True) group['dbreadth'] = np.nanfor i inrange(1, len(group)): current_date = group.loc[i, 'date'] prev_date = group.loc[i-1, 'date']# Institutions in universe for both periods current_universe = inst_by_period.get(current_date, set()) prev_universe = inst_by_period.get(prev_date, set()) continuing_universe = current_universe & prev_universeiflen(prev_universe) ==0:continue# Count continuing institutions holding this stock in each period ticker = group.loc[i, 'ticker'] current_holders =set( inst_ownership[ (inst_ownership['ticker'] == ticker) & (inst_ownership['date'] == current_date) ]['shareholder_name'] ) prev_holders =set( inst_ownership[ (inst_ownership['ticker'] == ticker) & (inst_ownership['date'] == prev_date) ]['shareholder_name'] )# Count only continuing institutions n_current_cont =len(current_holders & continuing_universe) n_prev_cont =len(prev_holders & continuing_universe) group.loc[i, 'dbreadth'] = ( (n_current_cont - n_prev_cont) /len(prev_universe) )return group metrics = metrics.groupby('ticker', group_keys=False).apply(compute_breadth_change)# --- FOL Indicators ---if'fol_utilization'in metrics.columns: metrics['near_fol_cap'] = (metrics['fol_utilization'] >0.90).astype(int) metrics['at_fol_cap'] = (metrics['fol_utilization'] >0.98).astype(int)print(f"IO metrics computed for Vietnam:")print(f" Observations: {len(metrics):,}")print(f"\nKey metric distributions:") summary_cols = ['pct_institutional', 'pct_state', 'pct_foreign_total','hhi_institutional', 'n_inst_owners', 'dbreadth'] summary_cols = [c for c in summary_cols if c in metrics.columns]print(metrics[summary_cols].describe().round(4).to_string())return metrics# io_metrics = compute_io_metrics_vietnam(# ownership_classified, ownership_decomp, adj_factors# )```### Trực quan hóa chuỗi thời gian```{python}#| label: fig-io-timeseries-vn#| fig-cap: "Chuỗi thời gian của cơ cấu sở hữu tại các công ty niêm yết Việt Nam. Panel A thể hiện sự thay đổi của cơ cấu sở hữu trên tất cả cổ phiếu HOSE/HNX. Panel B phân rã sở hữu tổ chức thành các thành phần nước ngoài và trong nước. Sự suy giảm của sở hữu nhà nước phản ánh chương trình cổ phần hóa đang diễn ra của Việt Nam, trong khi sự tăng lên của sở hữu tổ chức nước ngoài phản ánh quá trình mở cửa dần của thị trường đối với nhà đầu tư quốc tế."#| fig-height: 12#| eval: falsedef plot_ownership_timeseries_vietnam(metrics: pd.DataFrame):""" Create publication-quality time series plots of Vietnamese ownership structure evolution. """ fig, axes = plt.subplots(3, 1, figsize=(12, 14))# Aggregate across all stocks (market-cap weighted) ts = metrics.groupby('quarter_end').apply(lambda g: pd.Series({'pct_state': np.average(g['pct_state'].fillna(0), weights=g['market_cap'].fillna(1)),'pct_foreign': np.average(g['pct_foreign_total'].fillna(0), weights=g['market_cap'].fillna(1)),'pct_domestic_inst': np.average(g['pct_domestic_inst'].fillna(0), weights=g['market_cap'].fillna(1)),'pct_individual': np.average(g['pct_individual'].fillna(0), weights=g['market_cap'].fillna(1)),'n_stocks': g['ticker'].nunique(),'total_mktcap': g['market_cap'].sum(),'median_n_inst': g['n_inst_owners'].median(),'median_hhi': g['hhi_institutional'].median(),'pct_soe': g['is_soe'].mean(), }) ).reset_index()# ---- Panel A: Ownership Composition (Stacked Area) ---- ax = axes[0] dates = ts['quarter_end'] ax.stackplot(dates, ts['pct_state'] *100, ts['pct_foreign'] *100, ts['pct_domestic_inst'] *100, ts['pct_individual'] *100, labels=['State', 'Foreign Institutional', 'Domestic Institutional', 'Individual'], colors=[OWNER_COLORS['State'], OWNER_COLORS['Foreign Institutional'], OWNER_COLORS['Domestic Institutional'], OWNER_COLORS['Individual']], alpha=0.8) ax.set_ylabel('Ownership Share (%)') ax.set_title('Panel A: Ownership Composition of Vietnamese Listed Companies ''(Market-Cap Weighted)') ax.legend(loc='upper right', frameon=True, framealpha=0.9) ax.set_ylim(0, 100)# ---- Panel B: Institutional Ownership by Component ---- ax = axes[1] ax.plot(dates, ts['pct_state'] *100, label='State', color=OWNER_COLORS['State'], linewidth=2) ax.plot(dates, ts['pct_foreign'] *100, label='Foreign Institutional', color=OWNER_COLORS['Foreign Institutional'], linewidth=2) ax.plot(dates, ts['pct_domestic_inst'] *100, label='Domestic Institutional', color=OWNER_COLORS['Domestic Institutional'], linewidth=2) total_inst = (ts['pct_state'] + ts['pct_foreign'] + ts['pct_domestic_inst']) *100 ax.plot(dates, total_inst, label='Total Institutional', color=OWNER_COLORS['Total Institutional'], linewidth=2.5, linestyle='--') ax.set_ylabel('Ownership Ratio (%)') ax.set_title('Panel B: Institutional Ownership Components') ax.legend(loc='upper left', frameon=True, framealpha=0.9)# ---- Panel C: Market Structure ---- ax = axes[2] ax2 = ax.twinx() ax.plot(dates, ts['n_stocks'], color='#1f77b4', linewidth=2, label='# Listed Stocks') ax2.plot(dates, ts['total_mktcap'] /1000, color='#d62728', linewidth=2, label='Total Market Cap (Trillion VND)') ax.set_ylabel('Number of Listed Stocks', color='#1f77b4') ax2.set_ylabel('Market Cap (Trillion VND)', color='#d62728') ax.set_title('Panel C: Vietnamese Stock Market Development')# Combine legends lines1, labels1 = ax.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax.legend(lines1 + lines2, labels1 + labels2, loc='upper left', framealpha=0.9) plt.tight_layout() plt.savefig('fig_ownership_timeseries_vn.png', dpi=300, bbox_inches='tight') plt.show()# plot_ownership_timeseries_vietnam(io_metrics)``````{python}#| label: fig-io-by-exchange#| fig-cap: "Sở hữu tổ chức trung bình theo sàn giao dịch và nhóm quy mô ngũ phân vị. Các công ty niêm yết trên HOSE thu hút sở hữu tổ chức (đặc biệt là nước ngoài) cao hơn đáng kể so với các công ty trên HNX hoặc UPCOM, phù hợp với sự khác biệt về quy mô, thanh khoản và chất lượng quản trị công ty giữa các sàn."#| eval: falsedef plot_io_by_exchange_size(metrics: pd.DataFrame):"""Plot IO ratios by exchange and size quintile.""" df = metrics[metrics['market_cap'].notna() & (metrics['market_cap'] >0)].copy()# Size quintiles within each quarter df['size_quintile'] = df.groupby('quarter_end')['market_cap'].transform(lambda x: pd.qcut(x, 5, labels=['Q1\n(Small)', 'Q2', 'Q3', 'Q4', 'Q5\n(Large)'], duplicates='drop') ) fig, axes = plt.subplots(1, 3, figsize=(15, 5), sharey=True) metrics_to_plot = [ ('pct_institutional', 'Total Institutional'), ('pct_foreign_total', 'Foreign Institutional'), ('pct_state', 'State'), ]for ax, (col, title) inzip(axes, metrics_to_plot):for exchange, color in EXCHANGE_COLORS.items(): data = df[df['exchange'] == exchange]iflen(data) ==0:continue means = data.groupby('size_quintile')[col].mean() *100 ax.bar(np.arange(len(means)) +list(EXCHANGE_COLORS.keys()).index(exchange) *0.25, means, width=0.25, label=exchange, color=color, alpha=0.8) ax.set_title(title) ax.set_xlabel('Size Quintile')if ax == axes[0]: ax.set_ylabel('Mean Ownership (%)') ax.legend() ax.set_xticks(np.arange(5) +0.25) ax.set_xticklabels(['Q1\n(Small)', 'Q2', 'Q3', 'Q4', 'Q5\n(Large)']) plt.tight_layout() plt.savefig('fig_io_by_exchange_size.png', dpi=300, bbox_inches='tight') plt.show()# plot_io_by_exchange_size(io_metrics)``````{python}#| label: tbl-io-summary#| tbl-cap: "Thống kê mô tả cơ cấu sở hữu tại Việt Nam theo nhóm quy mô ngũ phân vị và sàn giao dịch (gộp 2010-2024)"#| eval: falsedef tabulate_io_summary(metrics: pd.DataFrame, start_year: int=2010) -> pd.DataFrame:""" Create publication-quality summary table of Vietnamese ownership structure by firm size. """ df = metrics[ (metrics['quarter_end'].dt.year >= start_year) & (metrics['market_cap'].notna()) & (metrics['market_cap'] >0) ].copy() df['size_quintile'] = df.groupby('quarter_end')['market_cap'].transform(lambda x: pd.qcut(x, 5, labels=['Q1 (Small)', 'Q2', 'Q3', 'Q4', 'Q5 (Large)'], duplicates='drop') ) table = df.groupby('size_quintile').agg( N=('ticker', 'count'), Mean_MktCap=('market_cap', 'mean'), Mean_IO_Total=('pct_institutional', 'mean'), Mean_State=('pct_state', 'mean'), Mean_Foreign=('pct_foreign_total', 'mean'), Mean_Domestic_Inst=('pct_domestic_inst', 'mean'), Mean_Individual=('pct_individual', 'mean'), Median_N_Owners=('n_inst_owners', 'median'), Median_HHI=('hhi_institutional', 'median'), Pct_SOE=('is_soe', 'mean'), Mean_FOL_Util=('fol_utilization', 'mean'), ).round(4)# Format table['N'] = table['N'].apply(lambda x: f"{x:,.0f}") table['Mean_MktCap'] = table['Mean_MktCap'].apply(lambda x: f"{x:,.0f}B VND")for col in ['Mean_IO_Total', 'Mean_State', 'Mean_Foreign', 'Mean_Domestic_Inst', 'Mean_Individual', 'Pct_SOE', 'Mean_FOL_Util']: table[col] = table[col].apply(lambda x: f"{x:.1%}"if pd.notna(x) else"—") table['Median_N_Owners'] = table['Median_N_Owners'].apply(lambda x: f"{x:.0f}") table['Median_HHI'] = table['Median_HHI'].apply(lambda x: f"{x:.3f}"if pd.notna(x) else"—") table.columns = ['N', 'Mean Mkt Cap', 'IO Total', 'State', 'Foreign', 'Dom. Inst.', 'Individual', 'Med. # Owners', 'Med. HHI', '% SOE', 'FOL Util.']return table# io_summary = tabulate_io_summary(io_metrics)# print(io_summary.to_string())```------------------------------------------------------------------------## Động lực của sở hữu nước ngoài {#sec-foreign-ownership}### Giới hạn sở hữu nước ngoài và phần bù FOL {#sec-fol}Giới hạn sở hữu nước ngoài của Việt Nam tạo ra sự phân khúc thị trường đặc thù. Khi một cổ phiếu chạm FOL, cách duy nhất để một nhà đầu tư nước ngoài mới mua được là một chủ sở hữu nước ngoài hiện hữu bán ra. Điều này tạo ra một thị trường "chỉ dành cho người nước ngoài" trên thực tế đối với các cổ phiếu bị ràng buộc bởi FOL, với các phần bù giá đã được ghi nhận [@vo2015foreign].**Tỷ lệ sử dụng FOL** của cổ phiếu $i$ tại thời điểm $t$ là:$$FOL\_Util_{i,t} = \frac{ForeignOwnership_{i,t}}{FOL\_Limit_i}$$ {#eq-fol-util}Các cổ phiếu được phân loại theo mức độ gần với FOL (@tbl-fol-zones).| Vùng FOL | Khoảng sử dụng | Hàm ý đối với thị trường ||:------------------|:------------------|:-----------------------------------|| **Xanh lục** |\< 50% | Room nước ngoài dồi dào; giao dịch bình thường || **Vàng** | 50-80% | Room vừa phải; có một số áp lực từ sự quan tâm của nước ngoài || **Cam** | 80-95% | Room hạn chế; phần bù nước ngoài bắt đầu xuất hiện || **Đỏ** | 95-100% | Gần chạm trần; phần bù nước ngoài đáng kể || **Kịch trần** | ≈ 100% | Chạm giới hạn; thị trường thứ cấp chỉ dành cho người nước ngoài |: Các vùng tiệm cận FOL {#tbl-fol-zones}```{python}#| label: fol-analysis#| code-summary: "Comprehensive Foreign Ownership Limit Analysis"#| eval: false# ============================================================================# Step 6: Foreign Ownership Limit Analysis# ============================================================================class FOLAnalyzer:""" Analyze Foreign Ownership Limit dynamics in the Vietnamese market. Key analyses: 1. FOL utilization tracking and classification 2. FOL premium estimation (price impact of being near cap) 3. Foreign room dynamics (opening/closing events) 4. Cross-sectional determinants of foreign ownership """ FOL_ZONES = {'Green': (0, 0.50),'Yellow': (0.50, 0.80),'Orange': (0.80, 0.95),'Red': (0.95, 1.00),'Capped': (1.00, 1.50), }def__init__(self, io_metrics: pd.DataFrame, foreign_daily: Optional[pd.DataFrame] =None):""" Parameters ---------- io_metrics : pd.DataFrame Full ownership metrics from compute_io_metrics_vietnam() foreign_daily : pd.DataFrame, optional Daily foreign ownership tracking from DataCore.vn """self.metrics = io_metrics.copy()self.foreign_daily = foreign_dailydef classify_fol_zones(self) -> pd.DataFrame:"""Classify stocks into FOL proximity zones.""" df =self.metrics.copy()if'fol_utilization'notin df.columns:print("FOL utilization not available in metrics.")return df conditions = [] choices = []for zone, (lo, hi) inself.FOL_ZONES.items(): conditions.append( (df['fol_utilization'] >= lo) & (df['fol_utilization'] < hi) ) choices.append(zone) df['fol_zone'] = np.select(conditions, choices, default='Unknown')# Summary zone_dist = df.groupby('fol_zone')['ticker'].nunique()print("FOL Zone Distribution (unique stocks):")print(zone_dist.to_string())return dfdef estimate_fol_premium(self) -> pd.DataFrame:""" Estimate the FOL premium using a cross-sectional approach. For each period, regress stock valuations (P/B or P/E) on FOL utilization, controlling for fundamentals. The coefficient on FOL utilization captures the premium investors pay for stocks near their foreign ownership cap. Alternative: Compare returns of stocks transitioning between FOL zones as a natural experiment. """ df =self.metrics.copy() df = df[df['fol_utilization'].notna() & df['market_cap'].notna()].copy()# FOL zone dummies df['near_cap'] = (df['fol_utilization'] >0.90).astype(int) df['at_cap'] = (df['fol_utilization'] >0.98).astype(int)# Price-to-book as valuation measure# (Assumes 'equity' is available from financial data)if'equity'in df.columns: df['pb_ratio'] = df['market_cap'] *1e9/ df['equity']else:# Use market cap as proxy for cross-sectional analysis df['log_mktcap'] = np.log(df['market_cap'])# Fama-MacBeth style: run cross-sectional regressions each period results = []for quarter, group in df.groupby('quarter_end'): group = group.dropna(subset=['fol_utilization', 'log_mktcap'])iflen(group) <50:continue y = group['log_mktcap'] X = sm.add_constant(group[['fol_utilization', 'pct_state', 'n_inst_owners']])try: model = sm.OLS(y, X).fit() results.append({'quarter': quarter,'beta_fol': model.params.get('fol_utilization', np.nan),'tstat_fol': model.tvalues.get('fol_utilization', np.nan),'r2': model.rsquared,'n': len(group), })exceptException:continueif results: results_df = pd.DataFrame(results)print("FOL Premium (Fama-MacBeth Regression):")print(f" Mean β(FOL_util): {results_df['beta_fol'].mean():.4f}")print(f" t-statistic: {results_df['beta_fol'].mean() /" f"(results_df['beta_fol'].std() / np.sqrt(len(results_df))):.2f}")return results_dfreturn pd.DataFrame()def analyze_foreign_room_events(self) -> pd.DataFrame:""" Analyze events where foreign room opens or closes. Room-opening events (FOL cap raised, foreign seller exits) can trigger significant price movements as pent-up foreign demand is released. Room-closing events (approaching cap) can create selling pressure as foreign investors anticipate illiquidity. """ifself.foreign_daily isNone:print("Daily foreign ownership data required for event analysis.")return pd.DataFrame() df =self.foreign_daily.copy() df = df.sort_values(['ticker', 'date'])# Compute daily change in foreign room df['foreign_room_change'] = df.groupby('ticker')['foreign_room'].diff()# Identify room-opening events (room increases by > 1 percentage point) df['room_open_event'] = (df['foreign_room_change'] >0.01).astype(int)# Identify room-closing events (room decreases to < 2%) df['room_close_event'] = ( (df['foreign_room'] <0.02) & (df.groupby('ticker')['foreign_room'].shift(1) >=0.02) ).astype(int) events = df[ (df['room_open_event'] ==1) | (df['room_close_event'] ==1) ].copy()print(f"Foreign room events identified:")print(f" Room-opening events: {df['room_open_event'].sum():,}")print(f" Room-closing events: {df['room_close_event'].sum():,}")return events# fol_analyzer = FOLAnalyzer(io_metrics, dc.foreign_ownership)# fol_classified = fol_analyzer.classify_fol_zones()# fol_premium = fol_analyzer.estimate_fol_premium()``````{python}#| label: fig-fol-utilization#| fig-cap: "Phân phối mức sử dụng giới hạn sở hữu nước ngoài theo ngành. Các cổ phiếu ngân hàng tập trung gần mức trần 30%, trong khi nhiều cổ phiếu vốn hóa lớn phi ngân hàng tiệm cận giới hạn tiêu chuẩn 49%. Phân phối hai đỉnh ở nhóm 'Others' phản ánh sự pha trộn giữa các cổ phiếu có và không có sự quan tâm đáng kể của nhà đầu tư nước ngoài."#| eval: falsedef plot_fol_utilization(metrics: pd.DataFrame):"""Plot FOL utilization distribution by sector.""" df = metrics[metrics['fol_utilization'].notna()].copy()# Assign broad sectors sector_map = {'Banking': ['VCB', 'BID', 'CTG', 'TCB', 'VPB', 'MBB', 'ACB', 'HDB', 'STB', 'TPB'],'Real Estate': ['VHM', 'VIC', 'NVL', 'KDH', 'DXG', 'HDG', 'VRE'],'Technology': ['FPT', 'CMG', 'FOX'],'Consumer': ['VNM', 'MSN', 'SAB', 'MWG', 'PNJ'], } fig, ax = plt.subplots(figsize=(10, 6))for sector, tickers in sector_map.items(): data = df[df['ticker'].isin(tickers)]['fol_utilization']iflen(data) >0: ax.hist(data *100, bins=30, alpha=0.4, label=sector, density=True) ax.axvline(x=30, color='red', linestyle='--', alpha=0.7, label='Banking FOL (30%)') ax.axvline(x=49, color='blue', linestyle='--', alpha=0.7, label='Standard FOL (49%)') ax.set_xlabel('FOL Utilization (%)') ax.set_ylabel('Density') ax.set_title('Foreign Ownership Limit Utilization Distribution') ax.legend() plt.tight_layout() plt.savefig('fig_fol_utilization.png', dpi=300, bbox_inches='tight') plt.show()# plot_fol_utilization(io_metrics)```## Giao dịch của tổ chức {#sec-trades}### Suy luận giao dịch tại Việt Nam {#sec-trade-inference}Ở Mỹ, các giao dịch của tổ chức được suy ra từ các ảnh chụp danh mục nắm giữ13F hằng quý.Ở Việt Nam, thách thức còn gay gắt hơn vì tần suất công bố không đồng nhất:-**Cổđông lớn (**$\ge$ **5%)**: Phải công bố trong vòng 7 ngày làm việc kể từ khi vượt các ngưỡng sở hữu (5%, 10%, 15%, 20%, 25%, 50%, 65%, 75%)-**Báo cáo danh mục của quỹ:** Bắt buộc công bố bán niên; một số quỹ báo cáo hằng quý-**Báo cáo thường niên:** Cung cấp danh sách cổđông đầy đủ nhưng chỉ một lần mỗi năm-**Sở hữu nước ngoài hằng ngày:** HOSE/HNX công bố dữ liệu tổng hợp mua/bán của nhàđầu tư nước ngoài hằng ngàyChúng ta suy ra các giao dịch từ**thay đổi sở hữu giữa các ngày công bố liên tiếp**,áp dụng cùng logic với thuật toán @ben2013hedge của Mỹ nhưng điều chỉnh cho phù hợp với các khoảng cách công bố không đều của Việt Nam.Cần lưu ý rằng mọi bài tập suy luận giao dịch dựa trên danh mục nắm giữđược công bốđều có một hạn chế: bản thân các công bố có thể mang tính chiến lược. Ngay cả trong chếđộ13F hằng quý bắt buộc của Mỹ,@cao2026hedge ghi nhận rằng các quỹ phòng hộđôi khi khai sai báo cáo gốc và lặng lẽ sửa lại sau đó thông qua các bản điều chỉnh (phổ biến ngang với các báo cáo bảo mật và ảnh hưởng đến số cổ phiếu nhiều gấp khoảng ba lần), với các mô thức lợi nhuận cho thấy việc khai sai che giấu ýđịnh giao dịch trong lúc các vị thếđang được xây dựng. Các công bố sở hữu của Việt Nam (theo sự kiện, ít chuẩn hóa hơn và ít bị giám sát chặt chẽ hơn so với báo cáo 13F) đáng được hoài nghi ít nhất ở mức tương đương: hãy coi các ảnh chụp là những báo cáo có thểđược chọn thời điểm một cách chiến lược hoặc chưa đầy đủ thay vì là sự thật tuyệt đối, vàđối chiếu chéo chúng với các nguồn độc lập như dữ liệu dòng vốn nước ngoài hằng ngày và danh sách cổđông thường niên bất cứ khi nào có thể.```{python}#| label: derive-trades-vn#| code-summary: "Derive Institutional Trades from Vietnamese Ownership Disclosures"#| eval: false# ============================================================================# Step 7: Derive Institutional Trades# ============================================================================def derive_trades_vietnam(ownership: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Derive institutional trades from changes in ownership disclosures. Adapted from Ben-David, Franzoni, and Moussawi (2012) for Vietnam's irregular disclosure frequency. Key differences from US approach: 1. Disclosure intervals are irregular (not always quarterly) 2. We observe ALL institutional types, not just 13F filers 3. No $100M AUM threshold (we see all institutional holders) 4. Must adjust for corporate actions between disclosure dates Trade types: +1: Initiating Buy (new position) +2: Incremental Buy (increased existing position) -1: Terminating Sale (fully exited position) -2: Incremental Sale (reduced existing position) Parameters ---------- ownership : pd.DataFrame Classified ownership with: ticker, date, shareholder_name, shares_held, owner_type adj_factors : pd.DataFrame Corporate action adjustment factors Returns ------- pd.DataFrame Trade-level data: date, shareholder_name, ticker, trade, buysale, owner_type """# Focus on institutional shareholders only inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy() inst = inst.sort_values(['shareholder_name', 'ticker', 'date']).reset_index(drop=True) trades_list = []for (shareholder, ticker), group in inst.groupby(['shareholder_name', 'ticker']): group = group.reset_index(drop=True)for i inrange(len(group)): current = group.iloc[i] current_date = current['date'] current_shares = current['shares_held'] owner_type = current['owner_type']if i ==0:# First observation: if institution appears, it's an initiating buy# (we don't know if they held before our data starts)# Skip the very first observation to avoid false initiating buyscontinue prev = group.iloc[i -1] prev_date = prev['date'] prev_shares = prev['shares_held']# Adjust previous shares for corporate actions between dates prev_shares_adj = adjust_shares( prev_shares, ticker, prev_date, current_date, adj_factors )# Compute trade (in adjusted shares) trade = current_shares - prev_shares_adj# Classify trade typeifabs(trade) <1:# De minimis thresholdcontinueif prev_shares_adj <=0and current_shares >0: buysale =1# Initiating buyelif prev_shares_adj >0and current_shares <=0: buysale =-1# Terminating saleelif trade >0: buysale =2# Incremental buyelse: buysale =-2# Incremental sale trades_list.append({'date': current_date,'shareholder_name': shareholder,'ticker': ticker,'trade': trade,'prev_shares_adj': prev_shares_adj,'current_shares': current_shares,'buysale': buysale,'owner_type': owner_type,'days_between': (current_date - prev_date).days, }) trades = pd.DataFrame(trades_list)iflen(trades) >0:print(f"Trades derived: {len(trades):,}")print(f"\nTrade type distribution:") labels = {1: 'Initiating Buy', 2: 'Incremental Buy',-1: 'Terminating Sale', -2: 'Incremental Sale'}for bs, label insorted(labels.items()): n = (trades['buysale'] == bs).sum()print(f" {label}: {n:,} ({n/len(trades):.1%})")print(f"\nBy owner type:")print(trades.groupby('owner_type')['trade'].agg(['count', 'mean', 'median']) .round(0).to_string())return trades# trades = derive_trades_vietnam(ownership_classified, adj_factors)```::: {.callout-warning title="Điều chỉnh theo sự kiện doanh nghiệp khi suy ra giao dịch"}Khi tính giao dịch dưới dạng $\Delta Shares = Shares_t - Shares_{t-1}$, số cổ phiếu của kỳ trước **phải**được điều chỉnh theo mọi sự kiện doanh nghiệp xảy ra giữa $t-1$ và$t$. Nếu VNM phát hành cổ tức bằng cổ phiếu 20% giữa hai ngày công bố, thì1.000 cổ phiếu tại $t-1$ cần được so sánh với 1.200 cổ phiếu đãđiều chỉnh, chứ không phải 1.000 cổ phiếu thô. Việc không thực hiện điều chỉnh này sẽ tạo ra một giao dịch "mua"ảo 200 cổ phiếu chưa từng thực sự xảy ra.:::```{python}#| label: vectorized-trades-vn#| code-summary: "Vectorized Trade Derivation for Large Datasets"def derive_trades_vectorized_vietnam(ownership: pd.DataFrame, adj_factors: pd.DataFrame) -> pd.DataFrame:""" Vectorized version of Vietnamese trade derivation. Uses pandas groupby and vectorized operations instead of Python loops. Approximately 20-50x faster for large datasets. Note: Corporate action adjustment is applied per-group, which still requires some iteration but is much faster than row-by-row. """ inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) & (ownership['shares_held'] >0) ].copy() inst = inst.sort_values(['shareholder_name', 'ticker', 'date']).reset_index(drop=True)# Lagged values inst['prev_date'] = inst.groupby(['shareholder_name', 'ticker'])['date'].shift(1) inst['prev_shares'] = inst.groupby(['shareholder_name', 'ticker'])['shares_held'].shift(1) inst['is_first'] = inst['prev_date'].isna()# Remove first observations (no prior to compare) inst = inst[~inst['is_first']].copy()# Adjust previous shares for corporate actions# Vectorized: for each row, apply adjustment between prev_date and datedef adjust_row(row):return adjust_shares( row['prev_shares'], row['ticker'], row['prev_date'], row['date'], adj_factors ) inst['prev_shares_adj'] = inst.apply(adjust_row, axis=1)# Compute trade inst['trade'] = inst['shares_held'] - inst['prev_shares_adj'] inst['days_between'] = (inst['date'] - inst['prev_date']).dt.days# Classify trade type inst['buysale'] = np.select( [ (inst['prev_shares_adj'] <=0) & (inst['shares_held'] >0), (inst['prev_shares_adj'] >0) & (inst['shares_held'] <=0), inst['trade'] >0, inst['trade'] <0, ], [1, -1, 2, -2], default=0 )# Remove zero trades trades = inst[inst['buysale'] !=0].copy() trades = trades[['date', 'shareholder_name', 'ticker', 'trade', 'buysale', 'owner_type', 'days_between','prev_shares_adj', 'shares_held']].copy() trades = trades.rename(columns={'shares_held': 'current_shares'})print(f"Vectorized trades: {len(trades):,}")return trades# trades = derive_trades_vectorized_vietnam(ownership_classified, adj_factors)```## Dòng vốn và tỷ lệ vòng quay ở cấp quỹ {#sec-flows-turnover}### Tài sản và lợi nhuận của danh mục từ dữ liệu nắm giữ của quỹSử dụng dữ liệu danh mục nắm giữ của quỹ từ DataCore.vn, chúng ta tính các phân tích danh mục ở cấp quỹ tương tự cách tiếp cận 13F của Mỹ:$$Assets_{j,t} =\sum_{i=1}^{N_{j,t}} S_{i,j,t} \times P_{i,t}$$ {#eq-assets-vn}$$R_{j,t \to t+1}^{holdings} = \frac{\sum_{i} S_{i,j,t} \times P_{i,t} \times R_{i,t \to t+1}}{\sum_{i} S_{i,j,t} \times P_{i,t}}$$ {#eq-hret-vn}$$NetFlows_{j,t} = Assets_{j,t} - Assets_{j,t-1} \times (1+ R_{j,t-1 \to t}^{holdings})$$ {#eq-flows-vn}### Các thước đo tỷ lệ vòng quayTheo @carhart1997persistence, được điều chỉnh cho phù hợp với báo cáo quỹ của Việt Nam:$$Turnover_{j,t}^{Carhart} = \frac{\min(TotalBuys_{j,t}, TotalSales_{j,t})}{\overline{Assets}_{j,t}}$$ {#eq-turnover-vn}```{python}#| label: fund-analytics#| code-summary: "Fund-Level Portfolio Analytics from DataCore.vn Fund Holdings"#| eval: false# ============================================================================# Step 8: Fund-Level Portfolio Analytics# ============================================================================def compute_fund_analytics(fund_holdings: pd.DataFrame, prices_q: pd.DataFrame, adj_factors: pd.DataFrame) -> Dict:""" Compute fund-level portfolio analytics from DataCore.vn fund holdings. Vietnamese fund disclosure is typically semi-annual (some quarterly), which limits the frequency of these analytics compared to the US quarterly approach. Returns ------- dict with keys: 'fund_assets': pd.DataFrame of fund-level assets and returns 'fund_trades': pd.DataFrame of fund-level derived trades 'fund_aggregates': pd.DataFrame of flows and turnover """ fh = fund_holdings.copy() fh = fh[fh['shares_held'] >0].copy()# Merge with prices fh = fh.merge( prices_q[['ticker', 'quarter_end', 'close', 'adjusted_close', 'ret']], left_on=['ticker', 'report_date'], right_on=['ticker', 'quarter_end'], how='inner' )# Portfolio value fh['holding_value'] = fh['shares_held'] * fh['close']# --- Fund-Level Assets --- fund_assets = fh.groupby(['fund_name', 'report_date']).agg( total_assets=('holding_value', lambda x: x.sum() /1e9), # Billion VND n_stocks=('ticker', 'nunique'), ).reset_index()# Holdings return (value-weighted) fh['weight'] = fh.groupby(['fund_name', 'report_date'])['holding_value'].transform(lambda x: x / x.sum() ) fund_hret = (fh.groupby(['fund_name', 'report_date']) .apply(lambda g: np.average(g['ret'].fillna(0), weights=g['weight'])) .reset_index(name='holdings_return')) fund_assets = fund_assets.merge(fund_hret, on=['fund_name', 'report_date'])# --- Fund-Level Trades ---# Derive trades from changes in holdings fh_sorted = fh.sort_values(['fund_name', 'ticker', 'report_date']) fh_sorted['prev_shares'] = fh_sorted.groupby(['fund_name', 'ticker'])['shares_held'].shift(1) fh_sorted['prev_date'] = fh_sorted.groupby(['fund_name', 'ticker'])['report_date'].shift(1)# Adjust for corporate actions fh_sorted['prev_shares_adj'] = fh_sorted.apply(lambda r: adjust_shares(r['prev_shares'], r['ticker'], r['prev_date'], r['report_date'], adj_factors)if pd.notna(r['prev_shares']) else np.nan, axis=1 ) fh_sorted['trade'] = fh_sorted['shares_held'] - fh_sorted['prev_shares_adj'] fh_sorted['trade_value'] = fh_sorted['trade'] * fh_sorted['close'] /1e9# Billion VND# Aggregate buys and sells per fund-period fund_trades = fh_sorted[fh_sorted['trade'].notna()].copy() fund_flows = fund_trades.groupby(['fund_name', 'report_date']).agg( total_buys=('trade_value', lambda x: x[x >0].sum()), total_sales=('trade_value', lambda x: -x[x <0].sum()), ).reset_index()# --- Fund-Level Aggregates --- fund_agg = fund_assets.merge(fund_flows, on=['fund_name', 'report_date'], how='left') fund_agg[['total_buys', 'total_sales']] = fund_agg[['total_buys', 'total_sales']].fillna(0) fund_agg = fund_agg.sort_values(['fund_name', 'report_date']) fund_agg['lag_assets'] = fund_agg.groupby('fund_name')['total_assets'].shift(1) fund_agg['lag_hret'] = fund_agg.groupby('fund_name')['holdings_return'].shift(1)# Net flows fund_agg['net_flows'] = (fund_agg['total_assets'] - fund_agg['lag_assets'] * (1+ fund_agg['holdings_return']))# Turnover (Carhart definition) fund_agg['avg_assets'] = (fund_agg['total_assets'] + fund_agg['lag_assets']) /2 fund_agg['turnover'] = ( fund_agg[['total_buys', 'total_sales']].min(axis=1) / fund_agg['avg_assets'] )# Annualize (approximate, since disclosure may be semi-annual) fund_agg['periods_per_year'] =365/ fund_agg.groupby('fund_name')['report_date'].diff().dt.days fund_agg['turnover_annual'] = fund_agg['turnover'] * fund_agg['periods_per_year'].fillna(2)print(f"Fund analytics computed:")print(f" Unique funds: {fund_agg['fund_name'].nunique():,}")print(f" Fund-period observations: {len(fund_agg):,}")print(f"\nTurnover statistics:")print(fund_agg[['turnover', 'turnover_annual']].describe().round(4))return {'fund_assets': fund_assets,'fund_trades': fund_trades,'fund_aggregates': fund_agg, }# fund_analytics = compute_fund_analytics(dc.fund_holdings, prices_q, adj_factors)```------------------------------------------------------------------------## Phân tích sở hữu nhà nước {#sec-state-ownership}### Cổ phần hóa và sự suy giảm của sở hữu nhà nướcVietnam's equitization (cổ phần hóa) program has been a defining feature of the market since the early 2000s. The program converts state-owned enterprises into joint-stock companies, typically with the state retaining a controlling or significant minority stake that is then gradually reduced through secondary offerings.```{python}#| label: state-ownership-analysis#| code-summary: "Analyze State Ownership Dynamics and Equitization Trends"#| eval: false# ============================================================================# Step 9: State Ownership Analysis# ============================================================================def analyze_state_ownership(metrics: pd.DataFrame) -> Dict:""" Comprehensive analysis of state ownership in Vietnam. Computes: 1. Aggregate state ownership trends 2. SOE population dynamics (entry/exit from SOE classification) 3. Equitization event detection (large drops in state ownership) 4. State ownership by sector and size 5. Governance implications (state as blockholder) """ df = metrics.copy()# --- 1. Aggregate Trends --- ts = df.groupby('quarter_end').agg( n_soe=('is_soe', 'sum'), n_total=('ticker', 'nunique'), pct_soe=('is_soe', 'mean'), mean_state_pct=('pct_state', 'mean'), median_state_pct=('pct_state', 'median'),# Market cap share of SOEs soe_mktcap=('market_cap', lambda x: x[df.loc[x.index, 'is_soe'] ==1].sum()), total_mktcap=('market_cap', 'sum'), ).reset_index() ts['soe_mktcap_share'] = ts['soe_mktcap'] / ts['total_mktcap']# --- 2. Equitization Events ---# Detect large drops in state ownership (>10 percentage points) df_sorted = df.sort_values(['ticker', 'quarter_end']) df_sorted['state_change'] = df_sorted.groupby('ticker')['pct_state'].diff() equitization_events = df_sorted[ df_sorted['state_change'] <-0.10# > 10pp drop ][['ticker', 'quarter_end', 'pct_state', 'state_change', 'market_cap']].copy()# --- 3. By Sector ---if'industry_code'in df.columns: by_sector = df.groupby('industry_code').agg( mean_state=('pct_state', 'mean'), pct_soe=('is_soe', 'mean'), n_firms=('ticker', 'nunique'), ).sort_values('mean_state', ascending=False)else: by_sector =Noneprint(f"State Ownership Analysis:")print(f" Current SOE count: {ts.iloc[-1]['n_soe']:.0f} / {ts.iloc[-1]['n_total']:.0f}")print(f" SOE market cap share: {ts.iloc[-1]['soe_mktcap_share']:.1%}")print(f" Mean state ownership: {ts.iloc[-1]['mean_state_pct']:.1%}")print(f"\nEquitization events detected: {len(equitization_events):,}")return {'trends': ts,'equitization_events': equitization_events,'by_sector': by_sector, }# state_analysis = analyze_state_ownership(io_metrics)``````{python}#| label: fig-state-ownership#| fig-cap: "Diễn biến sở hữu nhà nước tại Việt Nam. Panel A thể hiện tỷ trọng suy giảm của các doanh nghiệp nhà nước (SOE) và trọng số vốn hóa thị trường của họ. Panel B thể hiện phân phối của các tỷ lệ sở hữu nhà nước, minh họa mô thức hai đỉnh giữa các công ty có sở hữu nhà nước không đáng kể và những công ty chịu sự kiểm soát chi phối của nhà nước."#| fig-height: 10#| eval: falsedef plot_state_ownership(state_analysis: Dict, metrics: pd.DataFrame):"""Plot state ownership dynamics.""" fig, axes = plt.subplots(2, 1, figsize=(12, 10)) ts = state_analysis['trends']# Panel A: SOE trends ax = axes[0] ax.plot(ts['quarter_end'], ts['pct_soe'] *100, label='% of Firms that are SOEs', linewidth=2, color='#d62728') ax.plot(ts['quarter_end'], ts['soe_mktcap_share'] *100, label='SOE Market Cap Share (%)', linewidth=2, color='#1f77b4') ax.plot(ts['quarter_end'], ts['mean_state_pct'] *100, label='Mean State Ownership (%)', linewidth=2, color='#2ca02c', linestyle='--') ax.set_ylabel('Percentage') ax.set_title('Panel A: State Ownership and SOE Prevalence Over Time') ax.legend(frameon=True, framealpha=0.9)# Panel B: Distribution ax = axes[1]# Use most recent period latest = metrics[metrics['quarter_end'] == metrics['quarter_end'].max()] state_pct = latest['pct_state'].dropna() *100 ax.hist(state_pct, bins=50, color='#d62728', alpha=0.7, edgecolor='black') ax.axvline(x=50, color='black', linestyle='--', alpha=0.7, label='50% (SOE threshold)') ax.set_xlabel('State Ownership (%)') ax.set_ylabel('Number of Companies') ax.set_title('Panel B: Distribution of State Ownership (Most Recent Quarter)') ax.legend() plt.tight_layout() plt.savefig('fig_state_ownership.png', dpi=300, bbox_inches='tight') plt.show()# plot_state_ownership(state_analysis, io_metrics)```------------------------------------------------------------------------## Các mở rộng hiện đại {#sec-modern-extensions}### Phân tích mạng lưới đồng sở hữu {#sec-network}Các mạng lưới đồng sở hữu của tổ chức cho thấy các cổ phiếu được kết nối với nhau như thế nào thông qua các nhà đầu tư chung. Ở Việt Nam, các mạng lưới này tiết lộ cấu trúc ảnh hưởng của các tập đoàn lớn trong nước (ví dụ: Vingroup, Masan, FPT) và sự chồng lấn giữa các danh mục của quỹ nước ngoài.```{python}#| label: coownership-network#| code-summary: "Construct Co-Ownership Network for Vietnamese Stocks"#| eval: falsedef construct_stock_coownership_network(ownership: pd.DataFrame, period: str, min_overlap: int=3) -> Dict:""" Construct a stock-level co-ownership network. Two stocks are connected if they share institutional investors. Edge weight = number of shared institutional investors. This is particularly informative in Vietnam where: - Foreign fund portfolios concentrate on the same blue-chips - Conglomerate cross-holdings create explicit linkages - State ownership creates implicit connections (SCIC holds multiple stocks) Parameters ---------- ownership : pd.DataFrame Classified ownership data period : str Analysis date min_overlap : int Minimum shared investors to create an edge Returns ------- dict with network statistics and adjacency data """import networkx as nx date = pd.Timestamp(period)# Get institutional holders for this period inst = ownership[ (ownership['date'] == date) & (ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL)) ][['ticker', 'shareholder_name', 'owner_type']].copy()# Create bipartite mapping: institution → set of stocks held inst_to_stocks = inst.groupby('shareholder_name')['ticker'].apply(set).to_dict()# Stock → set of institutions stock_to_inst = inst.groupby('ticker')['shareholder_name'].apply(set).to_dict()# Build stock-level network stocks =list(stock_to_inst.keys()) G = nx.Graph()for i inrange(len(stocks)):for j inrange(i +1, len(stocks)): shared = stock_to_inst[stocks[i]] & stock_to_inst[stocks[j]]iflen(shared) >= min_overlap: G.add_edge(stocks[i], stocks[j], weight=len(shared), shared_investors=list(shared)[:5]) # Store sample# Add node attributesfor stock in stocks:if stock in G.nodes: G.nodes[stock]['n_inst_holders'] =len(stock_to_inst[stock])# Network statistics stats = {'n_nodes': G.number_of_nodes(),'n_edges': G.number_of_edges(),'density': nx.density(G) if G.number_of_nodes() >1else0,'avg_clustering': nx.average_clustering(G, weight='weight') if G.number_of_nodes() >0else0,'n_components': nx.number_connected_components(G), }# Centrality measuresif G.number_of_nodes() >0: degree_cent = nx.degree_centrality(G) stats['most_connected'] =sorted(degree_cent.items(), key=lambda x: x[1], reverse=True)[:10]if G.number_of_nodes() >2:try: eigen_cent = nx.eigenvector_centrality_numpy(G, weight='weight') stats['most_central'] =sorted(eigen_cent.items(), key=lambda x: x[1], reverse=True)[:10]exceptException: stats['most_central'] = []print(f"Co-Ownership Network ({period}):")for k, v in stats.items():if k notin ['most_connected', 'most_central']:print(f" {k}: {v}")if'most_connected'in stats:print(f"\nMost connected stocks:")for stock, cent in stats['most_connected'][:5]:print(f" {stock}: {cent:.3f}")return {'graph': G, 'stats': stats}# network = construct_stock_coownership_network(# ownership_classified, '2024-06-30'# )```### Phân loại nhà đầu tư nâng cao bằng học máy {#sec-ml-classification}Thách thức phân loại nhà đầu tư của Việt Nam khác với Mỹ. Trong khi Mỹ có phân loại Bushee dựa trên tỷ lệ vòng quay và mức độ tập trung của danh mục, Việt Nam đòi hỏi phân loại cả **loại hình** nhà đầu tư (khi không được gắn nhãn rõ ràng) lẫn **hành vi** nhà đầu tư (chủ động so với thụ động, ngắn hạn so với dài hạn).```{python}#| label: ml-classification-vn#| code-summary: "Machine Learning Investor Classification for Vietnam"#| eval: falsedef classify_investors_vietnam(ownership: pd.DataFrame, prices_q: pd.DataFrame, n_clusters: int=4) -> pd.DataFrame:""" ML-based classification of Vietnamese institutional investors. Features adapted for Vietnam's market: 1. Portfolio concentration (HHI of holdings) 2. Holding duration (average time in positions) 3. Size preference (average market cap of holdings) 4. Sector concentration 5. Foreign/domestic indicator 6. Trading frequency (inverse of average days between disclosures) Expected clusters for Vietnam: - Passive State Holders: SOE parents, SCIC - low turnover, concentrated - Active Foreign Funds: Dragon Capital, VinaCapital - moderate turnover - Domestic Securities Firms: SSI, VNDirect - high turnover, diversified - Long-Term Foreign: Pension funds, sovereign wealth - low turnover """from sklearn.cluster import KMeansfrom sklearn.preprocessing import StandardScaler inst = ownership[ ownership['owner_type'].isin(OwnershipType.INSTITUTIONAL) ].copy()# Merge with price data inst = inst.merge( prices_q[['ticker', 'quarter_end', 'close', 'market_cap']], left_on=['ticker', 'date'], right_on=['ticker', 'quarter_end'], how='left' ) inst['holding_value'] = inst['shares_held'] * inst['close'].fillna(0)# Compute features per investor-period features = inst.groupby(['shareholder_name', 'date']).agg( n_stocks=('ticker', 'nunique'), total_value=('holding_value', 'sum'), hhi_portfolio=('holding_value', lambda x: ((x/x.sum())**2).sum() if x.sum() >0else np.nan), avg_mktcap=('market_cap', 'mean'), is_foreign=('owner_type', lambda x: (x == OwnershipType.FOREIGN_INST).any().astype(int)), is_state=('owner_type', lambda x: (x == OwnershipType.STATE).any().astype(int)), ).reset_index()# Average across all periods per investor investor_features = features.groupby('shareholder_name').agg( avg_n_stocks=('n_stocks', 'mean'), avg_hhi=('hhi_portfolio', 'mean'), avg_mktcap=('avg_mktcap', 'mean'), avg_total_value=('total_value', 'mean'), is_foreign=('is_foreign', 'max'), is_state=('is_state', 'max'), n_periods=('date', 'nunique'), ).dropna()# Feature matrix feature_cols = ['avg_n_stocks', 'avg_hhi', 'avg_mktcap', 'avg_total_value'] X = investor_features[feature_cols].copy()# Log-transformfor col in feature_cols: X[col] = np.log1p(X[col].clip(lower=0))# Add binary features X['is_foreign'] = investor_features['is_foreign'] X['is_state'] = investor_features['is_state']# Standardize scaler = StandardScaler() X_scaled = scaler.fit_transform(X)# K-means kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=20) investor_features['cluster'] = kmeans.fit_predict(X_scaled)# Label clusters cluster_profiles = investor_features.groupby('cluster').agg({'avg_n_stocks': 'mean','avg_hhi': 'mean','avg_total_value': 'mean','is_foreign': 'mean','is_state': 'mean','shareholder_name': 'count', }).rename(columns={'shareholder_name': 'n_investors'})print("Investor Clusters:")print(cluster_profiles.round(3).to_string())return investor_features# investor_classes = classify_investors_vietnam(ownership_classified, prices_q)```### Nghiên cứu sự kiện: các cú sốc công bố sở hữu {#sec-event-study}Việc công bố cổ đông lớn dựa trên ngưỡng của Việt Nam tạo ra các sự kiện tự nhiên để nghiên cứu tác động lên giá của những thay đổi sở hữu.```{python}#| label: event-study#| code-summary: "Event Study Framework for Ownership Disclosure Events"#| eval: falsedef ownership_event_study(major_shareholders: pd.DataFrame, prices: pd.DataFrame, event_window: Tuple[int, int] = (-5, 20), estimation_window: int=120) -> pd.DataFrame:""" Event study of ownership disclosure announcements. Vietnam requires major shareholders (≥5%) to disclose within 7 business days of crossing ownership thresholds. These disclosures can be informationally significant, especially: 1. Foreign fund accumulation (signal of quality) 2. State divestiture (equitization signal) 3. Insider purchases (management confidence signal) Uses market model for expected returns: E[R_i,t] = α_i + β_i × R_m,t Parameters ---------- major_shareholders : pd.DataFrame Disclosure events from DataCore.vn prices : pd.DataFrame Daily stock prices event_window : tuple (pre_event_days, post_event_days) estimation_window : int Days before event window for market model estimation """ events = major_shareholders.copy() events = events.sort_values(['ticker', 'date'])# Identify significant ownership changes events['ownership_change'] = events.groupby( ['ticker', 'shareholder_name'] )['ownership_pct'].diff() significant_events = events[ events['ownership_change'].abs() >0.01# > 1 percentage point ].copy() significant_events['event_type'] = np.where( significant_events['ownership_change'] >0, 'accumulation', 'divestiture' )# Merge with daily prices prices_daily = prices[['ticker', 'date', 'ret']].copy() prices_daily = prices_daily.sort_values(['ticker', 'date'])# VN-Index as market return (ticker code depends on data provider)if'VNINDEX'in prices_daily['ticker'].values: market_ret = prices_daily[prices_daily['ticker'] =='VNINDEX'][['date', 'ret']].copy() market_ret = market_ret.rename(columns={'ret': 'mkt_ret'})else:# Use equal-weighted market return as proxy market_ret = (prices_daily.groupby('date')['ret'] .mean() .reset_index() .rename(columns={'ret': 'mkt_ret'}))# For each event, compute abnormal returns results = [] pre, post = event_windowfor _, event in significant_events.iterrows(): ticker = event['ticker'] event_date = event['date']# Get stock returns around the event stock_ret = prices_daily[prices_daily['ticker'] == ticker].copy() stock_ret = stock_ret.merge(market_ret, on='date', how='left') stock_ret = stock_ret.sort_values('date').reset_index(drop=True)# Find event date index event_idx = stock_ret[stock_ret['date'] >= event_date].indexiflen(event_idx) ==0:continue event_idx = event_idx[0]# Estimation window est_start =max(0, event_idx - estimation_window + pre) est_end = event_idx + pre est_data = stock_ret.iloc[est_start:est_end].dropna(subset=['ret', 'mkt_ret'])iflen(est_data) <30:continue# Market model X = sm.add_constant(est_data['mkt_ret']) y = est_data['ret']try: model = sm.OLS(y, X).fit()exceptException:continue# Event window abnormal returns ew_start = event_idx + pre ew_end =min(event_idx + post +1, len(stock_ret)) event_data = stock_ret.iloc[ew_start:ew_end].copy()iflen(event_data) ==0:continue event_data['expected_ret'] = (model.params['const'] + model.params['mkt_ret'] * event_data['mkt_ret']) event_data['abnormal_ret'] = event_data['ret'] - event_data['expected_ret'] event_data['car'] = event_data['abnormal_ret'].cumsum() event_data['event_day'] =range(pre, pre +len(event_data)) event_data['ticker'] = ticker event_data['event_date'] = event_date event_data['event_type'] = event['event_type'] event_data['ownership_change'] = event['ownership_change'] event_data['shareholder_name'] = event['shareholder_name'] results.append(event_data)if results: all_results = pd.concat(results, ignore_index=True)# Average CARs by event type avg_car = (all_results.groupby(['event_type', 'event_day'])['car'] .agg(['mean', 'std', 'count']) .reset_index()) avg_car['t_stat'] = avg_car['mean'] / (avg_car['std'] / np.sqrt(avg_car['count']))print(f"Event Study Results:")print(f" Total events: {significant_events['event_type'].value_counts().to_string()}")# CAR at event day 0, +5, +10, +20for et in ['accumulation', 'divestiture']:print(f"\n{et.title()} Events:") subset = avg_car[avg_car['event_type'] == et]for day in [0, 5, 10, 20]: row = subset[subset['event_day'] == day]iflen(row) >0:print(f" CAR({day:+d}): {row.iloc[0]['mean']:.4f} "f"(t={row.iloc[0]['t_stat']:.2f})")return all_resultsreturn pd.DataFrame()# event_results = ownership_event_study(dc.major_shareholders, dc.prices)```------------------------------------------------------------------------## Các ứng dụng thực nghiệm {#sec-empirical-applications}### Ứng dụng 1: Sở hữu nước ngoài và lợi nhuận cổ phiếu tại Việt NamLiệu sở hữu tổ chức nước ngoài có dự báo được lợi nhuận tại Việt Nam không? @huang2023factors tìm thấy bằng chứng phù hợp với giả thuyết lợi thế thông tin.```{python}#| label: foreign-io-returns#| code-summary: "Test Foreign Ownership-Return Predictability"#| eval: falsedef test_foreign_io_returns(metrics: pd.DataFrame) -> pd.DataFrame:""" Test whether changes in foreign institutional ownership predict future stock returns in Vietnam. Methodology: 1. Sort stocks into quintiles by change in foreign IO 2. Compute equal-weighted and VN-Index-adjusted returns 3. Report portfolio returns and long-short spread This adapts the Chen, Hong, and Stein (2002) breadth test specifically for Vietnam's foreign ownership component. """ df = metrics.copy() df = df.sort_values(['ticker', 'quarter_end'])# Change in foreign IO df['delta_foreign'] = df.groupby('ticker')['pct_foreign_total'].diff()# Forward quarterly return df['fwd_ret'] = df.groupby('ticker')['ret'].shift(-1)# Drop missing df = df.dropna(subset=['delta_foreign', 'fwd_ret'])# Quintile portfolios each quarter df['foreign_quintile'] = df.groupby('quarter_end')['delta_foreign'].transform(lambda x: pd.qcut(x, 5, labels=[1, 2, 3, 4, 5], duplicates='drop') )# Portfolio returns port_ret = (df.groupby(['quarter_end', 'foreign_quintile'])['fwd_ret'] .mean() .reset_index()) port_wide = port_ret.pivot(index='quarter_end', columns='foreign_quintile', values='fwd_ret') port_wide['LS'] = port_wide[5] - port_wide[1]# Test significance results = {}for q in [1, 2, 3, 4, 5, 'LS']: data = port_wide[q].dropna() mean_ret = data.mean() t_stat = mean_ret / (data.std() / np.sqrt(len(data))) results[q] = {'Mean Return (%)': mean_ret *100,'t-statistic': t_stat,'N quarters': len(data), } results_df = pd.DataFrame(results).T results_df.index.name ='ΔForeign IO Quintile'print("Foreign Ownership Change and Future Returns (Vietnam)")print("="*60)print(results_df.round(3).to_string())return results_df# foreign_return_results = test_foreign_io_returns(io_metrics)```### Ứng dụng 2: Thoái vốn nhà nước và tạo lập giá trị```{python}#| label: equitization-value#| code-summary: "Analyze Value Impact of State Ownership Reduction"#| eval: falsedef analyze_equitization_value(metrics: pd.DataFrame, state_analysis: Dict) -> pd.DataFrame:""" Test whether reductions in state ownership are associated with subsequent value creation (higher returns, improved governance). Hypothesis: State divestiture reduces agency costs, improves operational efficiency, and attracts institutional investors, leading to positive abnormal returns. Uses a difference-in-differences approach: Treatment: Firms experiencing >10pp drop in state ownership Control: Matched firms with stable state ownership """ df = metrics.copy() events = state_analysis['equitization_events']iflen(events) ==0:print("No equitization events detected.")return pd.DataFrame()# Get treated firms and their event quarters treated = events[['ticker', 'quarter_end']].drop_duplicates() treated['treated'] =1# Merge with metrics df = df.merge(treated, on=['ticker', 'quarter_end'], how='left') df['treated'] = df['treated'].fillna(0)# Pre/post comparison for treated firms treated_tickers = treated['ticker'].unique() results = []for ticker in treated_tickers: firm = df[df['ticker'] == ticker].sort_values('quarter_end') event_row = firm[firm['treated'] ==1]iflen(event_row) ==0:continue event_q = event_row.iloc[0]['quarter_end']# Pre-event (4 quarters before) pre = firm[firm['quarter_end'] < event_q].tail(4)# Post-event (4 quarters after) post = firm[firm['quarter_end'] > event_q].head(4)iflen(pre) <2orlen(post) <2:continue results.append({'ticker': ticker,'event_quarter': event_q,'state_pct_pre': pre['pct_state'].mean(),'state_pct_post': post['pct_state'].mean(),'foreign_pct_pre': pre['pct_foreign_total'].mean(),'foreign_pct_post': post['pct_foreign_total'].mean(),'n_inst_pre': pre['n_inst_owners'].mean(),'n_inst_post': post['n_inst_owners'].mean(),'ret_pre': pre['ret'].mean(),'ret_post': post['ret'].mean(), })if results: results_df = pd.DataFrame(results)# Paired t-testsprint("Equitization Value Analysis")print("="*60)for metric in ['state_pct', 'foreign_pct', 'n_inst', 'ret']: pre_col =f'{metric}_pre' post_col =f'{metric}_post' diff = results_df[post_col] - results_df[pre_col] t_stat, p_val = stats.ttest_1samp(diff.dropna(), 0)print(f" Δ{metric}: {diff.mean():.4f} (t={t_stat:.2f}, p={p_val:.3f})")return results_dfreturn pd.DataFrame()# equitization_results = analyze_equitization_value(io_metrics, state_analysis)```### Ứng dụng 3: Hành vi bầy đàn của tổ chức tại Việt Nam```{python}#| label: herding-vn#| code-summary: "Compute LSV Herding Measure for Vietnamese Market"#| eval: falsedef compute_herding_vietnam(trades: pd.DataFrame, owner_types: Optional[List[str]] =None) -> pd.DataFrame:""" Compute the Lakonishok, Shleifer, and Vishny (1992) herding measure adapted for the Vietnamese market. Can be computed separately for: - All institutional investors - Foreign institutions only - Domestic institutions only The herding measure captures whether institutions systematically trade in the same direction beyond what chance would predict. """from scipy.stats import binom t = trades.copy()if owner_types: t = t[t['owner_type'].isin(owner_types)] t['is_buy'] = (t['trade'] >0).astype(int)# For each stock-period stock_trades = t.groupby(['ticker', 'date']).agg( n_traders=('shareholder_name', 'nunique'), n_buyers=('is_buy', 'sum'), ).reset_index()# Minimum traders threshold stock_trades = stock_trades[stock_trades['n_traders'] >=3] stock_trades['p_buy'] = stock_trades['n_buyers'] / stock_trades['n_traders']# Expected proportion per period E_p = stock_trades.groupby('date').apply(lambda g: g['n_buyers'].sum() / g['n_traders'].sum() ).reset_index(name='E_p') stock_trades = stock_trades.merge(E_p, on='date')# Adjustment factordef expected_abs_dev(n, p): k = np.arange(0, n +1) probs = binom.pmf(k, n, p)return np.sum(probs * np.abs(k / n - p)) stock_trades['adj_factor'] = stock_trades.apply(lambda r: expected_abs_dev(int(r['n_traders']), r['E_p']), axis=1 ) stock_trades['hm'] = (np.abs(stock_trades['p_buy'] - stock_trades['E_p']) - stock_trades['adj_factor']) stock_trades['buy_herd'] = np.where( stock_trades['p_buy'] > stock_trades['E_p'], stock_trades['hm'], np.nan ) stock_trades['sell_herd'] = np.where( stock_trades['p_buy'] < stock_trades['E_p'], stock_trades['hm'], np.nan )# Time series of herding ts_herding = stock_trades.groupby('date').agg( mean_hm=('hm', 'mean'), mean_buy_herd=('buy_herd', 'mean'), mean_sell_herd=('sell_herd', 'mean'), pct_herding=('hm', lambda x: (x >0).mean()), n_stocks=('ticker', 'nunique'), ).reset_index()print(f"Herding Analysis ({owner_types or'All Institutions'}):")print(f" Mean HM: {stock_trades['hm'].mean():.4f}")print(f" Mean Buy Herding: {stock_trades['buy_herd'].mean():.4f}")print(f" Mean Sell Herding: {stock_trades['sell_herd'].mean():.4f}")print(f" % stocks with herding: {(stock_trades['hm'] >0).mean():.1%}")return stock_trades, ts_herding# herding_all, herding_ts = compute_herding_vietnam(trades)# herding_foreign, _ = compute_herding_vietnam(# trades, owner_types=[OwnershipType.FOREIGN_INST]# )```## Kết luận và khuyến nghị thực tiễn {#sec-conclusion}### Tóm tắt các thước đo@tbl-summary-all tóm tắt tất cả các thước đo sở hữu tổ chức được phát triển trong chương này cho thị trường Việt Nam.| Thước đo | Định nghĩa | Điều chỉnh chính cho Việt Nam | Hàm Python ||:-----------------|:-----------------|:------------------|:-----------------|| Tỷ lệ IO | Cổ phiếu tổ chức / TSO | Phân rã thành nhà nước, nước ngoài, trong nước | `compute_ownership_decomposition()` || Mức tập trung HHI | $\sum w_j^2$ | HHI riêng cho tổng, phi nhà nước, nước ngoài | `compute_io_metrics_vietnam()` || ΔĐộ rộng | Điều chỉnh theo Lehavy-Sloan | Áp dụng cho các khoảng công bố không đều | `compute_io_metrics_vietnam()` || Mức sử dụng FOL |% nước ngoài / giới hạn FOL | Đặc thù Việt Nam; không có tương đương ở Mỹ | `FOLAnalyzer` || Phần bù FOL | Tác động lên giá của mức tiệm cận FOL | Cách tiếp cận hồi quy mặt cắt ngang | `FOLAnalyzer.estimate_fol_premium()` || Giao dịch | ΔShares (đã điều chỉnh sự kiện doanh nghiệp) | Quan trọng: điều chỉnh theo cổ tức bằng cổ phiếu | `derive_trades_vectorized_vietnam()` || Vòng quay quỹ |min(B,S)/avg(A) | Tần suất bán niên; quy đổi theo năm | `compute_fund_analytics()` || Tình trạng SOE | Sở hữu nhà nước \>50%| Theo dõi chương trình cổ phần hóa | `analyze_state_ownership()` || Hành vi bầy đàn LSV | $|p - E[p]|- E[|p - E[p]|]$ | Tách riêng hành vi bầy đàn của nước ngoài và trong nước | `compute_herding_vietnam()` || Mạng lưới đồng sở hữu | Các cổ đông tổ chức chung | Làm lộ các mối liên kết tập đoàn | `construct_stock_coownership_network()` |: Tóm tắt tất cả các thước đo sở hữu cho Việt Nam {#tbl-summary-all}### Danh mục kiểm tra chất lượng dữ liệu cho Việt Nam::: {.callout-tip title="Danh mục kiểm tra chất lượng dữ liệu Việt Nam"}1. [ ] **Sự kiện doanh nghiệp:** Bạn đã xây dựng và áp dụng hệ số điều chỉnh cho TẤT CẢ cổ tức bằng cổ phiếu, cổ phiếu thưởng, chia tách và phát hành quyền mua chưa?2. [ ] **Phân loại cổ đông:** Bạn đã kiểm tra việc phân loại loại hình chủ sở hữu (nhà nước, nước ngoài, tổ chức trong nước, cá nhân) chưa?3. [ ] **Giới hạn FOL:** Các giới hạn FOL theo ngành có được gán đúng không (30% đối với ngân hàng, 49% tiêu chuẩn, không giới hạn đối với một số ngành)?4. [ ] **Ngày công bố:** Bạn có đang sử dụng ngày công bố thực tế (không phải ngày chốt danh sách hay ngày giao dịch không hưởng quyền) cho các ảnh chụp sở hữu không?5. [ ] **Cổ phiếu quỹ:** Cổ phiếu quỹ có được loại khỏi mẫu số của tỷ lệ sở hữu không?6. [ ] **Phạm vi UPCOM:** Mẫu của bạn bao gồm hay loại trừ các cổ phiếu UPCOM (vốn có yêu cầu công bố thông tin yếu hơn)?7. [ ] **Niêm yết chéo:** Bạn có xử lý NVDR (Chứng chỉ lưu ký không có quyền biểu quyết) nếu áp dụng sau các cải cách thị trường không?8. [ ] **Tính nhất quán của tên:** Tên cổ đông có được chuẩn hóa giữa các kỳ công bố không (tên tiếng Việt có thể có nhiều dạng La-tinh hóa)?9. [ ] **Điều chỉnh giao dịch:** Khi suy ra giao dịch giữa các kỳ, bạn đã điều chỉnh số cổ phiếu kỳ trước theo TẤT CẢ sự kiện doanh nghiệp xen giữa chưa?10. [ ] **Thay đổi chính sách đầu tư của quỹ:** Đối với phân tích quỹ, bạn đã tính đến việc sáp nhập, đóng cửa quỹ và thay đổi chính sách đầu tư ảnh hưởng đến tính liên tục của chuỗi thời gian chưa?:::### So sánh với khung của Mỹ| Khía cạnh | Mỹ (WRDS/13F) | Việt Nam (DataCore.vn) ||:------------------|:--------------------|:-------------------------------||**Công bố**|13F hằng quý (bắt buộc) | Báo cáo thường niên + theo sự kiện ||**Phạm vi**| Tổ chức có AUM \> \$100 triệu | Tất cả cổ đông trong báo cáo thường niên ||**Sở hữu quan sát được**| Chỉ các vị thế mua | Phân rã đầy đủ ||**IO có thể vượt 100%**| Có (do bán khống) | Không (theo cấu trúc) ||**Mã định danh cố định**| CRSP PERMNO | Mã cổ phiếu (cần theo dõi thủ công các thay đổi) ||**Hệ số điều chỉnh**| CRSP cfacshr | Phải xây dựng từ các sự kiện doanh nghiệp ||**Phân loại nhà đầu tư**| LSEG typecode / Bushee | Nhà nước/Nước ngoài/Trong nước/Cá nhân ||**Bán khống**| Không có trong 13F; tồn tại trên thị trường | Rất hạn chế; không đáng lo ngại ||**Đặc điểm riêng**| — | FOL, sở hữu của SOE, tần suất cổ tức bằng cổ phiếu |: So sánh khung sở hữu tổ chức giữa Mỹ và Việt Nam {#tbl-us-vn-comparison}