34.1 Institutional Ownership in Vietnam: A Distinct Landscape
Vietnam’s equity market presents a fundamentally different institutional ownership landscape from the mature markets of the US, Europe, or Japan. Since the Ho Chi Minh City Securities Trading Center (now HOSE) opened on July 28, 2000 with just two listed stocks, the market has grown to over 1,700 listed companies across three exchanges (HOSE, HNX, and UPCOM) with a combined market capitalization exceeding 200 billion USD. Yet the ownership structure remains distinctive in several critical ways:
Retail dominance. Individual investors account for approximately 85% of trading value on Vietnamese exchanges, far exceeding the institutional share. This contrasts sharply with the US, where institutional investors dominate both ownership and trading (Bao Dinh and Tran 2024). The implications for market efficiency, price discovery, and volatility are profound.
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, and Shu 2023). State ownership introduces unique agency problems, governance dynamics, and liquidity constraints.
Foreign Ownership Limits (FOLs). Vietnam imposes sector-specific caps on aggregate foreign ownership, typically 49% for most sectors, 30% for banking, and varying limits for aviation, media, and telecommunications. When a stock reaches its FOL, foreign investors can only buy from other foreign sellers, creating a segmented market with distinct pricing dynamics and a well-documented “FOL premium” (Vo 2015).
Disclosure regime. Unlike the US quarterly 13F filing system, Vietnam’s ownership disclosure is event-driven and periodic. Major shareholders (≥5%) must disclose within 7 business days of crossing thresholds. Annual reports contain detailed shareholder registers. Semi-annual fund reports provide portfolio snapshots. This creates a patchwork of disclosure frequencies that require careful handling.
34.2 Data Infrastructure: DataCore.vn
DataCore.vn is a comprehensive Vietnamese financial data platform that provides academic-grade datasets for the Vietnamese market. Throughout this chapter, we assume all data is sourced exclusively from DataCore.vn, which provides:
Table 34.1: DataCore.vn Data Tables Used in This Chapter
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')
This chapter proceeds as follows. Section 34.3 builds the complete data pipeline from raw DataCore.vn extracts to clean, analysis-ready datasets, with particular attention to corporate action adjustments. Section 34.4 defines Vietnam’s unique ownership taxonomy. Section 34.5 computes institutional ownership ratios, concentration, and breadth for the Vietnamese market. Section 34.6 develops specialized foreign ownership analytics including FOL utilization and room premium. Section 34.7 derives institutional trades from ownership disclosure snapshots. Section 34.8 computes fund-level flows and turnover. Section 34.9 analyzes state ownership dynamics. Section 34.10 introduces network analysis, ML classification, and event-study frameworks. Section 34.11 presents complete empirical applications, and Section 34.12 concludes.
34.3 Data Pipeline
34.3.1 Stock Price Data and Corporate Action Adjustments
Vietnam’s equity market is notorious for frequent corporate actions, particularly stock dividends and bonus share issuances, that dramatically alter share counts. A company issuing a 30% stock dividend means every 100 shares become 130 shares, and the reference price adjusts downward proportionally. Failure to properly adjust historical shares and prices for these events is the single most common source of error in Vietnamese equity research.
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)
The Stock Dividend Problem in Vietnam
Vietnamese companies issue stock dividends with remarkable frequency, many growth companies do so 2-3 times per year. Consider Vinhomes (VHM) or FPT Corporation: their share counts may double or triple over a 5-year period purely from stock dividends. If you compare raw ownership shares from 2019 to 2024 without adjustment, you will obtain nonsensical ownership ratios. Every time-series analysis of Vietnamese ownership data must use adjusted shares. This is the Vietnamese equivalent of the CRSP cfacshr adjustment factor problem in US data, but more severe because the events are more frequent and larger in magnitude.
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)
34.3.2 Ownership Structure Data
Vietnamese ownership data captures the composition of shareholders as disclosed in annual reports, semi-annual reports, and event-driven disclosures. The key distinction from US 13F data is that Vietnamese disclosures provide a complete ownership decomposition, not just institutional long positions, but the full breakdown into state, institutional, foreign, and individual ownership.
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)
34.4 Vietnam’s Ownership Taxonomy
34.4.1 The Five Ownership Categories
Vietnam’s ownership structure is decomposed into five mutually exclusive categories that together sum to 100% of shares outstanding:
Retail investors (both Vietnamese and foreign individuals)
~55-65%
Treasury
Cổ phiếu quỹ
Company’s own repurchased shares
~0-2%
This taxonomy differs fundamentally from the US 13F framework in several ways:
Completeness: We observe 100% of ownership, not just institutional long positions above $100 million AUM.
State as a category: State ownership is a first-class analytical category, not subsumed under “All Others” as in the LSEG type code system.
Individual visibility: We observe aggregate individual ownership directly, whereas in the US, individual ownership is merely the residual (100% − institutional ownership).
No short position ambiguity: Vietnam’s market has very limited short-selling infrastructure, so ownership data genuinely represents long positions.
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# )
34.5 Institutional Ownership Measures
34.5.1 Ownership Ratio
The Institutional Ownership Ratio (IOR) for stock \(i\) at time \(t\) in Vietnam is:
where \(S_{i,t}^{type}\) denotes adjusted shares held by each ownership category and \(TSO_{i,t}\) is total shares outstanding. Unlike the US where the IOR can exceed 100% due to long-only reporting and short selling, the Vietnamese IOR is bounded by construction in \([0, 1]\) because we observe the complete ownership decomposition.
We also compute category-specific ownership ratios:
In Vietnam, the HHI is particularly informative because it captures the dominance of state shareholders. A company where the government holds 65% will have a mechanically high HHI even if the remaining 35% is diversely held.
We therefore compute separate HHI measures for different ownership categories:
The non-state HHI is more comparable to the US institutional HHI, as it captures concentration among market-driven investors.
34.5.3 Breadth of Ownership
Following Chen, Hong, and Stein (2002), Institutional Breadth (\(N_{i,t}\)) is the number of institutional investors holding stock \(i\) in period \(t\). The Change in Breadth is:
where \(N_{i,t}^{cont}\) counts only institutions that appear in the disclosure universe in both periods \(t\) and \(t-1\), following the Lehavy and Sloan (2008) algorithm. This adjustment is particularly important in Vietnam where:
New funds launch frequently (especially ETFs tracking VN30)
Foreign funds enter and exit the market
Domestic securities firms consolidate or spin off asset management divisions
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# )
34.6.1 Foreign Ownership Limits and the FOL Premium
Vietnam’s Foreign Ownership Limits create a unique market segmentation. When a stock reaches its FOL, the only way for a new foreign investor to buy is if an existing foreign holder sells. This creates a de facto “foreign-only” market for FOL-constrained stocks, with documented price premiums (Vo 2015).
The FOL Utilization Ratio for stock \(i\) at time \(t\) is:
In the US, institutional trades are inferred from quarterly 13F holding snapshots. In Vietnam, the challenge is more acute because disclosure frequency varies:
Major shareholders (\(\ge\)5%): Must disclose within 7 business days of crossing ownership thresholds (5%, 10%, 15%, 20%, 25%, 50%, 65%, 75%)
Fund portfolio reports: Semi-annual disclosure required; some funds report quarterly
Annual reports: Provide complete shareholder register but only once per year
Daily foreign ownership: HOSE/HNX publish aggregate daily foreign buy/sell data
We derive trades from the change in ownership between consecutive disclosure dates, applying the same logic as the US Ben-David et al. (2013) algorithm but adapted for Vietnam’s irregular disclosure intervals.
A caveat applies to any trade-inference exercise built on disclosed holdings: the disclosures themselves may be strategic. Even under the US’s mandatory quarterly 13F regime, Cao et al. (2026) document that hedge funds sometimes misreport their original filings and quietly correct them later through restatements (which are as common as confidential filings and affect roughly three times as many stocks), with return patterns suggesting that the misreporting hides trading intentions while positions are being built. Vietnamese ownership disclosures (event-driven, less standardized, and less intensively policed than 13F filings) warrant at least as much skepticism: treat snapshots as reports that may be strategically timed or incomplete rather than ground truth, and cross-validate them against independent sources such as daily foreign flow data and annual shareholder registers wherever possible.
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)
Corporate Action Adjustment in Trade Derivation
When computing trades as \(\Delta Shares = Shares_t - Shares_{t-1}\), the previous period’s shares must be adjusted for any corporate actions between \(t-1\) and \(t\). If VNM issued a 20% stock dividend between the two disclosure dates, then 1,000 shares at \(t-1\) should be compared to 1,200 adjusted shares, not 1,000 raw shares. Failing to make this adjustment would create a phantom “buy” of 200 shares that never actually occurred.
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)
34.8 Fund-Level Flows and Turnover
34.8.1 Portfolio Assets and Returns from Fund Holdings
Using DataCore.vn’s fund holdings data, we compute fund-level portfolio analytics analogous to the US 13F approach:
34.9.1 Equitization and the Decline of State Ownership
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)
Show code
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)
Figure 34.4
34.10 Modern Extensions
34.10.1 Network Analysis of Co-Ownership
Institutional co-ownership networks capture how stocks are connected through shared investors. In Vietnam, these networks reveal the influence structure of major domestic conglomerates (e.g., Vingroup, Masan, FPT) and the overlap between foreign fund portfolios.
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'# )
34.10.2 ML-Enhanced Investor Classification
Vietnam’s investor classification challenge is distinct from the US. While the US has the Bushee typology based on portfolio turnover and concentration, Vietnam requires classification of both investor type (when not explicitly labeled) and investor behavior (active vs passive, short-term vs long-term).
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)
34.10.3 Event Study: Ownership Disclosure Shocks
Vietnam’s threshold-based major shareholder disclosure creates natural events for studying the price impact of ownership changes.
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)
34.11 Empirical Applications
34.11.1 Application 1: Foreign Ownership and Stock Returns in Vietnam
Does foreign institutional ownership predict returns in Vietnam? Huang, Liu, and Shu (2023) find evidence consistent with the information advantage hypothesis.
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)
34.11.2 Application 2: State Divestiture and Value Creation
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)
34.11.3 Application 3: Institutional Herding in Vietnam
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]# )
34.12 Conclusion and Practical Recommendations
34.12.1 Summary of Measures
Table 34.5 summarizes all institutional ownership measures developed in this chapter for the Vietnamese market.
Table 34.5: Summary of All Ownership Measures for Vietnam
Measure
Definition
Key Adaptation for Vietnam
Python Function
IO Ratio
Inst. shares / TSO
Decomposed into state, foreign, domestic
compute_ownership_decomposition()
HHI Concentration
\(\sum w_j^2\)
Separate HHI for total, non-state, foreign
compute_io_metrics_vietnam()
ΔBreadth
Lehavy-Sloan adjusted
Applied to irregular disclosure intervals
compute_io_metrics_vietnam()
FOL Utilization
Foreign % / FOL limit
Vietnam-specific; no US equivalent
FOLAnalyzer
FOL Premium
Price impact of FOL proximity
Cross-sectional regression approach
FOLAnalyzer.estimate_fol_premium()
Trades
ΔShares (corp-action adjusted)
Critical: adjust for stock dividends
derive_trades_vectorized_vietnam()
Fund Turnover
min(B,S)/avg(A)
Semi-annual frequency; annualized
compute_fund_analytics()
SOE Status
State ownership > 50%
Tracks equitization program
analyze_state_ownership()
LSV Herding
\(|p - E[p]| - E[|p - E[p]|]\)
Separate foreign vs domestic herding
compute_herding_vietnam()
Co-Ownership Network
Shared institutional holders
Reveals conglomerate linkages
construct_stock_coownership_network()
34.12.2 Data Quality Checklist for Vietnam
Vietnam Data Quality Checklist
34.12.3 Comparison with US Framework
Table 34.6: US vs Vietnam Institutional Ownership Framework Comparison
Dimension
US (WRDS/13F)
Vietnam (DataCore.vn)
Disclosure
Quarterly 13F (mandatory)
Annual reports + event-driven
Coverage
Institutions > $100M AUM
All shareholders in annual reports
Ownership observed
Long positions only
Complete decomposition
IO can exceed 100%
Yes (short selling)
No (by construction)
Permanent ID
CRSP PERMNO
Ticker (with manual tracking of changes)
Adjustment factors
CRSP cfacshr
Must build from corporate actions
Investor classification
LSEG typecode / Bushee
State/Foreign/Domestic/Individual
Short selling
Not in 13F; exists in market
Very limited; not a concern
Unique features
—
FOL, SOE ownership, stock dividend frequency
Bao Dinh, Ngoc, and 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, and Rabih Moussawi. 2013. “Do Hedge Funds Manipulate Stock Prices?”The Journal of Finance 68 (6): 2383–2434.
Cao, Sean, Zhi Da, Daniel Jiang, and 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, and 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, and Tao Shu. 2023. “Factors and Anomalies in the Vietnamese Stock Market.”Pacific-Basin Finance Journal 82: 102176.
Lehavy, Reuven, and 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.