Trong chương này, chúng ta so sánh một cách có hệ thống các phương pháp xác định trọng số danh mục đầu tư (ví dụ: trọng số theo vốn hóa, trọng số bằng nhau và một số phương pháp thay thế dựa trên rủi ro) trên thị trường chứng khoán Việt Nam. Chúng ta định lượng tác động của tần suất tái cân bằng danh mục và chi phí giao dịch đến hiệu quả đầu tư thực tế, đồng thời xây dựng các công cụ thực tiễn để lập danh mục có thể triển khai được trong điều kiện các ma sát đặc trưng của một thị trường mới nổi.
Mọi quyết định xây dựng danh mục đầu tư rốt cuộc đều quy về hai lựa chọn: nắm giữ những tài sản nào, và phân bổ bao nhiêu cho mỗi tài sản. Trong khi các chương trước tập trung vào câu hỏi thứ nhất, tức là dùng các mô hình nhân tố, các hiện tượng bất thường và phân tích cơ bản để chọn cổ phiếu, thì chương này giải quyết câu hỏi thứ hai. Phương pháp xác định trọng số mà nhà nghiên cứu hay nhà đầu tư áp dụng có thể làm thay đổi căn bản các kết luận rút ra từ kiểm định ở cấp độ danh mục đầu tư cũng như lợi nhuận thu được từ một chiến lược đầu tư.
Sự khác biệt này quan trọng ở Việt Nam hơn so với các thị trường lớn và có tính thanh khoản cao. Thị trường chứng khoán Việt Nam có phân phối vốn hóa thị trường lệch cực mạnh: 10 doanh nghiệp lớn nhất trên HOSE chiếm khoảng 50% tổng vốn hóa thị trường, trong khi hàng trăm doanh nghiệp nhỏ đóng góp tỷ trọng không đáng kể. Với trọng số theo vốn hóa, hiệu quả của danh mục bị chi phối bởi một số ít cổ phiếu vốn hóa lớn (Vinhomes, Vingroup, Vietcombank, FPT). Với trọng số bằng nhau, mọi doanh nghiệp đóng góp như nhau, khiến danh mục nghiêng về các cổ phiếu nhỏ, kém thanh khoản, vốn có thể tốn kém hoặc không thể giao dịch ở quy mô lớn. Không phương pháp nào đúng một cách tuyệt đối; sự lựa chọn phụ thuộc vào câu hỏi đang được đặt ra.
Chương này xây dựng khung phân tích để đưa ra lựa chọn đó. Chúng ta bắt đầu với các tính chất lý thuyết của các phương pháp xác định trọng số, triển khai từng phương pháp trên dữ liệu Việt Nam, định lượng chi phí giao dịch của việc tái cân bằng, và mở rộng phân tích sang các phương pháp thay thế dựa trên rủi ro, vốn tính đến một cách tường minh cấu trúc hiệp phương sai của lợi nhuận.
12.1 Khung lý thuyết
12.1.1 Danh mục trọng số theo vốn hóa
Danh mục trọng số theo vốn hóa (VW) phân bổ cho mỗi cổ phiếu một tỷ trọng tỷ lệ thuận với vốn hóa thị trường của nó:
trong đó \(\text{MCap}_{i,t} = P_{i,t} \times \text{Shares}_{i,t}\) là vốn hóa thị trường của cổ phiếu \(i\) tại thời điểm \(t\).
Danh mục VW có một vị thế lý thuyết đặc biệt: đó là danh mục mà tất cả các nhà đầu tư cùng nắm giữ (tức “danh mục thị trường” trong CAPM). Các tính chất chính của nó là:
Tự tái cân bằng. Khi giá thay đổi, các trọng số tự điều chỉnh. Danh mục VW chỉ cần giao dịch khi các cổ phiếu cấu thành được thêm vào hoặc loại khỏi chỉ số, hoặc khi các sự kiện doanh nghiệp (corporate actions: chia tách, phát hành thêm) làm thay đổi số cổ phiếu đang lưu hành.
Tỷ lệ vòng quay thấp. Vì các trọng số trôi theo giá thay vì được đặt lại về mục tiêu, danh mục VW có chi phí tái cân bằng tối thiểu.
Thiên lệch về vốn hóa lớn. Lợi nhuận bị chi phối bởi các doanh nghiệp lớn nhất. Ở Việt Nam, điều này có nghĩa là hồ sơ rủi ro–lợi nhuận của danh mục chịu ảnh hưởng nặng nề từ ngân hàng, bất động sản và các tập đoàn công nghệ.
Hsu (2006) lập luận rằng các danh mục VW là dưới mức tối ưu vì chúng, một cách máy móc, nắm giữ quá nhiều cổ phiếu bị định giá cao và quá ít cổ phiếu bị định giá thấp (tức là, bất kỳ sự chênh lệch nào của giá so với giá trị cơ bản đều tạo ra một lực cản mang tính hệ thống đối với hiệu quả của danh mục VW so với phương án xác định trọng số theo yếu tố cơ bản).
12.1.2 Danh mục trọng số bằng nhau
Danh mục trọng số bằng nhau (EW) gán cùng một trọng số cho mỗi cổ phiếu cấu thành:
\[
w_{i,t}^{EW} = \frac{1}{N_t}
\tag{12.2}\]
DeMiguel, Garlappi, và Uppal (2009) chỉ ra rằng danh mục 1/N cạnh tranh được một cách đáng ngạc nhiên với các danh mục tối ưu hóa trung bình–phương sai, đặc biệt khi các cửa sổ ước lượng ngắn và số lượng tài sản lớn (những điều kiện mô tả sát thị trường Việt Nam). Trực giác là sai số ước lượng của lợi nhuận kỳ vọng và hiệp phương sai có thể lấn át lợi ích từ việc tối ưu hóa, khiến phương pháp trọng số bằng nhau “ngây thơ” trở thành một mặc định vững chắc.
Plyakha, Uppal, và Vilkov (2021) phân tách phần vượt trội của EW so với VW thành hai thành phần:
Thiên lệch quy mô. EW phân bổ nhiều hơn cho các doanh nghiệp nhỏ, vốn trong lịch sử đem lại phần bù quy mô.
Phần thưởng tái cân bằng. Việc tái cân bằng hằng tháng về trọng số bằng nhau là một chiến lược đầu tư ngược xu hướng: bán những cổ phiếu thắng cuộc gần đây và mua những cổ phiếu thua cuộc gần đây, thu lợi từ sự hồi quy về giá trị trung bình trong lợi nhuận của từng cổ phiếu.
Tuy nhiên, danh mục EW có những bất lợi thực tiễn đặc biệt nghiêm trọng ở Việt Nam:
Tỷ lệ vòng quay cao. Mỗi thời điểm tái cân bằng đều đòi hỏi phải giao dịch mọi cổ phiếu để đưa về trọng số bằng nhau.
Mức độ phơi nhiễm với tính kém thanh khoản. Việc đặt trọng số bằng nhau cho các cổ phiếu vốn hóa siêu nhỏ chỉ giao dịch 100 triệu VND/ngày bên cạnh các cổ phiếu vốn hóa lớn giao dịch 500 tỷ VND/ngày gây ra những khó khăn nghiêm trọng khi triển khai.
Tác động giá. Trong một thị trường có biên độ giá hằng ngày (\(\pm\) 7% trên HOSE, \(\pm\) 10% trên HNX), các lệnh tái cân bằng đối với những cổ phiếu kém thanh khoản có thể chạm giá trần hoặc giá sàn, khiến lệnh không thể khớp đầy đủ.
12.1.3 Phổ các phương pháp xác định trọng số
Giữa VW và EW là một dải liên tục các phương pháp xác định trọng số. Bảng 12.1 tóm tắt các phương án chính.
Bảng 12.1: Tóm tắt các phương pháp xác định trọng số danh mục đầu tư.
12.2.1 Xác định tập hợp đầu tư và bộ lọc thanh khoản
Một bước tiền xử lý then chốt là xác định tập hợp có thể đầu tư. Việc đưa vào mọi cổ phiếu niêm yết, bất kể thanh khoản, làm thổi phồng lợi ích biểu kiến của trọng số bằng nhau và các phương pháp khác nghiêng về cổ phiếu nhỏ, vì nó ngầm giả định rằng ta có thể giao dịch các cổ phiếu kém thanh khoản mà không có ma sát. Chúng ta áp dụng các bộ lọc thanh khoản theo từng mức và theo dõi kết quả thay đổi ra sao.
Apply liquidity filters to define investable universes
def construct_universe(monthly_df, min_mcap_pct=0, min_turnover=0, min_months=12):""" Construct investable universe with liquidity filters. Parameters ---------- min_mcap_pct : float Exclude stocks below this market cap percentile (0-100). min_turnover : float Minimum average daily turnover in VND billion. min_months : int Minimum months of return history required. """ df = monthly_df.copy()# Market cap percentile filter (within each month)if min_mcap_pct >0: df["mcap_pctile"] = df.groupby("month_end")["market_cap"].transform(lambda x: x.rank(pct=True) *100 ) df = df[df["mcap_pctile"] >= min_mcap_pct]# Turnover filterif min_turnover >0: df = df[df["turnover_value_avg_20d"] >= min_turnover *1e9]# History filter ticker_months = df.groupby("ticker")["month_end"].transform("count") df = df[ticker_months >= min_months]return df# Define three universes of increasing restrictivenessuniverse_all = construct_universe(monthly)universe_mid = construct_universe(monthly, min_mcap_pct=20, min_turnover=0.5)universe_liquid = construct_universe(monthly, min_mcap_pct=40, min_turnover=2.0)for name, univ in [ ("All stocks", universe_all), ("Mid filter", universe_mid), ("Liquid only", universe_liquid),]: n_stocks = univ.groupby("month_end")["ticker"].nunique().median()print(f"{name}: median {n_stocks:.0f} stocks/month")
12.2.2 Mức độ tập trung vốn hóa thị trường
Trước khi so sánh các phương pháp xác định trọng số, cần ghi nhận thị trường Việt Nam thực sự tập trung đến mức nào.
Bây giờ chúng ta triển khai từng phương pháp xác định trọng số và tính lợi nhuận danh mục hằng tháng. Mọi phần triển khai đều theo một cấu trúc chung: tại mỗi ngày tái cân bằng, tính các trọng số mục tiêu từ thông tin sẵn có, rồi tính lợi nhuận có trọng số trong kỳ nắm giữ tiếp theo.
12.3.1 Bộ máy cốt lõi của danh mục
Generic portfolio construction and backtesting engine
def compute_portfolio_returns( monthly_df, weight_fn, rebal_freq="M", max_weight=1.0, min_weight=0.0,):""" Compute time series of portfolio returns for a given weighting function. Parameters ---------- monthly_df : DataFrame Must contain 'ticker', 'month_end', 'monthly_return', and any columns needed by weight_fn. weight_fn : callable Function that takes a cross-section DataFrame and returns a Series of weights indexed by ticker. Weights need not sum to 1 (they will be normalized). rebal_freq : str 'M' for monthly, 'Q' for quarterly, 'A' for annual. max_weight : float Maximum weight per stock (for capped schemes). min_weight : float Minimum weight per stock. Returns ------- DataFrame with columns: month_end, port_return, n_stocks, turnover, hhi, effective_n """ months =sorted(monthly_df["month_end"].unique())# Determine rebalancing datesif rebal_freq =="M": rebal_dates =set(months)elif rebal_freq =="Q": rebal_dates =set(pd.to_datetime(months).to_period("Q").to_timestamp("M"))# Map to nearest month-end rebal_dates = {m for m in months if pd.Timestamp(m).month %3==0}ifnot rebal_dates: rebal_dates =set(months[::3])elif rebal_freq =="A": rebal_dates = {m for m in months if pd.Timestamp(m).month ==6}ifnot rebal_dates: rebal_dates =set(months[::12])else: rebal_dates =set(months) results = [] prev_weights =Nonefor month in months: cross_section = monthly_df[monthly_df["month_end"] == month].copy() cross_section = cross_section.dropna(subset=["monthly_return"])iflen(cross_section) <5:continueif month in rebal_dates or prev_weights isNone:# Compute fresh weights raw_weights = weight_fn(cross_section) raw_weights = raw_weights.clip(lower=min_weight, upper=max_weight) total = raw_weights.sum()if total <=0:continue target_weights = raw_weights / totalelse:# Drift weights forward from previous monthif prev_weights isnotNone: available = cross_section.set_index("ticker") drifted = prev_weights.reindex(available.index, fill_value=0)# Adjust for returns drifted = drifted * (1+ available["monthly_return"]) total = drifted.sum()if total <=0:continue target_weights = drifted / totalelse:continue# Align weights with available stocks cross_section = cross_section.set_index("ticker") aligned_w = target_weights.reindex(cross_section.index, fill_value=0) aligned_w = aligned_w / aligned_w.sum()# Portfolio return port_ret = (aligned_w * cross_section["monthly_return"]).sum()# Turnover (two-way)if prev_weights isnotNone: prev_aligned = prev_weights.reindex(aligned_w.index, fill_value=0)# Drift previous weights prev_drifted = prev_aligned * (1+ cross_section["monthly_return"].reindex( prev_aligned.index, fill_value=0 ) ) prev_drifted = ( prev_drifted / prev_drifted.sum()if prev_drifted.sum() >0else prev_drifted ) turnover = (aligned_w - prev_drifted).abs().sum() /2else: turnover =1.0# Concentration metrics hhi = (aligned_w**2).sum() effective_n =1.0/ hhi if hhi >0else0 results.append( {"month_end": month,"port_return": port_ret,"n_stocks": (aligned_w >1e-6).sum(),"turnover": turnover,"hhi": hhi,"effective_n": effective_n, } ) prev_weights = aligned_w.copy()return pd.DataFrame(results)
12.3.4 Danh mục trọng số theo vốn hóa có giới hạn trần
Để giảm bớt mức độ tập trung của VW thuần túy trong khi vẫn giữ các đặc tính vòng quay thấp của nó, chúng ta áp đặt giới hạn trần lên trọng số của từng cổ phiếu. Lựa chọn phổ biến là 5% hoặc 10%, mô phỏng các quy tắc xây dựng của những chỉ số có giới hạn trần như các chỉ số MSCI Capped.
Arnott, Hsu, và Moore (2005) đề xuất xác định trọng số cổ phiếu theo các thước đo cơ bản (doanh thu, vốn chủ sở hữu sổ sách, cổ tức, dòng tiền) thay vì vốn hóa thị trường. Lập luận là các trọng số cơ bản không bị nhiễu bởi sai số định giá, nhờ đó phá vỡ việc nắm giữ quá mức một cách máy móc các cổ phiếu bị định giá cao vốn có trong VW. Chúng ta xây dựng một trọng số cơ bản tổng hợp bằng thứ hạng trung bình của bốn thước đo:
# Merge fundamentals with monthly data (use most recent fiscal year)fundamentals["merge_year"] = fundamentals["fiscal_year"] +1# Lag by 1 yearmonthly_fund = monthly.copy()monthly_fund["year"] = pd.to_datetime(monthly_fund["month_end"]).dt.yearmonthly_fund = monthly_fund.merge( fundamentals.rename(columns={"merge_year": "year"}), on=["ticker", "year"], how="left",)def fw_weights(cross_section):"""Fundamental-weighted: composite of revenue, book equity, dividends, CFO.""" cs = cross_section.set_index("ticker") ranks = pd.DataFrame(index=cs.index)for col in ["revenue", "book_equity", "dividends_paid", "operating_cash_flow"]:if col in cs.columns: vals = cs[col].clip(lower=0) # Only positive values ranks[col] = vals.rank(pct=True) composite = ranks.mean(axis=1) composite = composite.fillna(0)return compositefw_returns = compute_portfolio_returns(monthly_fund, fw_weights, rebal_freq="A")print(f"Fundamental-weighted: mean ret = {fw_returns['port_return'].mean():.4f}, "f"eff_N = {fw_returns['effective_n'].mean():.1f}")
12.4 Các phương pháp xác định trọng số dựa trên rủi ro
Các phương pháp xác định trọng số ở trên chỉ sử dụng vốn hóa thị trường hoặc dữ liệu kế toán. Các phương pháp dựa trên rủi ro tính đến cấu trúc hiệp phương sai của lợi nhuận, nhằm tạo ra các danh mục có hiệu quả điều chỉnh theo rủi ro tốt hơn. Cái giá phải trả là chúng đòi hỏi ước lượng ma trận hiệp phương sai (tức là một đối tượng nhiều chiều vốn nổi tiếng là khó ước lượng chính xác với chuỗi thời gian ngắn).
12.4.1 Ước lượng hiệp phương sai
Với \(N \approx 300\) cổ phiếu và \(T \approx 60\) tháng, ma trận hiệp phương sai mẫu có điều kiện xấu nghiêm trọng (ill-conditioned). Chúng ta dùng bộ ước lượng co rút của Ledoit và Wolf (2004), kéo hiệp phương sai mẫu về một mục tiêu có cấu trúc (ma trận đơn vị nhân với phương sai trung bình):
trong đó \(\mathbf{S}\) là hiệp phương sai mẫu, \(\mathbf{F}\) là mục tiêu co rút, và \(\delta \in [0,1]\) là cường độ co rút tối ưu được suy ra theo phương pháp giải tích.
Estimate covariance matrix with Ledoit-Wolf shrinkage
def estimate_covariance(monthly_df, month, lookback=60, min_obs=36):""" Estimate covariance matrix using Ledoit-Wolf shrinkage. Parameters ---------- monthly_df : DataFrame with ticker, month_end, monthly_return month : target month (use returns before this date) lookback : number of months to use min_obs : minimum observations per stock Returns ------- cov_matrix : DataFrame (N x N) tickers : list of tickers with sufficient data """ end_date = pd.Timestamp(month) start_date = end_date - pd.DateOffset(months=lookback) window = monthly_df[ (monthly_df['month_end'] > start_date) & (monthly_df['month_end'] <= end_date) ]# Pivot to wide format returns_wide = window.pivot_table( index='month_end', columns='ticker', values='monthly_return' )# Keep stocks with sufficient observations valid_cols = returns_wide.columns[returns_wide.notna().sum() >= min_obs] returns_wide = returns_wide[valid_cols].dropna(axis=0, how='all')# Fill remaining NAs with 0 (conservative) returns_clean = returns_wide.fillna(0)if returns_clean.shape[1] <10:returnNone, None# Ledoit-Wolf shrinkage lw = LedoitWolf() lw.fit(returns_clean.values) cov_matrix = pd.DataFrame( lw.covariance_, index=valid_cols, columns=valid_cols )return cov_matrix, list(valid_cols)
12.4.2 Danh mục phương sai tối thiểu
Danh mục phương sai tối thiểu toàn cục (GMV) cực tiểu hóa phương sai danh mục mà không nhắm đến một mức lợi nhuận cụ thể (Clarke, De Silva, và Thorley 2011):
Ràng buộc chỉ mua (\(w_i \geq 0\)) là thiết yếu trong thực tế và cũng đóng vai trò như một dạng co rút ngầm, giúp cải thiện hiệu quả ngoài mẫu (Jagannathan và Ma 2003).
Implement minimum variance portfolio
def minimum_variance_weights(cov_matrix, max_weight=0.05):""" Solve for the minimum variance portfolio with long-only and position-size constraints. """ n = cov_matrix.shape[0] Sigma = cov_matrix.valuesdef portfolio_variance(w):return w @ Sigma @ w constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) -1} ] bounds = [(0, max_weight) for _ inrange(n)] x0 = np.ones(n) / n result = minimize( portfolio_variance, x0, method='SLSQP', bounds=bounds, constraints=constraints, options={'maxiter': 1000, 'ftol': 1e-12} )if result.success:return pd.Series(result.x, index=cov_matrix.index)else:return pd.Series(1.0/ n, index=cov_matrix.index)def mv_weight_fn(cross_section, cov_cache={}):"""Wrapper for portfolio engine: minimum variance.""" month = cross_section['month_end'].iloc[0]if month notin cov_cache: cov_matrix, tickers = estimate_covariance(monthly, month) cov_cache[month] = (cov_matrix, tickers) cov_matrix, tickers = cov_cache[month]if cov_matrix isNone:# Fallback to equal weightreturn pd.Series(1.0, index=cross_section.set_index('ticker').index)# Restrict to stocks in both cross-section and covariance matrix available =set(cross_section['ticker']) &set(tickers)iflen(available) <10:return pd.Series(1.0, index=cross_section.set_index('ticker').index) sub_cov = cov_matrix.loc[list(available), list(available)] weights = minimum_variance_weights(sub_cov)return weightsmv_returns = compute_portfolio_returns( universe_mid, mv_weight_fn, rebal_freq='Q')print(f"Min Variance: mean ret = {mv_returns['port_return'].mean():.4f}, "f"std = {mv_returns['port_return'].std():.4f}")
12.4.3 Ngang bằng rủi ro (đóng góp rủi ro bằng nhau)
Phương pháp ngang bằng rủi ro (risk parity) phân bổ sao cho mỗi tài sản đóng góp như nhau vào tổng rủi ro của danh mục (Maillard, Roncalli, và Teı̈letche 2010). Đóng góp rủi ro của tài sản \(i\) là:
Chi phí giao dịch ở Việt Nam bao gồm các thành phần tường minh (phí môi giới, phí sàn giao dịch, thuế) và các thành phần ngầm (chênh lệch giá mua–bán, tác động giá). Cấu trúc chi phí tường minh tính đến năm 2024 xấp xỉ như sau:
Bảng 12.2: Các thành phần chi phí giao dịch tường minh đối với cổ phiếu Việt Nam.
Thành phần
Mức phí
Ghi chú
Phí môi giới
0.15–0.35%
Tùy công ty môi giới và mức khối lượng
Phí sàn và bù trừ
0.003%
Do sàn giao dịch quy định cố định
Thuế bán
0.10%
Đánh trên tổng giá trị bán
Tổng chi phí tường minh cho một vòng giao dịch (mua + bán) dao động từ khoảng 0.30% đến 0.80%. Các chi phí ngầm, tức chênh lệch giá và tác động giá, có thể lớn hơn đáng kể đối với các cổ phiếu nhỏ và kém thanh khoản.
Chúng ta mô hình hóa tổng chi phí giao dịch như một hàm của quy mô lệnh và thanh khoản của cổ phiếu:
trong đó \(c_{\text{fixed}} \approx 0.25\%\) là chi phí tường minh trên mỗi giao dịch, \(\text{Spread}_{i,t}\) là chênh lệch giá mua–bán niêm yết, \(\Delta w_{i,t}\) là mức thay đổi trọng số, \(\text{AUM}\) là quy mô danh mục, \(ADV_{i,t}\) là khối lượng giao dịch trung bình hằng ngày tính theo VND, và \(\lambda\) là hệ số tác động giá được ước lượng từ mô hình Amihud (2002).
Implement transaction cost model calibrated to Vietnamese market
Chúng ta áp dụng mô hình chi phí giao dịch để tính lợi nhuận sau chi phí của từng phương pháp xác định trọng số tại các mức AUM giả định khác nhau. Điều này rất quan trọng vì những chiến lược có vẻ hấp dẫn khi tính gộp có thể không thể triển khai ở quy mô lớn do tính kém thanh khoản của các cổ phiếu vốn hóa nhỏ Việt Nam.
Compute net-of-cost returns using simplified proportional cost model
def compute_net_returns(portfolio_df, cost_per_turnover=0.005):""" Approximate net returns using a proportional cost model. Net return = gross return - (turnover * cost_per_unit_turnover) """ df = portfolio_df.copy() df['tc'] = df['turnover'] * cost_per_turnover df['net_return'] = df['port_return'] - df['tc']return df# Compute at different cost assumptionscost_scenarios = {'Low (25 bps)': 0.0025,'Medium (50 bps)': 0.005,'High (100 bps)': 0.01}print("Annualized Net Sharpe Ratios by Cost Scenario:")print("-"*70)for cost_name, cost_rate in cost_scenarios.items(): row = {'Scenario': cost_name}for port_name, port_df in portfolios.items(): net_df = compute_net_returns(port_df, cost_rate) rf_monthly = (1.04) ** (1/12) -1 excess = net_df['net_return'] - rf_monthly sharpe = excess.mean() / excess.std() * np.sqrt(12) if excess.std() >0else0 row[port_name] =round(sharpe, 3)print(f"{cost_name}:")for k, v in row.items():if k !='Scenario':print(f" {k}: {v}")print()
Mối quan hệ giữa AUM của danh mục và hiệu quả có thể triển khai là phi tuyến vì chi phí tác động giá tăng theo quy mô lệnh. Chúng ta mô phỏng hiệu quả tại các mức AUM khác nhau:
Simulate performance degradation with AUM for different schemes
aum_levels = [10, 50, 100, 500, 1000, 5000] # VND billionsfig, ax = plt.subplots(figsize=(10, 6))selected_ports = ['VW', 'EW (Monthly)', 'Capped VW (5%)','Min Variance', 'Risk Parity']for name in selected_ports: sharpes = [] df = portfolios[name]for aum in aum_levels:# Cost scales with sqrt(AUM / ADV) base_cost =0.003# Base cost at small AUM scale_factor = np.sqrt(aum /100) # Normalized to VND 100bn cost_rate = base_cost * scale_factor# VW is less affected (large-cap tilt)if name =='VW': cost_rate *=0.3elif'Capped'in name: cost_rate *=0.5elif'Min Variance'in name or'Risk Parity'in name: cost_rate *=0.7 net_df = compute_net_returns(df, cost_rate) rf_m = (1.04) ** (1/12) -1 excess = net_df['net_return'] - rf_m s = excess.mean() / excess.std() * np.sqrt(12) if excess.std() >0else0 sharpes.append(s) ax.plot(aum_levels, sharpes, marker='o', label=name, color=colors.get(name, 'gray'), linewidth=2)ax.set_xlabel('Portfolio AUM (VND Billion)')ax.set_ylabel('Net Sharpe Ratio')ax.set_title('Sharpe Ratio Degradation with AUM')ax.set_xscale('log')ax.legend(fontsize=9)ax.axhline(y=0, color='gray', linewidth=0.5)plt.tight_layout()plt.show()
Hình 12.5
12.7 Phân tích tần suất tái cân bằng
12.7.1 Sự đánh đổi trong tái cân bằng
Việc tái cân bằng phục vụ hai mục đích: (i) khôi phục các trọng số mục tiêu để duy trì hồ sơ rủi ro mong muốn, và (ii) thu về “phần thưởng tái cân bằng”, tức khoản lợi nhuận mang tính hệ thống từ việc mua thấp bán cao phát sinh khi các trọng số được đặt lại về mục tiêu trong điều kiện lợi nhuận chéo giữa các cổ phiếu có tính hồi quy về giá trị trung bình.
Sự đánh đổi là rõ ràng: tái cân bằng càng thường xuyên thì càng bám sát các trọng số mục tiêu và nắm bắt được nhiều phần thưởng tái cân bằng hơn, nhưng đồng thời phát sinh chi phí giao dịch cao hơn. Tần suất tối ưu phụ thuộc vào mức độ hồi quy về giá trị trung bình (quyết định phần thưởng tái cân bằng gộp), mức chi phí giao dịch, và tốc độ các trọng số trôi khỏi mục tiêu.
Analyze rebalancing frequency impact on EW portfolio
frequencies = {'Monthly': 'M','Quarterly': 'Q','Semi-annual': 'Q', # Approximate with every 6 months'Annual': 'A'}# Recompute EW at each frequencyfreq_results = {}for freq_name, freq_code in frequencies.items(): freq_df = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq=freq_code ) freq_results[freq_name] = freq_dffig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Gross vs Net Sharpefreq_names =list(freq_results.keys())gross_sharpes = []net_sharpes = []for fn in freq_names: df = freq_results[fn] rf_m = (1.04) ** (1/12) -1 exc = df['port_return'] - rf_m gross_sharpes.append(exc.mean() / exc.std() * np.sqrt(12)) net_df = compute_net_returns(df, 0.005) exc_net = net_df['net_return'] - rf_m net_sharpes.append(exc_net.mean() / exc_net.std() * np.sqrt(12))x =range(len(freq_names))axes[0].bar([i -0.15for i in x], gross_sharpes, width=0.3, color='#2C5F8A', alpha=0.85, label='Gross')axes[0].bar([i +0.15for i in x], net_sharpes, width=0.3, color='#C0392B', alpha=0.85, label='Net (50 bps)')axes[0].set_xticks(x)axes[0].set_xticklabels(freq_names)axes[0].set_ylabel('Annualized Sharpe Ratio')axes[0].set_title('Panel A: Sharpe Ratio by Rebalancing Frequency')axes[0].legend()# Panel B: Average turnoverturnovers = [freq_results[fn]['turnover'].mean() for fn in freq_names]axes[1].bar(x, turnovers, color='#E67E22', alpha=0.85)axes[1].set_xticks(x)axes[1].set_xticklabels(freq_names)axes[1].set_ylabel('Average Monthly Turnover')axes[1].set_title('Panel B: Turnover by Rebalancing Frequency')plt.tight_layout()plt.show()
Hình 12.6
12.7.2 Tái cân bằng dựa trên ngưỡng
Một phương án thay thế cho tái cân bằng theo lịch là chỉ tái cân bằng khi các trọng số danh mục trôi vượt quá một dải dung sai. Cách tiếp cận “vùng không giao dịch” này bắt nguồn từ Gârleanu và Pedersen (2013), các tác giả đã suy ra chiến lược giao dịch động tối ưu với chi phí giao dịch dạng toàn phương và chỉ ra rằng danh mục tối ưu là trung bình có trọng số của danh mục đang nắm giữ và mục tiêu không có ma sát.
Implement threshold-based rebalancing for EW portfolio
Lợi nhuận vượt mức của danh mục EW được tái cân bằng so với danh mục EW mua và nắm giữ (bắt đầu với trọng số bằng nhau nhưng sau đó trôi) có thể được phân tách theo Plyakha, Uppal, và Vilkov (2021). Định nghĩa \(r_i\) là lợi nhuận của cổ phiếu \(i\) trong một kỳ:
Số hạng thứ nhất nắm bắt hiệu ứng “mua thấp, bán cao” từ việc đặt lại trọng số sau khi lợi nhuận phân tán. Số hạng thứ hai là chi phí của việc đảo ngược sự trôi do hiệp phương sai gây ra. Phần thưởng tái cân bằng lớn hơn khi độ phân tán lợi nhuận chéo giữa các cổ phiếu cao (điều đúng ở Việt Nam) và khi các tương quan theo cặp thấp.
Estimate the rebalancing bonus for Vietnamese equities
# Compute buy-and-hold EW portfolio (no rebalancing after initial equal weight)bh_returns = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq='A'# Rebalance once per year only)# The rebalancing bonus is the differencebonus_df = pd.merge( ew_monthly[['month_end', 'port_return']].rename( columns={'port_return': 'rebal_return'}), bh_returns[['month_end', 'port_return']].rename( columns={'port_return': 'bh_return'}), on='month_end')bonus_df['bonus'] = bonus_df['rebal_return'] - bonus_df['bh_return']# Cross-sectional return dispersiondispersion = ( universe_mid .groupby('month_end')['monthly_return'] .std() .reset_index(name='cs_dispersion'))bonus_df = bonus_df.merge(dispersion, on='month_end')ann_bonus = bonus_df['bonus'].mean() *12print(f"Annualized rebalancing bonus (EW monthly vs annual): {ann_bonus:.4f}")print(f"Mean cross-sectional dispersion: {bonus_df['cs_dispersion'].mean():.4f}")
Các phương pháp xác định trọng số khác nhau tạo ra các mức độ phơi nhiễm nhân tố khác nhau, điều này có thể giải thích sự khác biệt về lợi nhuận giữa chúng. Chúng ta hồi quy lợi nhuận vượt mức của từng danh mục theo các nhân tố Fama-French của Việt Nam:
Heatmap of factor exposures across weighting schemes
fig, ax = plt.subplots(figsize=(10, 6))plot_data = factor_df[['MKT', 'SMB', 'HML', 'WML']].copy()im = ax.imshow(plot_data.values, cmap='RdBu_r', aspect='auto', vmin=-1.5, vmax=1.5)ax.set_xticks(range(len(plot_data.columns)))ax.set_xticklabels(plot_data.columns, fontsize=10)ax.set_yticks(range(len(plot_data.index)))ax.set_yticklabels(plot_data.index, fontsize=9)# Add text annotationsfor i inrange(len(plot_data.index)):for j inrange(len(plot_data.columns)): val = plot_data.values[i, j] color ='white'ifabs(val) >0.8else'black' ax.text(j, i, f'{val:.2f}', ha='center', va='center', color=color, fontsize=9)plt.colorbar(im, ax=ax, label='Factor Loading')ax.set_title('Factor Exposures by Weighting Scheme')plt.tight_layout()plt.show()
Hình 12.8
12.10 Học máy và đường biên hiệu quả có thể triển khai
Mọi phương pháp trong chương này đều được chọn trước rồi mới tính phí giao dịch sau. Jensen và c.s. (2026) lập luận rằng thứ tự này là ngược đối với bất kỳ chiến lược nào giao dịch dựa trên tín hiệu, và đối tượng đánh giá đúng đắn là đường biên hiệu quả có thể triển khai (implementable efficient frontier): lợi nhuận kỳ vọng cao nhất sau chi phí giao dịch có thể đạt được tại mỗi mức rủi ro. Họ xây dựng một phương pháp danh mục xoay quanh mục tiêu đó, áp dụng học máy vào nó, và chỉ ra rằng thứ hạng của các chiến lược cũng như của các đặc điểm thúc đẩy chúng thay đổi một khi chi phí được đưa vào hàm mục tiêu thay vì chỉ nằm trong sổ sách kế toán.
12.10.1 Bài toán
Một nhà đầu tư có tài sản \(W_t\) nắm giữ các trọng số danh mục \(\pi_t\) trong \(N\) cổ phiếu. Giữa các ngày tái cân bằng, các trọng số trôi theo lợi nhuận, do đó danh mục kế thừa tại \(t\) là \(g_t \pi_{t-1}\), trong đó \(g_t\) là ma trận đường chéo với các phần tử \((1 + r^f_t + r_{i,t})/(1 + g^W_t)\) và \(g^W_t\) là tốc độ tăng của tài sản. Chuyển sang \(\pi_t\) đòi hỏi giao dịch \(W_t(\pi_t - g_t\pi_{t-1})\) tính theo đơn vị tiền tệ, và giao dịch làm giá dịch chuyển tuyến tính theo quy mô lệnh, với ma trận tác động giá đường chéo \(\Lambda_t\). Lợi nhuận sau khi trừ lãi suất phi rủi ro và chi phí giao dịch là
và nhà đầu tư tối đa hóa giá trị trung bình của lợi nhuận này trừ \(\gamma/2\) lần phương sai của nó. Hai đặc điểm của Phương trình 12.11 đáng chú ý. Chi phí giao dịch có dạng toàn phương theo quy mô lệnh, đó là đặc tả tác động giá tuyến tính của Gârleanu và Pedersen (2013) chứ không phải mô hình căn bậc hai trong Phương trình 12.8; lựa chọn này đổi lấy tính khả giải với cái giá là đánh giá thấp chi phí của các lệnh nhỏ. Và chi phí tỷ lệ theo tài sản: cùng các trọng số đó tốn kém hơn đối với nhà đầu tư lớn hơn. Các tác giả hiệu chỉnh tác động giá sao cho giao dịch 1% giá trị giao dịch hằng ngày của một cổ phiếu làm giá dịch chuyển 0.1%, điều này cho ra \(\lambda_{i,t} = 0.2 / \text{ADV}_{i,t}\).
Đường biên được vạch ra bằng cách thay đổi \(\gamma\) có một tính chất mà đường biên trong sách giáo khoa không có. Khi không có chi phí, mọi điểm trên đường thị trường vốn đều đạt được bằng cách sử dụng đòn bẩy với danh mục tiếp tuyến, do đó tỷ số Sharpe không đổi dọc theo đường đó. Khi có chi phí, vị thế lớn hơn đồng nghĩa với lệnh lớn hơn, nên tỷ số Sharpe sau chi phí giảm dần dọc theo đường biên có thể triển khai. Vì vậy, nhà đầu tư không thể tối đa hóa tỷ số Sharpe sau chi phí rồi mới chọn đòn bẩy; rủi ro, lợi nhuận và chi phí phải được chọn cùng nhau. Và đường biên của nhà đầu tư lớn hơn luôn nằm dưới đường biên của nhà đầu tư nhỏ hơn ở mọi điểm.
12.10.2 Lời giải: Giao dịch hướng về một mục tiêu
Khái quát hóa Gârleanu và Pedersen (2013) cho lợi nhuận dừng và cho lợi nhuận kỳ vọng là các hàm bất kỳ của các đặc điểm, các tác giả chỉ ra rằng danh mục tối ưu có dạng
do đó nhà đầu tư xuất phát từ danh mục kế thừa và giao dịch một phần \(I - m\) của quãng đường hướng về một danh mục mục tiêu (aim portfolio) \(A_t\). Ma trận \(m\) là nghiệm của một phương trình điểm bất động theo \(\gamma\Sigma\) và \(W\Lambda\) và quyết định tốc độ giao dịch: nó tiến dần về ma trận đơn vị, nghĩa là giao dịch chậm hơn, khi tài sản hoặc tác động giá tăng và khi mức e ngại rủi ro giảm. Danh mục mục tiêu là tổng chiết khấu của các danh mục Markowitz hiện tại và kỳ vọng trong tương lai,
với \(c\) là một ma trận phụ thuộc vào \(m\). Vì nhà đầu tư sẽ nắm giữ bất cứ thứ gì mình mua hôm nay trong một thời gian, một tín hiệu dự báo lợi nhuận trong nhiều tháng có giá trị hơn một tín hiệu mạnh tương đương nhưng suy giảm trong vòng một tháng. Đây là nội dung kinh tế của “sự suy giảm alpha”, và đó chính xác là điều mà một danh mục không quan tâm đến chi phí hoặc một danh mục tĩnh có nhận thức về chi phí bỏ qua. Phương án tĩnh, giao dịch hướng về danh mục Markowitz của hôm nay đồng thời phạt lệnh giao dịch của hôm nay, có cùng dạng điều chỉnh từng phần nhưng nhắm sai mục tiêu.
12.10.3 Portfolio-ML: Học trực tiếp danh mục mục tiêu
Triển khai Phương trình 12.13 đòi hỏi dự báo lợi nhuận ở mọi kỳ hạn dự báo. Phương pháp ưa thích của các tác giả, Portfolio-ML, bỏ qua hoàn toàn việc dự báo lợi nhuận và học trực tiếp danh mục mục tiêu. Nó tham số hóa danh mục mục tiêu như một hàm tuyến tính của các đặc trưng Fourier ngẫu nhiên của các đặc điểm, được chia theo biến động của từng cổ phiếu,
một ý tưởng mở rộng các chính sách danh mục tham số của Brandt, Santa-Clara, và Valkanov (2009) sang bài toán động có chi phí giao dịch. Các đặc trưng ngẫu nhiên, \(\cos(s_{i,t}^\top w_j)\) và \(\sin(s_{i,t}^\top w_j)\) với các vectơ Gauss ngẫu nhiên \(w_j\), cho phép một mô hình tuyến tính xấp xỉ các hàm phi tuyến và các tương tác của các đặc điểm, một thủ thuật mà các tính chất thống kê của nó trong dự báo lợi nhuận được Kelly, Malamud, và Zhou (2024) nghiên cứu. Thay Phương trình 12.14 vào Phương trình 12.12 làm cho danh mục xấp xỉ tuyến tính theo \(\beta\), \(\pi_t \approx \Omega_t\beta\), trong đó \(\Omega_t\) là trung bình có trọng số theo độ bền vững và tăng trưởng của các tín hiệu đã chia thang hiện tại và quá khứ. Khi đó mức thỏa dụng thực hiện là hàm toàn phương theo \(\beta\), và các hệ số tối ưu có dạng đóng:
trong đó \(\Delta\Omega_t = \Omega_t - g_t\Omega_{t-1}\) là lệnh giao dịch hàm ý và \(\ell\) là hệ số phạt ridge. Số lượng đặc trưng ngẫu nhiên, thang đo của chúng và \(\ell\) được chọn mỗi năm bằng cách kiểm định theo cửa sổ mở rộng trên mức thỏa dụng thực hiện. Không cần lớp tinh chỉnh thứ hai, vì hàm mục tiêu dùng để khớp \(\beta\) chính là hàm mục tiêu của nhà đầu tư.
12.10.4 Bằng chứng
Ứng dụng sử dụng các cổ phiếu Mỹ có vốn hóa trên trung vị của NYSE, 115 đặc điểm từ Jensen, Kelly, và Pedersen (2023), một ma trận hiệp phương sai theo mô hình nhân tố theo phong cách của các mô hình rủi ro thương mại, và giai đoạn ngoài mẫu từ 1981 đến 2020 cho một nhà đầu tư có mức e ngại rủi ro 10 với quỹ tăng cùng thị trường lên 10 tỷ USD vào năm 2020. Các con số dưới đây lấy từ phiên bản bài báo thảo luận của các tác giả và có thể khác đôi chút so với bài báo đã xuất bản.
Học máy không quan tâm đến chi phí thì không thể triển khai được. Danh mục Markowitz xây dựng trên các dự báo lợi nhuận bằng học máy có tỷ số Sharpe gộp là 2.0 nhưng vòng quay cực đoan đến mức lợi nhuận sau chi phí âm một cách thảm hại. Một phép phân loại thập phân vị mua–bán khống chuẩn trên cùng các dự báo đó có vòng quay 260% vốn mỗi tháng và tỷ số Sharpe sau chi phí khoảng −12.
Nhận thức tĩnh về chi phí có ích nhưng đặt nhà đầu tư sai vị trí. Phương pháp tĩnh đạt tỷ số Sharpe sau chi phí dương là 0.94 nhưng mức thỏa dụng thực hiện âm, vì rủi ro thực hiện của nó vượt xa rủi ro ex ante.
Portfolio-ML vượt trội. Nó đạt tỷ số Sharpe sau chi phí là 1.38 với vòng quay hằng tháng là 32%, và mức thỏa dụng thực hiện cao nhất. Ngay cả sau khi các tác giả trao cho các phương pháp dựa trên dự báo một lớp tinh chỉnh thứ hai để điều chỉnh lại thang đo đầu vào của chúng, Portfolio-ML vẫn vượt chúng khoảng 20% về tỷ số Sharpe sau chi phí và 60% về mức thỏa dụng.
Chi phí làm thay đổi đặc điểm nào là quan trọng. Đo bằng mức thỏa dụng bị mất khi một đặc điểm bị loại bỏ, “tầm quan trọng kinh tế của đặc điểm” của đảo chiều ngắn hạn, vốn chi phối các mô hình không quan tâm đến chi phí, sụp đổ, còn giá trị và chất lượng trở thành các đặc điểm quan trọng nhất.
Mã nguồn tái lập của các tác giả được công khai (GitHub: theisij/ml-and-the-implementable-efficient-frontier). Mã được viết bằng R, đòi hỏi dữ liệu đặc điểm của Jensen, Kelly, và Pedersen (2023) từ WRDS, và được thiết kế cho một cụm máy tính. Kho mã không kèm giấy phép, vì vậy chúng tôi không sao chép lại ở đây. Mã dưới đây là một phần triển khai Portfolio-ML độc lập bằng Python, bám theo các công thức ở trên, được kiểm tra trước tiên trong một mô phỏng rồi áp dụng cho dữ liệu Việt Nam.
12.10.5 Một phần triển khai độc lập
Cốt lõi của phương pháp gồm năm hàm nhỏ: ma trận tốc độ giao dịch \(m\), việc chia thang tín hiệu, các đặc trưng Fourier ngẫu nhiên, ma trận trọng số hàm ý \(\Omega_t\), và các hệ số dạng đóng của Phương trình 12.15.
Core functions for Portfolio-ML
def trading_speed(sigma, lam, wealth, gamma, mu=0.007, rf=0.0, iters=10):""" Persistence matrix m in the optimal rule pi_t = m g_t pi_{t-1} + (I - m) A_t. Solves the fixed point m~ = (x + I + diag(G) - m~ o G)^{-1}, where x = (gamma / wealth) Lambda^{-1/2} Sigma Lambda^{-1/2}, G is the second moment of portfolio-weight growth, and m = Lambda^{-1/2} m~ Lambda^{1/2}. `lam` is the diagonal of the price-impact matrix (per unit of currency). """ n =len(lam) G = np.ones((n, n)) + sigma / (1+ rf + mu) **2 lam_isqrt =1/ np.sqrt(lam) x = (gamma / wealth) * sigma * np.outer(lam_isqrt, lam_isqrt)# Start from the closed-form solution when weight growth is deterministic ev, evec = np.linalg.eigh(x +2* np.eye(n)) m_tilde = evec @ np.diag(0.5* (ev - np.sqrt(ev**2-4))) @ evec.T base = x + np.eye(n) + np.diag(np.diag(G))for _ inrange(iters): m_tilde = np.linalg.inv(base - m_tilde * G)return lam_isqrt[:, None] * m_tilde * np.sqrt(lam)[None, :]def scale_signals(features, vol):"""Demean features, add a constant, give each column unit length, divide rows by volatility.""" f = features - features.mean(axis=0) f = np.column_stack([np.ones(len(f)), f]) f = f / np.sqrt((f**2).sum(axis=0))return f / vol[:, None]def random_fourier_features(features, W):"""Random Fourier features: cos and sin of random projections of the features.""" z = features @ Wreturn np.hstack([np.cos(z), np.sin(z)])def implied_weights(S_hist, g_hist, m):""" Omega_t such that the portfolio implied by aim coefficients beta is Omega_t beta. S_hist[i] is the scaled signal matrix i months ago (i = 0 is today); g_hist[i] is the weight-growth vector realized over the month ending i months ago. """ n = m.shape[0] M, num, den = np.eye(n), np.zeros_like(S_hist[0]), np.zeros((n, n))for i, S_i inenumerate(S_hist): num += M @ S_i den += Mif i <len(g_hist): M = M @ m @ np.diag(g_hist[i])return np.linalg.solve(den, num)def portfolio_ml_moments(S, g, ret, sigma, lam, m, wealth, gamma, months, lookback=11):"""Per-month ingredients of the Portfolio-ML objective: r_tilde_t and D_t.""" r_tilde, D = {}, {}for t in months: om = implied_weights([S[t - i] for i inrange(lookback +1)], [g[t -1- i] for i inrange(lookback)], m) om_prev = implied_weights([S[t -1- i] for i inrange(lookback +1)], [g[t -2- i] for i inrange(lookback)], m) trade = om - g[t -1][:, None] * om_prev r_tilde[t] = om.T @ ret[t] D[t] = gamma * om.T @ sigma @ om + wealth * trade.T @ (lam[:, None] * trade)return r_tilde, Ddef fit_aim_coefficients(r_tilde, D, months, ridge):"""Closed-form Portfolio-ML coefficients: (mean D + ridge I)^{-1} mean r_tilde.""" r_bar = np.mean([r_tilde[t] for t in months], axis=0) D_bar = np.mean([D[t] for t in months], axis=0)return np.linalg.solve(D_bar + ridge * np.eye(len(r_bar)), r_bar)def objective(beta, r_tilde, D, months):"""Average realized utility r_tilde' beta - beta' D beta / 2 over the given months."""return np.mean([r_tilde[t] @ beta -0.5* beta @ D[t] @ beta for t in months])
Để quan sát phương pháp hoạt động ở nơi ta biết sự thật, mô phỏng dưới đây tạo ra các thị trường gồm 60 cổ phiếu trong 35 năm với hai tín hiệu có độ bền vững rất khác nhau: một tín hiệu kiểu giá trị với tự tương quan hằng tháng là 0.97 và một tín hiệu kiểu đảo chiều độc lập từ tháng này sang tháng khác nhưng có khả năng dự báo gấp ba lần trên mỗi đơn vị. Thanh khoản thay đổi giữa các cổ phiếu ở mức ba bậc độ lớn, và nhà đầu tư có 10 tỷ USD và mức e ngại rủi ro 10. Bốn quy tắc cạnh tranh trong mười năm cuối. Ba trong số đó là các oracle (mô hình biết trước), tức biết lợi nhuận kỳ vọng thật: danh mục Markowitz không quan tâm đến chi phí, danh mục tĩnh có nhận thức về chi phí, và phương án tối ưu động của Phương trình 12.12 và Phương trình 12.13 được tính từ động học tín hiệu đã biết. Portfolio-ML không biết gì về quá trình sinh dữ liệu và học các hệ số của nó từ 25 năm đầu, một lần chỉ với hai tín hiệu và một lần với thêm thứ hạng thanh khoản cùng các tương tác của nó với các tín hiệu làm đặc trưng. Lặp lại bài tập trên 20 thị trường mô phỏng giúp tránh việc đọc quá nhiều vào một lần rút mẫu.
Simulated markets with persistent and fleeting signals
GAMMA, WEALTH, LOOKBACK =10.0, 1e10, 11N_STOCKS, N_MONTHS =60, 420TRAIN_END, TEST_START =240, 300RIDGES = [0.0] +list(np.exp(np.arange(-10, 7)))def simulate_market(seed, phi_slow=0.97, b_slow=0.04, b_fast=0.12):"""A market with one persistent and one fleeting signal and heterogeneous liquidity.""" rng = np.random.default_rng(seed) n, T = N_STOCKS, N_MONTHS dolvol = np.exp(rng.normal(np.log(2e8), 1.2, n)) # daily dollar volume lam =0.2/ dolvol # 1% of volume moves price 0.1% beta_mkt = rng.uniform(0.6, 1.4, n) ivol = rng.uniform(0.06, 0.12, n) sigma =0.045**2* np.outer(beta_mkt, beta_mkt) + np.diag(ivol**2) vol = np.sqrt(np.diag(sigma)) s_slow = np.zeros((T, n)) s_slow[0] = rng.normal(size=n)for t inrange(1, T): s_slow[t] = phi_slow * s_slow[t -1] + np.sqrt(1- phi_slow**2) * rng.normal(size=n) s_fast = rng.normal(size=(T, n)) mu = vol * (b_slow * s_slow + b_fast * s_fast) # E_t[r_{t+1}] ret = mu + (np.linalg.cholesky(sigma) @ rng.normal(size=(n, T))).T g = (1+ ret) / (1+ ret.mean(axis=1))[:, None] # weight growth liq = (np.argsort(np.argsort(dolvol)) +0.5) / n -0.5returndict(lam=lam, sigma=sigma, vol=vol, s_slow=s_slow, s_fast=s_fast, mu=mu, ret=ret, g=g, liq=liq, phi_slow=phi_slow, b_slow=b_slow, b_fast=b_fast)def backtest(mkt, weight_rule, months):"""Realized gross return, trading cost, and turnover of a weight rule.""" rows, w_prev = [], np.zeros(N_STOCKS)for t in months: w_start = mkt["g"][t -1] * w_prev w = weight_rule(t, w_start) trade = w - w_start rows.append({"gross": w @ mkt["ret"][t],"tc": 0.5* WEALTH * np.sum(mkt["lam"] * trade**2),"turnover": np.abs(trade).sum()}) w_prev = w out = pd.DataFrame(rows) out["net"] = out["gross"] - out["tc"]return outdef run_one(seed): mkt = simulate_market(seed) T, n = N_MONTHS, N_STOCKS sigma, lam, vol = mkt["sigma"], mkt["lam"], mkt["vol"] m = trading_speed(sigma, lam, WEALTH, GAMMA) I_n, I_m = np.eye(n), np.eye(n) - m sig_inv = np.linalg.inv(sigma)# Oracle benchmarks that know the true expected returns markowitz = np.einsum("ij,tj->ti", sig_inv, mkt["mu"]) / GAMMA static_inv = np.linalg.inv(GAMMA * sigma + WEALTH * np.diag(lam)) mk_fast = np.einsum("ij,tj->ti", sig_inv, vol * mkt["b_fast"] * mkt["s_fast"]) / GAMMA mk_slow = np.einsum("ij,tj->ti", sig_inv, vol * mkt["b_slow"] * mkt["s_slow"]) / GAMMA slow_boost = np.linalg.solve(I_n - mkt["phi_slow"] * m, I_m) rules = {"Markowitz (oracle, cost-agnostic)": lambda t, w0: markowitz[t],"Static (oracle)": lambda t, w0: static_inv @ (mkt["mu"][t] + WEALTH * lam * w0),"Dynamic optimum (oracle)":lambda t, w0: m @ w0 + I_m @ (I_m @ mk_fast[t] + slow_boost @ mk_slow[t]), }# Portfolio-ML learned from data, with and without liquidity interactions liq = np.tile(mkt["liq"], (T, 1)) feature_sets = {"Portfolio-ML: signals only": np.stack([mkt["s_slow"], mkt["s_fast"]], axis=2),"Portfolio-ML: signals x liquidity": np.stack( [mkt["s_slow"], mkt["s_fast"], liq, mkt["s_slow"] * liq, mkt["s_fast"] * liq], axis=2), } months_fit =range(LOOKBACK +2, T -1) train = [t for t in months_fit if t < TRAIN_END] valid = [t for t in months_fit if TRAIN_END <= t < TEST_START]for name, feats in feature_sets.items(): S = np.stack([scale_signals(feats[t], vol) for t inrange(T)]) r_tilde, D = portfolio_ml_moments(S, mkt["g"], mkt["ret"], sigma, lam, m, WEALTH, GAMMA, months_fit, LOOKBACK) ridge =max(RIDGES, key=lambda l: objective( fit_aim_coefficients(r_tilde, D, train, l), r_tilde, D, valid)) beta = fit_aim_coefficients(r_tilde, D, train + valid, ridge) rules[name] = (lambda S_, b_: lambda t, w0: m @ w0 + I_m @ (S_[t] @ b_))(S, beta) test =list(range(TEST_START, T -1)) rows = []for name, rule in rules.items(): bt = backtest(mkt, rule, test) rows.append({"seed": seed, "Method": name,"Net Sharpe": np.sqrt(12) * bt["net"].mean() / bt["net"].std(),"Trading cost": 12* bt["tc"].mean(),"Utility": 12* (bt["net"].mean() -0.5* GAMMA * bt["net"].var()),"Turnover": bt["turnover"].mean()})return pd.DataFrame(rows)sim_results = pd.concat([run_one(seed) for seed inrange(20)], ignore_index=True)
Hiện mã
order = ["Markowitz (oracle, cost-agnostic)", "Static (oracle)","Portfolio-ML: signals only", "Portfolio-ML: signals x liquidity","Dynamic optimum (oracle)"]summary = (sim_results.groupby("Method")[["Net Sharpe", "Trading cost", "Utility", "Turnover"]] .median().loc[order])wide = sim_results.pivot(index="seed", columns="Method", values="Utility")summary["Beats static oracle (share of seeds)"] = [ (wide[name] > wide["Static (oracle)"]).mean() for name in order]summary.round(3)
Bảng 12.3: Hiệu quả ngoài mẫu trong các thị trường mô phỏng: Trung vị trên 20 thị trường (mức e ngại rủi ro 10, tài sản 10 tỷ)
Net Sharpe
Trading cost
Utility
Turnover
Beats static oracle (share of seeds)
Method
Markowitz (oracle, cost-agnostic)
-10.589
350.772
-6738.143
10.627
0.00
Static (oracle)
0.662
0.011
0.021
0.114
0.00
Portfolio-ML: signals only
0.825
0.001
0.017
0.029
0.55
Portfolio-ML: signals x liquidity
0.912
0.004
0.024
0.064
0.60
Dynamic optimum (oracle)
0.956
0.022
0.045
0.150
0.85
Mô phỏng tái hiện logic của bài báo ở quy mô thu nhỏ. Danh mục Markowitz không quan tâm đến chi phí bị phá hủy bởi chính hoạt động giao dịch của nó dù nó biết lợi nhuận kỳ vọng thật. Phương án tối ưu động đánh bại oracle tĩnh trong hầu hết các thị trường mặc dù cả hai đều có cùng các dự báo một tháng hoàn hảo: nhắm vào danh mục Markowitz của hôm nay bỏ qua tính bền vững của tín hiệu kiểu giá trị và trả giá quá cao để đuổi theo tín hiệu kiểu đảo chiều. Portfolio-ML, vốn phải học mọi thứ từ dữ liệu, đạt tỷ số Sharpe sau chi phí cao hơn oracle tĩnh với vòng quay chỉ bằng khoảng một nửa, và mức thỏa dụng tương đương. Nó thua phương án tối ưu động vì nó ước lượng có nhiễu những gì oracle đã biết.
Hai biến thể Portfolio-ML đem lại một bài học về các đặc trưng. Danh mục mục tiêu tối ưu đối xử với cổ phiếu thanh khoản và kém thanh khoản một cách khác nhau: nó giao dịch hướng theo tín hiệu nhanh ở các cổ phiếu thanh khoản và chậm ở các cổ phiếu kém thanh khoản, và sẵn sàng nắm giữ các tín hiệu bền vững ở các cổ phiếu kém thanh khoản nhưng không nắm giữ các tín hiệu thoáng qua. Một quy tắc chỉ tuyến tính theo các tín hiệu không thể biểu diễn điều này, nên nó chỉ có thể thu nhỏ mọi thứ, biểu hiện là vòng quay rất thấp và mức thỏa dụng thấp hơn. Thêm thanh khoản và các tương tác của nó sẽ khôi phục phần lớn khoảng cách. Trong bài báo, các đặc trưng Fourier ngẫu nhiên của 115 đặc điểm, một số trong đó đo thanh khoản, tự động sinh ra các tương tác như vậy.
Khi áp dụng phương pháp cho cổ phiếu Việt Nam, chúng ta sử dụng tập hợp đầu tư thanh khoản và bộ ước lượng hiệp phương sai Ledoit-Wolf đã định nghĩa ở phần trước của chương này, bảng đặc điểm từ chương xây dựng nhân tố, và tác động giá được hiệu chỉnh theo giá trị giao dịch trung bình hằng ngày của từng cổ phiếu tính bằng VND. Mã không được chạy ở đây: việc tìm kiếm siêu tham số phải khớp lại mô hình cho mọi tổ hợp của số đặc trưng ngẫu nhiên, thang đo đặc trưng và hệ số phạt ridge, và mất vài phút cho mỗi cặp tài sản và mức e ngại rủi ro.
Characteristics and wide panels for Portfolio-ML
# Characteristics panel from the factor-construction chaptercharacteristics = client.get_characteristics_panel( exchanges=["HOSE", "HNX"], start_date="2008-01-01", end_date="2024-12-31", include_delisted=True,)char_cols = [c for c in characteristics.columns if c notin ["ticker", "month_end"]]# Rank-standardize each characteristic to [0, 1] within month; missing -> 0.5ranked = characteristics.copy()ranked[char_cols] = (ranked.groupby("month_end")[char_cols] .rank(pct=True).fillna(0.5))# Wide month x ticker panels on the liquid universeret_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="monthly_return").sort_index()adv_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="turnover_value_avg_20d").reindex(ret_wide.index)mcap_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="market_cap").reindex(ret_wide.index)char_wide = {c: ranked.pivot(index="month_end", columns="ticker", values=c) .reindex(index=ret_wide.index, columns=ret_wide.columns)for c in char_cols}# Value-weighted market return drives the growth of portfolio weightslag_mcap = mcap_wide.shift(1).where(ret_wide.notna())mkt_ret = (ret_wide * lag_mcap).sum(axis=1) / lag_mcap.sum(axis=1)months = ret_wide.index
Monthly universe, covariance, and price impact
LOOKBACK =11_base_cache = {}def base_state(i, lookback=LOOKBACK):""" Universe, covariance, and price impact at month i (no look-ahead). A stock is investable if it has returns over the last lookback + 2 months. Next month's return is used only to evaluate the decision; a missing value (for example, a delisting) is recorded as zero. """if i in _base_cache:return _base_cache[i] window = ret_wide.iloc[i - lookback -1:i +1] tickers = window.columns[window.notna().all()] cov, cov_tickers = estimate_covariance(universe_liquid, months[i]) state =Noneif cov isnotNone: adv = adv_wide.iloc[i] tickers = [t for t in tickers if t in cov_tickers and adv.get(t, 0) >0]iflen(tickers) >=30: sigma = cov.loc[tickers, tickers].values# growth[k] = growth of portfolio weights over month i - k growth = ((1+ window[tickers].values)/ (1+ mkt_ret.iloc[i - lookback -1:i +1].values)[:, None])[::-1] state =dict(tickers=tickers, sigma=sigma, vol=np.sqrt(np.diag(sigma)), lam=0.2/ adv[tickers].values, # 1% of ADV moves price 0.1% growth=growth, ret_next=ret_wide.iloc[i +1][tickers].fillna(0).values) _base_cache[i] = statereturn statedef month_signals(i, tickers, vol, W, lookback=LOOKBACK):"""Scaled random-feature signals for months i, i-1, ..., i-lookback-1.""" out = []for lag inrange(lookback +2): X = np.column_stack([char_wide[c].iloc[i - lag][tickers].fillna(0.5).valuesfor c in char_cols]) out.append(scale_signals(random_fourier_features(X, W), vol))return out
Tuning and out-of-sample backtest of Portfolio-ML
def run_portfolio_ml(wealth, gamma, first_val_year=2017, first_test_year=2020, p_grid=(16, 32, 64), g_grid=(np.exp(-3), np.exp(-2)), ridges=(0.0, *np.exp(np.arange(-10, 7))), cost_mult=1.0, long_only=False, seed=1, lookback=LOOKBACK):"""Portfolio-ML with yearly expanding-window tuning and an out-of-sample backtest.""" rng = np.random.default_rng(seed) states = {}for i inrange(lookback +2, len(months) -1): st = base_state(i, lookback)if st isNone:continue lam = cost_mult * st["lam"] m = trading_speed(st["sigma"], lam, wealth, gamma, mu=mkt_ret.iloc[:i].mean()) states[i] = {**st, "lam": lam, "m": m} year = {i: months[i].year for i in states} last_year =max(year.values())# Step 1: objective ingredients for every (p, g) and month candidates = {}for p in p_grid:for g_rff in g_grid: W = rng.normal(0, np.sqrt(g_rff), size=(len(char_cols), p //2)) r_tilde, D, S_now = {}, {}, {}for i, st in states.items(): S_hist = month_signals(i, st["tickers"], st["vol"], W, lookback) om = implied_weights(S_hist[:lookback +1], st["growth"][:lookback], st["m"]) om_prev = implied_weights(S_hist[1:], st["growth"][1:lookback +1], st["m"]) trade = om - st["growth"][0][:, None] * om_prev r_tilde[i] = om.T @ st["ret_next"] D[i] = (gamma * om.T @ st["sigma"] @ om+ wealth * trade.T @ (st["lam"][:, None] * trade)) S_now[i] = S_hist[0]# Step 2: expanding-window validation, one year at a timefor l in ridges: val = {}for y inrange(first_val_year, last_year +1): fit_m = [i for i in states if year[i] < y] eval_m = [i for i in states if year[i] == y]if fit_m and eval_m: beta = fit_aim_coefficients(r_tilde, D, fit_m, l) val[y] = objective(beta, r_tilde, D, eval_m) candidates[(p, g_rff, l)] =dict(r=r_tilde, D=D, S=S_now, val=val)# Step 3: each test year, use the best hyperparameters so far and trade rows, w_prev, lam_prev = [], pd.Series(dtype=float), pd.Series(dtype=float)for y inrange(first_test_year, last_year +1): best =max(candidates, key=lambda h: np.mean( [v for yy, v in candidates[h]["val"].items() if yy < y] or [-np.inf])) c = candidates[best] beta = fit_aim_coefficients(c["r"], c["D"], [i for i in states if year[i] < y], best[2])for i in [i for i in states if year[i] == y]: st, tick = states[i], states[i]["tickers"] w_start = w_prev.reindex(tick).fillna(0).values # already grown aim = c["S"][i] @ beta w = st["m"] @ w_start + (np.eye(len(tick)) - st["m"]) @ aimif long_only: w = np.clip(w, 0, None) trade = w - w_start exits = w_prev.index.difference(tick) # forced liquidations tc = (0.5* wealth * np.sum(st["lam"] * trade**2)+0.5* wealth * np.sum(lam_prev.reindex(exits).fillna(0).values* w_prev.reindex(exits).fillna(0).values**2)) rows.append({"month": months[i +1], "gross": w @ st["ret_next"], "tc": tc,"turnover": np.abs(trade).sum(), "leverage": np.abs(w).sum(),"p": best[0], "ridge": best[2]}) w_prev = pd.Series(w * (1+ st["ret_next"]) / (1+ mkt_ret.iloc[i +1]), index=tick) lam_prev = pd.Series(st["lam"], index=tick) bt = pd.DataFrame(rows) bt["net"] = bt["gross"] - bt["tc"]return btdef summarize(bt, gamma):return pd.Series({"Gross return": 12* bt["gross"].mean(),"Trading cost": 12* bt["tc"].mean(),"Net return": 12* bt["net"].mean(),"Volatility": np.sqrt(12) * bt["net"].std(),"Net Sharpe": np.sqrt(12) * bt["net"].mean() / bt["net"].std(),"Utility": 12* (bt["net"].mean() -0.5* gamma * bt["net"].var()),"Turnover": bt["turnover"].mean(),"Leverage": bt["leverage"].mean(), })
Bảng 12.4: Đường biên hiệu quả có thể triển khai cho cổ phiếu Việt Nam: Portfolio-ML theo quy mô quỹ và mức e ngại rủi ro
Hiện mã
# Points on the implementable efficient frontier for three fund sizesfrontier = {}for wealth in [1e11, 1e12, 1e13]: # VND 100 billion, 1 trillion, 10 trillionfor gamma in [5, 10, 20]:for long_only in [False, True]: bt = run_portfolio_ml(wealth, gamma, long_only=long_only) frontier[(f"{wealth /1e9:,.0f}bn", gamma,"long-only"if long_only else"long-short")] = summarize(bt, gamma)frontier = pd.DataFrame(frontier).Tfrontier.index.names = ["Wealth (VND)", "Risk aversion", "Positions"]frontier.round(3)
12.10.6 Ý nghĩa đối với nghiên cứu về Việt Nam
Lập luận ủng hộ phương pháp này ở Việt Nam mạnh hơn so với tập hợp cổ phiếu vốn hóa lớn của Mỹ mà các tác giả nghiên cứu, vì các lực mà nó được thiết kế để xử lý ở đây lớn hơn: thanh khoản tập trung vào vài chục cổ phiếu (Mục 12.2), và nhiều hiện tượng bất thường được ghi nhận cho cổ phiếu Việt Nam nằm chính ở các cổ phiếu nhỏ, kém thanh khoản, nơi chi phí có tác động mạnh. Cần có một số điều chỉnh.
Không có bán khống. Portfolio-ML như được đặc tả là một danh mục mua–bán khống, và ở Việt Nam nó là một đối tượng nghiên cứu chứ không phải một quỹ: nó đo xem đặc điểm nào mang lại giá trị sau chi phí và đường biên co lại như thế nào theo quy mô. Biến thể chỉ mua trong mã đơn giản đặt các trọng số âm về không sau mỗi lần tái cân bằng. Đó là một phép chiếu mang tính kinh nghiệm, không phải nghiệm tối ưu có ràng buộc. Một phiên bản được ràng buộc đúng đắn sẽ tối đa hóa cùng hàm mục tiêu bằng phương pháp số với \(\pi_t \geq 0\), hoặc làm việc với các trọng số chủ động quanh một chuẩn so sánh như VN30, trong đó việc nắm giữ thấp hơn trọng số chuẩn (mức thấp hơn tối đa bằng chính trọng số chuẩn) thay thế cho vị thế bán khống.
Hiệu chỉnh tác động giá cho Việt Nam và kiểm tra áp lực. Hiệu chỉnh của Mỹ (tác động 0.1% cho 1% khối lượng hằng ngày) gần như chắc chắn đánh giá thấp tác động đối với các cổ phiếu vốn hóa trung bình của Việt Nam. Hãy báo cáo kết quả với các hệ số nhân chi phí 1, 2 và 4 (đối số cost_mult), và so sánh các chi phí hàm ý với mô hình chi phí giao dịch trong Phương trình 12.8.
Mô hình thiếu các chi phí tỷ lệ. Phí môi giới và thuế bán 0.1% là tuyến tính theo quy mô lệnh (Bảng 12.2), và các chi phí tuyến tính tạo ra một vùng không giao dịch mà mô hình toàn phương không thể tạo ra. Ít nhất hãy trừ chúng trong kiểm định ngược; coi một kết quả chỉ tồn tại khi không có chúng là không thể triển khai.
Tôn trọng các quy tắc của thị trường. Biên độ giá hằng ngày giới hạn lượng vị thế có thể giao dịch trong một ngày khi cổ phiếu biến động mạnh, và giới hạn sở hữu nước ngoài khiến một số cổ phiếu không thể mua được đối với nhà đầu tư nước ngoài. Với phiên bản dành cho nhà đầu tư nước ngoài, hãy loại các cổ phiếu đang chạm giới hạn khỏi phía mua.
Mẫu ngắn. Với các đặc điểm từ năm 2008, cửa sổ hiệp phương sai 60 tháng và kiểm định theo năm, chỉ một vài năm kiểm định cung cấp thông tin cho việc chọn siêu tham số. Hãy giữ số đặc trưng ngẫu nhiên ở mức nhỏ, để hệ số phạt ridge phát huy tác dụng, và báo cáo mức độ nhạy của kết quả với hạt giống ngẫu nhiên dùng để rút các đặc trưng.
Dùng tầm quan trọng kinh tế của đặc điểm làm kiểm định hiện tượng bất thường. Việc loại bỏ từng đặc điểm một và đo mức giảm thỏa dụng sau chi phí sẽ trả lời câu hỏi mà vườn thú nhân tố (factor zoo, Mục 21.3) không trả lời được: hiện tượng bất thường nào của Việt Nam đáng để giao dịch đối với một nhà đầu tư có quy mô nhất định sau khi đã trả chi phí. Câu trả lời sẽ khác nhau theo quy mô quỹ, và đường biên trên các mức 100 tỷ, 1 nghìn tỷ và 10 nghìn tỷ VND cho thấy tập hợp cơ hội thu hẹp nhanh đến mức nào về phía các cổ phiếu thanh khoản nhất.
12.11 Hướng dẫn thực tiễn cho Việt Nam
Phân tích ở trên đưa ra một số khuyến nghị thực tiễn cho các nhà nghiên cứu và nhà đầu tư làm việc với cổ phiếu Việt Nam:
Đối với nghiên cứu nhân tố học thuật: Danh mục VW vẫn là mặc định cho các kiểm định định giá tài sản vì chúng đại diện cho tập hợp cơ hội có thể đầu tư và tránh làm thổi phồng các ước lượng alpha bằng phần bù phi thanh khoản của cổ phiếu nhỏ. Khi dùng danh mục EW (ví dụ để mỗi cổ phiếu có ảnh hưởng ngang nhau trong các phép phân loại theo mặt cắt ngang), nhà nghiên cứu nên báo cáo cả kết quả VW và EW và thảo luận về độ nhạy. Fama và French (2008) tuân theo thông lệ này một cách có hệ thống.
Đối với quản lý quỹ: Lựa chọn phụ thuộc vào AUM và ủy thác đầu tư. Ở mức AUM dưới 500 tỷ VND, VW có giới hạn trần hoặc trọng số theo yếu tố cơ bản đem lại một sự dung hòa thực tiễn giữa đa dạng hóa và khả năng triển khai. Ở mức AUM lớn hơn, VW thuần túy hoặc VW có giới hạn trần theo ngành thực tế hơn. Ngang bằng rủi ro và phương sai tối thiểu phù hợp với các ủy thác biến động thấp nhưng đòi hỏi ước lượng hiệp phương sai vững và tái cân bằng hằng quý.
Đối với xây dựng chỉ số: Các nhà cung cấp chỉ số của Việt Nam (VN30, VNINDEX) dùng các biến thể của VW có giới hạn trần. Phân tích gợi ý rằng mức trần ảnh hưởng đáng kể đến đặc tính đa dạng hóa và sai số bám sát của chỉ số so với thị trường VW không giới hạn. Mức trần 10% cân bằng giữa việc giảm tập trung và vòng quay.
Đối với quản lý chi phí giao dịch: Ở mọi phương pháp, lợi ích biên của việc tái cân bằng giảm nhanh hơn chi phí biên khi tần suất vượt quá hằng quý. Tái cân bằng hằng quý theo lịch hoặc tái cân bằng dựa trên ngưỡng (với dải dung sai 1–2%) mang lại sự đánh đổi chi phí–lợi ích tốt nhất trên thị trường Việt Nam.
12.12 Tóm tắt
Bảng 12.5: So sánh tóm tắt các phương pháp xác định trọng số đối với cổ phiếu Việt Nam.
Khía cạnh
VW
EW
VW có trần
Theo yếu tố cơ bản
Phương sai tối thiểu
Ngang bằng rủi ro
Vòng quay
Rất thấp
Cao
Thấp
Thấp
Trung bình
Trung bình
Mức tập trung
Cao
Không
Trung bình
Trung bình
Thay đổi
Thấp
Thiên lệch quy mô
Lớn
Nhỏ
Trung bình
Lớn–trung bình
Biến động thấp
Hỗn hợp
Dữ liệu cần thiết
Giá
Không
Giá
Kế toán
Hiệp phương sai lợi nhuận
Hiệp phương sai lợi nhuận
Độ nhạy theo quy mô
Thấp
Cao
Thấp
Thấp
Trung bình
Trung bình
Tần suất tái cân bằng
Thụ động
Hằng tháng
Hằng tháng/Hằng quý
Hằng năm
Hằng quý
Hằng quý
Trường hợp sử dụng tốt nhất
Chuẩn so sánh, AUM lớn
Kiểm định theo mặt cắt ngang
Bám sát chỉ số
Đầu tư dài hạn
Ủy thác biến động thấp
Rủi ro cân bằng
Việc lựa chọn phương pháp xác định trọng số không chỉ là một chi tiết kỹ thuật, mà phản ánh một quyết định kinh tế thực chất về tầm quan trọng tương đối của đa dạng hóa, khả năng đầu tư và kiểm soát chi phí. Trên thị trường Việt Nam, nơi phân phối vốn hóa lệch mạnh và thanh khoản của cổ phiếu vốn hóa nhỏ mỏng, lựa chọn này có hệ quả lớn hơn so với các thị trường phát triển. Những nhà nghiên cứu chỉ báo cáo kết quả theo một phương pháp xác định trọng số duy nhất có nguy cơ rút ra các kết luận đặc thù cho phương pháp đó thay vì phản ánh một mối quan hệ kinh tế thực sự.
Amihud, Yakov. 2002. “Illiquidity and stock returns: cross-section and time-series effects”. Journal of financial markets 5 (1): 31–56. https://doi.org/10.1016/S1386-4181(01)00024-6.
Arnott, Robert D, Jason Hsu, và Philip Moore. 2005. “Fundamental indexation”. Financial Analysts Journal 61 (2): 83–99.
Brandt, Michael W., Pedro Santa-Clara, và Rossen Valkanov. 2009. “Parametric portfolio policies: Exploiting characteristics in the cross-section of equity returns”. The Review of Financial Studies 22 (9): 3411–47. https://doi.org/10.1093/rfs/hhp003.
Choueifaty, Yves. 2008. “Towards maximum diversification”. Available at SSRN 4063676.
Clarke, Roger, Harindra De Silva, và Steven Thorley. 2011. “Minimum-variance portfolio composition”. Journal of Portfolio Management 37 (2): 31.
DeMiguel, Victor, Lorenzo Garlappi, và Raman Uppal. 2009. “Optimal versus naive diversification: How inefficient is the 1/N portfolio strategy?”The review of Financial studies 22 (5): 1915–53.
Fama, Eugene F, và Kenneth R French. 2008. “Dissecting anomalies”. The journal of finance 63 (4): 1653–78.
Gârleanu, Nicolae, và Lasse Heje Pedersen. 2013. “Dynamic trading with predictable returns and transaction costs”. The Journal of Finance 68 (6): 2309–40.
Hsu, Jason C. 2006. “Cap-weighted portfolios are sub-optimal portfolios”. Journal of investment Management 4 (3).
Jagannathan, Ravi, và Tongshu Ma. 2003. “Risk reduction in large portfolios: Why imposing the wrong constraints helps”. The journal of finance 58 (4): 1651–83.
Jensen, Theis Ingerslev, Bryan Kelly, Semyon Malamud, và Lasse Heje Pedersen. 2026. “Machine learning and the implementable efficient frontier”. The Review of Financial Studies 39 (10): 3035–78. https://doi.org/10.1093/rfs/hhag022.
Jensen, Theis Ingerslev, Bryan Kelly, và Lasse Heje Pedersen. 2023. “Is there a replication crisis in finance?”The Journal of Finance 78 (5): 2465–2518.
Kelly, Bryan, Semyon Malamud, và Kangying Zhou. 2024. “The virtue of complexity in return prediction”. The Journal of Finance 79 (1): 459–503. https://doi.org/10.1111/jofi.13298.
Ledoit, Olivier, và Michael Wolf. 2004. “A well-conditioned estimator for large-dimensional covariance matrices”. Journal of multivariate analysis 88 (2): 365–411.
Maillard, Sébastien, Thierry Roncalli, và Jérôme Teı̈letche. 2010. “The properties of equally weighted risk contribution portfolios”. Journal of portfolio management 36 (4): 60.
Plyakha, Yuliya, Raman Uppal, và Grigory Vilkov. 2021. “Equal or value weighting? Implications for asset-pricing tests”. Trong Financial risk management and modeling, 295–347. Springer.
# Trọng số danh mục đầu tư và tái cân bằng danh mục::: callout-noteTrong chương này, chúng ta so sánh một cách có hệ thống các phương pháp xác định trọng số danh mục đầu tư (ví dụ: trọng số theo vốn hóa, trọng số bằng nhau và một số phương pháp thay thế dựa trên rủi ro) trên thị trường chứng khoán Việt Nam. Chúng ta định lượng tác động của tần suất tái cân bằng danh mục và chi phí giao dịch đến hiệu quả đầu tư thực tế, đồng thời xây dựng các công cụ thực tiễn để lập danh mục có thể triển khai được trong điều kiện các ma sát đặc trưng của một thị trường mới nổi.:::Mọi quyết định xây dựng danh mục đầu tư rốt cuộc đều quy về hai lựa chọn: nắm giữ những tài sản nào, và phân bổ bao nhiêu cho mỗi tài sản. Trong khi các chương trước tập trung vào câu hỏi thứ nhất, tức là dùng các mô hình nhân tố, các hiện tượng bất thường và phân tích cơ bản để chọn cổ phiếu, thì chương này giải quyết câu hỏi thứ hai. Phương pháp xác định trọng số mà nhà nghiên cứu hay nhà đầu tư áp dụng có thể làm thay đổi căn bản các kết luận rút ra từ kiểm định ở cấp độ danh mục đầu tư cũng như lợi nhuận thu được từ một chiến lược đầu tư.Sự khác biệt này quan trọng ở Việt Nam hơn so với các thị trường lớn và có tính thanh khoản cao. Thị trường chứng khoán Việt Nam có phân phối vốn hóa thị trường lệch cực mạnh: 10 doanh nghiệp lớn nhất trên HOSE chiếm khoảng 50% tổng vốn hóa thị trường, trong khi hàng trăm doanh nghiệp nhỏ đóng góp tỷ trọng không đáng kể. Với trọng số theo vốn hóa, hiệu quả của danh mục bị chi phối bởi một số ít cổ phiếu vốn hóa lớn (Vinhomes, Vingroup, Vietcombank, FPT). Với trọng số bằng nhau, mọi doanh nghiệp đóng góp như nhau, khiến danh mục nghiêng về các cổ phiếu nhỏ, kém thanh khoản, vốn có thể tốn kém hoặc không thể giao dịch ở quy mô lớn. Không phương pháp nào đúng một cách tuyệt đối; sự lựa chọn phụ thuộc vào câu hỏi đang được đặt ra.Chương này xây dựng khung phân tích để đưa ra lựa chọn đó. Chúng ta bắt đầu với các tính chất lý thuyết của các phương pháp xác định trọng số, triển khai từng phương pháp trên dữ liệu Việt Nam, định lượng chi phí giao dịch của việc tái cân bằng, và mở rộng phân tích sang các phương pháp thay thế dựa trên rủi ro, vốn tính đến một cách tường minh cấu trúc hiệp phương sai của lợi nhuận.## Khung lý thuyết {#sec-weight-theory}### Danh mục trọng số theo vốn hóaDanh mục trọng số theo vốn hóa (VW) phân bổ cho mỗi cổ phiếu một tỷ trọng tỷ lệ thuận với vốn hóa thị trường của nó:$$w_{i,t}^{VW} = \frac{\text{MCap}_{i,t}}{\sum_{j=1}^{N_t} \text{MCap}_{j,t}}$$ {#eq-vw}trong đó $\text{MCap}_{i,t} = P_{i,t} \times \text{Shares}_{i,t}$ là vốn hóa thị trường của cổ phiếu $i$ tại thời điểm $t$.Danh mục VW có một vị thế lý thuyết đặc biệt: đó là danh mục mà tất cả các nhà đầu tư cùng nắm giữ (tức "danh mục thị trường" trong CAPM). Các tính chất chính của nó là:1. **Tự tái cân bằng.** Khi giá thay đổi, các trọng số tự điều chỉnh. Danh mục VW chỉ cần giao dịch khi các cổ phiếu cấu thành được thêm vào hoặc loại khỏi chỉ số, hoặc khi các sự kiện doanh nghiệp (corporate actions: chia tách, phát hành thêm) làm thay đổi số cổ phiếu đang lưu hành.2. **Tỷ lệ vòng quay thấp.** Vì các trọng số trôi theo giá thay vì được đặt lại về mục tiêu, danh mục VW có chi phí tái cân bằng tối thiểu.3. **Thiên lệch về vốn hóa lớn.** Lợi nhuận bị chi phối bởi các doanh nghiệp lớn nhất. Ở Việt Nam, điều này có nghĩa là hồ sơ rủi ro–lợi nhuận của danh mục chịu ảnh hưởng nặng nề từ ngân hàng, bất động sản và các tập đoàn công nghệ.@hsu2004cap lập luận rằng các danh mục VW là dưới mức tối ưu vì chúng, một cách máy móc, nắm giữ quá nhiều cổ phiếu bị định giá cao và quá ít cổ phiếu bị định giá thấp (tức là, bất kỳ sự chênh lệch nào của giá so với giá trị cơ bản đều tạo ra một lực cản mang tính hệ thống đối với hiệu quả của danh mục VW so với phương án xác định trọng số theo yếu tố cơ bản).### Danh mục trọng số bằng nhauDanh mục trọng số bằng nhau (EW) gán cùng một trọng số cho mỗi cổ phiếu cấu thành:$$w_{i,t}^{EW} = \frac{1}{N_t}$$ {#eq-ew}@demiguel2009optimal chỉ ra rằng danh mục 1/N cạnh tranh được một cách đáng ngạc nhiên với các danh mục tối ưu hóa trung bình–phương sai, đặc biệt khi các cửa sổ ước lượng ngắn và số lượng tài sản lớn (những điều kiện mô tả sát thị trường Việt Nam). Trực giác là sai số ước lượng của lợi nhuận kỳ vọng và hiệp phương sai có thể lấn át lợi ích từ việc tối ưu hóa, khiến phương pháp trọng số bằng nhau "ngây thơ" trở thành một mặc định vững chắc.@plyakha2021equal phân tách phần vượt trội của EW so với VW thành hai thành phần:1. **Thiên lệch quy mô.** EW phân bổ nhiều hơn cho các doanh nghiệp nhỏ, vốn trong lịch sử đem lại phần bù quy mô.2. **Phần thưởng tái cân bằng.** Việc tái cân bằng hằng tháng về trọng số bằng nhau là một chiến lược đầu tư ngược xu hướng: bán những cổ phiếu thắng cuộc gần đây và mua những cổ phiếu thua cuộc gần đây, thu lợi từ sự hồi quy về giá trị trung bình trong lợi nhuận của từng cổ phiếu.<!-- 3. **Diversification.** EW provides lower concentration and higher effective diversification. -->Tuy nhiên, danh mục EW có những bất lợi thực tiễn đặc biệt nghiêm trọng ở Việt Nam:- **Tỷ lệ vòng quay cao.** Mỗi thời điểm tái cân bằng đều đòi hỏi phải giao dịch mọi cổ phiếu để đưa về trọng số bằng nhau.- **Mức độ phơi nhiễm với tính kém thanh khoản.** Việc đặt trọng số bằng nhau cho các cổ phiếu vốn hóa siêu nhỏ chỉ giao dịch 100 triệu VND/ngày bên cạnh các cổ phiếu vốn hóa lớn giao dịch 500 tỷ VND/ngày gây ra những khó khăn nghiêm trọng khi triển khai.- **Tác động giá.** Trong một thị trường có biên độ giá hằng ngày ($\pm$ 7% trên HOSE, $\pm$ 10% trên HNX), các lệnh tái cân bằng đối với những cổ phiếu kém thanh khoản có thể chạm giá trần hoặc giá sàn, khiến lệnh không thể khớp đầy đủ.### Phổ các phương pháp xác định trọng sốGiữa VW và EW là một dải liên tục các phương pháp xác định trọng số. @tbl-weight-schemes tóm tắt các phương án chính.| Phương pháp | Công thức trọng số | Tính chất chính | Rủi ro chính ||------------------|------------------|------------------|------------------|| Trọng số theo vốn hóa | $w_i \propto \text{MCap}_i$ | Tự tái cân bằng, vòng quay thấp | Tập trung vào vốn hóa lớn || Trọng số bằng nhau | $w_i = 1/N$ | Đa dạng hóa ngây thơ tối đa | Vòng quay cao, kém thanh khoản || Theo yếu tố cơ bản | $w_i \propto F_i$ (doanh thu, vốn chủ sở hữu sổ sách, v.v.) | Phá vỡ mối liên hệ giữa giá và giá trị | Đòi hỏi dữ liệu kế toán || Phương sai tối thiểu | $\mathbf{w} = \arg\min \mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}$ | Biến động danh mục thấp nhất | Sai số ước lượng của $\boldsymbol{\Sigma}$ || Ngang bằng rủi ro | $w_i \sigma_i = w_j \sigma_j \; \forall \, i,j$ | Đóng góp rủi ro bằng nhau | Dùng đòn bẩy cho các tài sản biến động thấp || Đa dạng hóa tối đa | $\max \frac{\mathbf{w}'\boldsymbol{\sigma}}{\sqrt{\mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}}}$ | Tối đa hóa tỷ số đa dạng hóa | Nhạy với các ước lượng tương quan || VW có giới hạn trần | $w_i \propto \text{MCap}_i$, $w_i \leq \bar{w}$ | Giảm mức độ tập trung | Ngưỡng trần mang tính tùy ý |: Tóm tắt các phương pháp xác định trọng số danh mục đầu tư. {#tbl-weight-schemes}## Xây dựng dữ liệu {#sec-weight-data}```{python}#| label: setup#| code-summary: "Import libraries and configure environment"import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy.optimize import minimizefrom sklearn.covariance import LedoitWolffrom linearmodels.panel import PanelOLSimport warningswarnings.filterwarnings('ignore')plt.rcParams.update({'figure.figsize': (10, 6),'figure.dpi': 150,'font.size': 11,'axes.spines.top': False,'axes.spines.right': False})``````{python}#| label: data-load#| eval: false#| code-summary: "Load market and fundamental data"from datacore import DataCoreClientclient = DataCoreClient()# Daily prices and volumedaily = client.get_daily_prices( exchanges=['HOSE', 'HNX'], start_date='2012-01-01', end_date='2024-12-31', fields=['ticker', 'date', 'close', 'adjusted_close', 'volume','turnover_value', 'market_cap', 'shares_outstanding','bid_ask_spread', 'free_float_pct' ])# Monthly returns (pre-computed for convenience)monthly = client.get_monthly_returns( exchanges=['HOSE', 'HNX'], start_date='2012-01-01', end_date='2024-12-31', fields=['ticker', 'month_end', 'monthly_return', 'market_cap','volume_avg_20d', 'turnover_value_avg_20d' ])# Fundamentals for fundamental weightingfundamentals = client.get_fundamentals( exchanges=['HOSE', 'HNX'], start_date='2012-01-01', end_date='2024-12-31', frequency='annual', fields=['ticker', 'fiscal_year', 'revenue', 'book_equity','total_assets', 'dividends_paid', 'operating_cash_flow' ])# Market-level returns for benchmarkingmarket_index = client.get_index( index='VNINDEX', start_date='2012-01-01', end_date='2024-12-31', frequency='monthly')print(f"Daily observations: {daily.shape[0]:,}")print(f"Monthly observations: {monthly.shape[0]:,}")print(f"Unique tickers: {monthly['ticker'].nunique()}")```### Xác định tập hợp đầu tư và bộ lọc thanh khoảnMột bước tiền xử lý then chốt là xác định tập hợp có thể đầu tư. Việc đưa vào mọi cổ phiếu niêm yết, bất kể thanh khoản, làm thổi phồng lợi ích biểu kiến của trọng số bằng nhau và các phương pháp khác nghiêng về cổ phiếu nhỏ, vì nó ngầm giả định rằng ta có thể giao dịch các cổ phiếu kém thanh khoản mà không có ma sát. Chúng ta áp dụng các bộ lọc thanh khoản theo từng mức và theo dõi kết quả thay đổi ra sao.```{python}# | label: universe-construction# | eval: false# | code-summary: "Apply liquidity filters to define investable universes"def construct_universe(monthly_df, min_mcap_pct=0, min_turnover=0, min_months=12):""" Construct investable universe with liquidity filters. Parameters ---------- min_mcap_pct : float Exclude stocks below this market cap percentile (0-100). min_turnover : float Minimum average daily turnover in VND billion. min_months : int Minimum months of return history required. """ df = monthly_df.copy()# Market cap percentile filter (within each month)if min_mcap_pct >0: df["mcap_pctile"] = df.groupby("month_end")["market_cap"].transform(lambda x: x.rank(pct=True) *100 ) df = df[df["mcap_pctile"] >= min_mcap_pct]# Turnover filterif min_turnover >0: df = df[df["turnover_value_avg_20d"] >= min_turnover *1e9]# History filter ticker_months = df.groupby("ticker")["month_end"].transform("count") df = df[ticker_months >= min_months]return df# Define three universes of increasing restrictivenessuniverse_all = construct_universe(monthly)universe_mid = construct_universe(monthly, min_mcap_pct=20, min_turnover=0.5)universe_liquid = construct_universe(monthly, min_mcap_pct=40, min_turnover=2.0)for name, univ in [ ("All stocks", universe_all), ("Mid filter", universe_mid), ("Liquid only", universe_liquid),]: n_stocks = univ.groupby("month_end")["ticker"].nunique().median()print(f"{name}: median {n_stocks:.0f} stocks/month")```### Mức độ tập trung vốn hóa thị trườngTrước khi so sánh các phương pháp xác định trọng số, cần ghi nhận thị trường Việt Nam thực sự tập trung đến mức nào.```{python}#| label: fig-concentration#| eval: false#| fig-cap: "Mức độ tập trung vốn hóa thị trường trên HOSE và HNX. Panel A cho thấy tỷ trọng vốn hóa thị trường tích lũy của các cổ phiếu được xếp hạng theo quy mô. Panel B cho thấy chỉ số Herfindahl-Hirschman (HHI) của các trọng số danh mục VW theo thời gian. Thị trường Việt Nam có mức độ tập trung cao: 10 cổ phiếu lớn nhất chiếm khoảng 40–50% tổng vốn hóa."#| code-summary: "Visualize market cap concentration"fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Cumulative market cap share (latest month)latest = monthly[monthly['month_end'] == monthly['month_end'].max()].copy()latest = latest.sort_values('market_cap', ascending=False)latest['cum_mcap_share'] = ( latest['market_cap'].cumsum() / latest['market_cap'].sum())latest['rank'] =range(1, len(latest) +1)latest['rank_pct'] = latest['rank'] /len(latest) *100axes[0].plot(latest['rank_pct'], latest['cum_mcap_share'] *100, color='#2C5F8A', linewidth=2)axes[0].axhline(y=50, color='gray', linestyle='--', linewidth=0.8)axes[0].axhline(y=80, color='gray', linestyle='--', linewidth=0.8)# Mark top 10 and top 30n_at_50 = (latest['cum_mcap_share'] <=0.50).sum()axes[0].annotate(f'Top {n_at_50} stocks = 50%', xy=(n_at_50 /len(latest) *100, 50), fontsize=9, color='#C0392B')axes[0].set_xlabel('Cumulative Stock Rank (%)')axes[0].set_ylabel('Cumulative Market Cap Share (%)')axes[0].set_title('Panel A: Market Cap Concentration Curve')# Panel B: HHI over timehhi_ts = ( monthly .groupby('month_end') .apply(lambda g: (g['market_cap'] / g['market_cap'].sum()).pow(2).sum()) .reset_index(name='hhi'))hhi_ts['month_end'] = pd.to_datetime(hhi_ts['month_end'])axes[1].plot(hhi_ts['month_end'], hhi_ts['hhi'] *10000, color='#2C5F8A', linewidth=1.5)axes[1].set_xlabel('Date')axes[1].set_ylabel('HHI (basis points)')axes[1].set_title('Panel B: Herfindahl Index of VW Weights')plt.tight_layout()plt.show()```## Triển khai các phương pháp xác định trọng số {#sec-weight-implementation}Bây giờ chúng ta triển khai từng phương pháp xác định trọng số và tính lợi nhuận danh mục hằng tháng. Mọi phần triển khai đều theo một cấu trúc chung: tại mỗi ngày tái cân bằng, tính các trọng số mục tiêu từ thông tin sẵn có, rồi tính lợi nhuận có trọng số trong kỳ nắm giữ tiếp theo.### Bộ máy cốt lõi của danh mục```{python}#| label: portfolio-engine#| eval: false#| code-summary: "Generic portfolio construction and backtesting engine"def compute_portfolio_returns( monthly_df, weight_fn, rebal_freq="M", max_weight=1.0, min_weight=0.0,):""" Compute time series of portfolio returns for a given weighting function. Parameters ---------- monthly_df : DataFrame Must contain 'ticker', 'month_end', 'monthly_return', and any columns needed by weight_fn. weight_fn : callable Function that takes a cross-section DataFrame and returns a Series of weights indexed by ticker. Weights need not sum to 1 (they will be normalized). rebal_freq : str 'M' for monthly, 'Q' for quarterly, 'A' for annual. max_weight : float Maximum weight per stock (for capped schemes). min_weight : float Minimum weight per stock. Returns ------- DataFrame with columns: month_end, port_return, n_stocks, turnover, hhi, effective_n """ months =sorted(monthly_df["month_end"].unique())# Determine rebalancing datesif rebal_freq =="M": rebal_dates =set(months)elif rebal_freq =="Q": rebal_dates =set(pd.to_datetime(months).to_period("Q").to_timestamp("M"))# Map to nearest month-end rebal_dates = {m for m in months if pd.Timestamp(m).month %3==0}ifnot rebal_dates: rebal_dates =set(months[::3])elif rebal_freq =="A": rebal_dates = {m for m in months if pd.Timestamp(m).month ==6}ifnot rebal_dates: rebal_dates =set(months[::12])else: rebal_dates =set(months) results = [] prev_weights =Nonefor month in months: cross_section = monthly_df[monthly_df["month_end"] == month].copy() cross_section = cross_section.dropna(subset=["monthly_return"])iflen(cross_section) <5:continueif month in rebal_dates or prev_weights isNone:# Compute fresh weights raw_weights = weight_fn(cross_section) raw_weights = raw_weights.clip(lower=min_weight, upper=max_weight) total = raw_weights.sum()if total <=0:continue target_weights = raw_weights / totalelse:# Drift weights forward from previous monthif prev_weights isnotNone: available = cross_section.set_index("ticker") drifted = prev_weights.reindex(available.index, fill_value=0)# Adjust for returns drifted = drifted * (1+ available["monthly_return"]) total = drifted.sum()if total <=0:continue target_weights = drifted / totalelse:continue# Align weights with available stocks cross_section = cross_section.set_index("ticker") aligned_w = target_weights.reindex(cross_section.index, fill_value=0) aligned_w = aligned_w / aligned_w.sum()# Portfolio return port_ret = (aligned_w * cross_section["monthly_return"]).sum()# Turnover (two-way)if prev_weights isnotNone: prev_aligned = prev_weights.reindex(aligned_w.index, fill_value=0)# Drift previous weights prev_drifted = prev_aligned * (1+ cross_section["monthly_return"].reindex( prev_aligned.index, fill_value=0 ) ) prev_drifted = ( prev_drifted / prev_drifted.sum()if prev_drifted.sum() >0else prev_drifted ) turnover = (aligned_w - prev_drifted).abs().sum() /2else: turnover =1.0# Concentration metrics hhi = (aligned_w**2).sum() effective_n =1.0/ hhi if hhi >0else0 results.append( {"month_end": month,"port_return": port_ret,"n_stocks": (aligned_w >1e-6).sum(),"turnover": turnover,"hhi": hhi,"effective_n": effective_n, } ) prev_weights = aligned_w.copy()return pd.DataFrame(results)```### Danh mục trọng số theo vốn hóa```{python}#| label: vw-portfolio#| eval: false#| code-summary: "Implement value-weighted portfolio"def vw_weights(cross_section):"""Value-weighted: proportional to market cap."""return cross_section.set_index('ticker')['market_cap']vw_returns = compute_portfolio_returns(universe_mid, vw_weights, rebal_freq='M')print(f"VW portfolio: {len(vw_returns)} months")print(f"Mean monthly return: {vw_returns['port_return'].mean():.4f}")print(f"Mean turnover: {vw_returns['turnover'].mean():.4f}")print(f"Mean effective N: {vw_returns['effective_n'].mean():.1f}")```### Danh mục trọng số bằng nhau```{python}#| label: ew-portfolio#| eval: false#| code-summary: "Implement equal-weighted portfolio at different rebalancing frequencies"def ew_weights(cross_section):"""Equal-weighted: 1/N.""" tickers = cross_section.set_index('ticker').indexreturn pd.Series(1.0, index=tickers)# Monthly rebalancingew_monthly = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq='M')# Quarterly rebalancingew_quarterly = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq='Q')# Annual rebalancing (June)ew_annual = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq='A')for name, df in [('EW Monthly', ew_monthly), ('EW Quarterly', ew_quarterly), ('EW Annual', ew_annual)]:print(f"{name}: mean ret = {df['port_return'].mean():.4f}, "f"turnover = {df['turnover'].mean():.4f}")```### Danh mục trọng số theo vốn hóa có giới hạn trầnĐể giảm bớt mức độ tập trung của VW thuần túy trong khi vẫn giữ các đặc tính vòng quay thấp của nó, chúng ta áp đặt giới hạn trần lên trọng số của từng cổ phiếu. Lựa chọn phổ biến là 5% hoặc 10%, mô phỏng các quy tắc xây dựng của những chỉ số có giới hạn trần như các chỉ số MSCI Capped.```{python}#| label: capped-vw#| eval: false#| code-summary: "Implement capped value-weighted portfolios"def capped_vw_weights(cross_section, cap=0.05):"""Capped VW: market cap weights with an upper bound.""" w = cross_section.set_index('ticker')['market_cap'] w = w / w.sum()# Iterative capping (redistribute excess weight)for _ inrange(20): excess = w[w > cap] - capif excess.sum() <=1e-8:break w[w > cap] = cap w[w <= cap] *= (1+ excess.sum() / w[w <= cap].sum())return wcapped5 = compute_portfolio_returns( universe_mid, lambda cs: capped_vw_weights(cs, 0.05), rebal_freq='M')capped10 = compute_portfolio_returns( universe_mid, lambda cs: capped_vw_weights(cs, 0.10), rebal_freq='M')print(f"Capped 5%: eff_N = {capped5['effective_n'].mean():.1f}, "f"turnover = {capped5['turnover'].mean():.4f}")print(f"Capped 10%: eff_N = {capped10['effective_n'].mean():.1f}, "f"turnover = {capped10['turnover'].mean():.4f}")```### Danh mục trọng số theo yếu tố cơ bản@arnott2005fundamental đề xuất xác định trọng số cổ phiếu theo các thước đo cơ bản (doanh thu, vốn chủ sở hữu sổ sách, cổ tức, dòng tiền) thay vì vốn hóa thị trường. Lập luận là các trọng số cơ bản không bị nhiễu bởi sai số định giá, nhờ đó phá vỡ việc nắm giữ quá mức một cách máy móc các cổ phiếu bị định giá cao vốn có trong VW. Chúng ta xây dựng một trọng số cơ bản tổng hợp bằng thứ hạng trung bình của bốn thước đo:$$w_{i,t}^{FW} \propto \frac{1}{4}\left(\text{Rank}_{i,t}^{\text{Rev}} + \text{Rank}_{i,t}^{\text{BE}} + \text{Rank}_{i,t}^{\text{Div}} + \text{Rank}_{i,t}^{\text{CFO}}\right)$$ {#eq-fw}```{python}# | label: fundamental-weighted# | eval: false# | code-summary: "Implement fundamental-weighted portfolio"# Merge fundamentals with monthly data (use most recent fiscal year)fundamentals["merge_year"] = fundamentals["fiscal_year"] +1# Lag by 1 yearmonthly_fund = monthly.copy()monthly_fund["year"] = pd.to_datetime(monthly_fund["month_end"]).dt.yearmonthly_fund = monthly_fund.merge( fundamentals.rename(columns={"merge_year": "year"}), on=["ticker", "year"], how="left",)def fw_weights(cross_section):"""Fundamental-weighted: composite of revenue, book equity, dividends, CFO.""" cs = cross_section.set_index("ticker") ranks = pd.DataFrame(index=cs.index)for col in ["revenue", "book_equity", "dividends_paid", "operating_cash_flow"]:if col in cs.columns: vals = cs[col].clip(lower=0) # Only positive values ranks[col] = vals.rank(pct=True) composite = ranks.mean(axis=1) composite = composite.fillna(0)return compositefw_returns = compute_portfolio_returns(monthly_fund, fw_weights, rebal_freq="A")print(f"Fundamental-weighted: mean ret = {fw_returns['port_return'].mean():.4f}, "f"eff_N = {fw_returns['effective_n'].mean():.1f}")```## Các phương pháp xác định trọng số dựa trên rủi ro {#sec-weight-risk-based}Các phương pháp xác định trọng số ở trên chỉ sử dụng vốn hóa thị trường hoặc dữ liệu kế toán. Các phương pháp dựa trên rủi ro tính đến cấu trúc hiệp phương sai của lợi nhuận, nhằm tạo ra các danh mục có hiệu quả điều chỉnh theo rủi ro tốt hơn. Cái giá phải trả là chúng đòi hỏi ước lượng ma trận hiệp phương sai (tức là một đối tượng nhiều chiều vốn nổi tiếng là khó ước lượng chính xác với chuỗi thời gian ngắn).### Ước lượng hiệp phương saiVới $N \approx 300$ cổ phiếu và $T \approx 60$ tháng, ma trận hiệp phương sai mẫu có điều kiện xấu nghiêm trọng (ill-conditioned). Chúng ta dùng bộ ước lượng co rút của @ledoit2004well, kéo hiệp phương sai mẫu về một mục tiêu có cấu trúc (ma trận đơn vị nhân với phương sai trung bình):$$\hat{\boldsymbol{\Sigma}}^{\text{shrink}} = \delta \mathbf{F} + (1 - \delta) \mathbf{S}$$ {#eq-shrinkage}trong đó $\mathbf{S}$ là hiệp phương sai mẫu, $\mathbf{F}$ là mục tiêu co rút, và $\delta \in [0,1]$ là cường độ co rút tối ưu được suy ra theo phương pháp giải tích.```{python}#| label: covariance-estimation#| eval: false#| code-summary: "Estimate covariance matrix with Ledoit-Wolf shrinkage"def estimate_covariance(monthly_df, month, lookback=60, min_obs=36):""" Estimate covariance matrix using Ledoit-Wolf shrinkage. Parameters ---------- monthly_df : DataFrame with ticker, month_end, monthly_return month : target month (use returns before this date) lookback : number of months to use min_obs : minimum observations per stock Returns ------- cov_matrix : DataFrame (N x N) tickers : list of tickers with sufficient data """ end_date = pd.Timestamp(month) start_date = end_date - pd.DateOffset(months=lookback) window = monthly_df[ (monthly_df['month_end'] > start_date) & (monthly_df['month_end'] <= end_date) ]# Pivot to wide format returns_wide = window.pivot_table( index='month_end', columns='ticker', values='monthly_return' )# Keep stocks with sufficient observations valid_cols = returns_wide.columns[returns_wide.notna().sum() >= min_obs] returns_wide = returns_wide[valid_cols].dropna(axis=0, how='all')# Fill remaining NAs with 0 (conservative) returns_clean = returns_wide.fillna(0)if returns_clean.shape[1] <10:returnNone, None# Ledoit-Wolf shrinkage lw = LedoitWolf() lw.fit(returns_clean.values) cov_matrix = pd.DataFrame( lw.covariance_, index=valid_cols, columns=valid_cols )return cov_matrix, list(valid_cols)```### Danh mục phương sai tối thiểuDanh mục phương sai tối thiểu toàn cục (GMV) cực tiểu hóa phương sai danh mục mà không nhắm đến một mức lợi nhuận cụ thể [@clarke2011minimum]:$$\mathbf{w}^{MV} = \arg\min_{\mathbf{w}} \; \mathbf{w}'\hat{\boldsymbol{\Sigma}}\mathbf{w} \quad \text{s.t.} \quad \mathbf{1}'\mathbf{w} = 1, \; w_i \geq 0$$ {#eq-minvar}Ràng buộc chỉ mua ($w_i \geq 0$) là thiết yếu trong thực tế và cũng đóng vai trò như một dạng co rút ngầm, giúp cải thiện hiệu quả ngoài mẫu [@jagannathan2003risk].```{python}#| label: minimum-variance#| eval: false#| code-summary: "Implement minimum variance portfolio"def minimum_variance_weights(cov_matrix, max_weight=0.05):""" Solve for the minimum variance portfolio with long-only and position-size constraints. """ n = cov_matrix.shape[0] Sigma = cov_matrix.valuesdef portfolio_variance(w):return w @ Sigma @ w constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) -1} ] bounds = [(0, max_weight) for _ inrange(n)] x0 = np.ones(n) / n result = minimize( portfolio_variance, x0, method='SLSQP', bounds=bounds, constraints=constraints, options={'maxiter': 1000, 'ftol': 1e-12} )if result.success:return pd.Series(result.x, index=cov_matrix.index)else:return pd.Series(1.0/ n, index=cov_matrix.index)def mv_weight_fn(cross_section, cov_cache={}):"""Wrapper for portfolio engine: minimum variance.""" month = cross_section['month_end'].iloc[0]if month notin cov_cache: cov_matrix, tickers = estimate_covariance(monthly, month) cov_cache[month] = (cov_matrix, tickers) cov_matrix, tickers = cov_cache[month]if cov_matrix isNone:# Fallback to equal weightreturn pd.Series(1.0, index=cross_section.set_index('ticker').index)# Restrict to stocks in both cross-section and covariance matrix available =set(cross_section['ticker']) &set(tickers)iflen(available) <10:return pd.Series(1.0, index=cross_section.set_index('ticker').index) sub_cov = cov_matrix.loc[list(available), list(available)] weights = minimum_variance_weights(sub_cov)return weightsmv_returns = compute_portfolio_returns( universe_mid, mv_weight_fn, rebal_freq='Q')print(f"Min Variance: mean ret = {mv_returns['port_return'].mean():.4f}, "f"std = {mv_returns['port_return'].std():.4f}")```### Ngang bằng rủi ro (đóng góp rủi ro bằng nhau)Phương pháp ngang bằng rủi ro (risk parity) phân bổ sao cho mỗi tài sản đóng góp như nhau vào tổng rủi ro của danh mục [@maillard2010properties]. Đóng góp rủi ro của tài sản $i$ là:$$RC_i = w_i \cdot \frac{(\boldsymbol{\Sigma} \mathbf{w})_i}{\sqrt{\mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}}}$$ {#eq-risk-contribution}Danh mục ngang bằng rủi ro giải $RC_i = RC_j$ với mọi $i, j$:```{python}#| label: risk-parity#| eval: false#| code-summary: "Implement risk parity (equal risk contribution) portfolio"def risk_parity_weights(cov_matrix, max_weight=0.05):""" Solve for the risk parity portfolio where each asset contributes equally to total portfolio variance. """ n = cov_matrix.shape[0] Sigma = cov_matrix.valuesdef risk_parity_objective(w): port_var = w @ Sigma @ w marginal = Sigma @ w risk_contrib = w * marginal target_rc = port_var / nreturn np.sum((risk_contrib - target_rc) **2) constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) -1} ] bounds = [(1e-6, max_weight) for _ inrange(n)] x0 = np.ones(n) / n result = minimize( risk_parity_objective, x0, method='SLSQP', bounds=bounds, constraints=constraints, options={'maxiter': 1000, 'ftol': 1e-12} )if result.success:return pd.Series(result.x, index=cov_matrix.index)else:return pd.Series(1.0/ n, index=cov_matrix.index)def rp_weight_fn(cross_section, cov_cache={}):"""Wrapper for portfolio engine: risk parity.""" month = cross_section['month_end'].iloc[0]if month notin cov_cache: cov_matrix, tickers = estimate_covariance(monthly, month) cov_cache[month] = (cov_matrix, tickers) cov_matrix, tickers = cov_cache[month]if cov_matrix isNone:return pd.Series(1.0, index=cross_section.set_index('ticker').index) available =set(cross_section['ticker']) &set(tickers)iflen(available) <10:return pd.Series(1.0, index=cross_section.set_index('ticker').index) sub_cov = cov_matrix.loc[list(available), list(available)]return risk_parity_weights(sub_cov)rp_returns = compute_portfolio_returns( universe_mid, rp_weight_fn, rebal_freq='Q')print(f"Risk Parity: mean ret = {rp_returns['port_return'].mean():.4f}, "f"std = {rp_returns['port_return'].std():.4f}")```### Danh mục đa dạng hóa tối đa@choueifaty2008towards định nghĩa tỷ số đa dạng hóa là tỷ số giữa biến động trung bình có trọng số và biến động của danh mục:$$DR(\mathbf{w}) = \frac{\mathbf{w}'\boldsymbol{\sigma}}{\sqrt{\mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}}}$$ {#eq-div-ratio}trong đó $\boldsymbol{\sigma}$ là vectơ biến động của từng tài sản. Danh mục đa dạng hóa tối đa cực đại hóa tỷ số này:```{python}#| label: max-diversification#| eval: false#| code-summary: "Implement maximum diversification portfolio"def max_diversification_weights(cov_matrix, max_weight=0.05):"""Maximize the diversification ratio.""" n = cov_matrix.shape[0] Sigma = cov_matrix.values sigma = np.sqrt(np.diag(Sigma))def neg_div_ratio(w): port_vol = np.sqrt(w @ Sigma @ w)if port_vol <1e-10:return0return-(w @ sigma) / port_vol constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) -1} ] bounds = [(0, max_weight) for _ inrange(n)] x0 = np.ones(n) / n result = minimize( neg_div_ratio, x0, method='SLSQP', bounds=bounds, constraints=constraints, options={'maxiter': 1000, 'ftol': 1e-12} )if result.success:return pd.Series(result.x, index=cov_matrix.index)else:return pd.Series(1.0/ n, index=cov_matrix.index)```## So sánh toàn diện hiệu quả đầu tư {#sec-weight-comparison}Bây giờ chúng ta so sánh tất cả các phương pháp trên cùng một tập hợp đầu tư với phương pháp luận nhất quán.```{python}#| label: all-portfolios#| eval: false#| code-summary: "Compute returns for all weighting schemes"# Collect all portfolio return seriesportfolios = {'VW': vw_returns,'EW (Monthly)': ew_monthly,'EW (Quarterly)': ew_quarterly,'EW (Annual)': ew_annual,'Capped VW (5%)': capped5,'Capped VW (10%)': capped10,'Fundamental': fw_returns,'Min Variance': mv_returns,'Risk Parity': rp_returns,}# Align to common date rangecommon_start =max(df['month_end'].min() for df in portfolios.values())common_end =min(df['month_end'].max() for df in portfolios.values())for name in portfolios: portfolios[name] = portfolios[name][ (portfolios[name]['month_end'] >= common_start) & (portfolios[name]['month_end'] <= common_end) ].copy()print(f"Common period: {common_start} to {common_end}")print(f"Number of months: {len(portfolios['VW'])}")```### Các thước đo hiệu quả```{python}#| label: performance-metrics#| eval: false#| code-summary: "Compute comprehensive performance statistics for all schemes"def compute_metrics(returns_df, risk_free_annual=0.04):"""Compute performance metrics from monthly portfolio returns.""" r = returns_df['port_return'] rf_monthly = (1+ risk_free_annual) ** (1/12) -1 excess = r - rf_monthly n_months =len(r)# Annualized return cum_ret = (1+ r).prod() ann_ret = cum_ret ** (12/ n_months) -1# Annualized volatility ann_vol = r.std() * np.sqrt(12)# Sharpe ratio sharpe = excess.mean() / excess.std() * np.sqrt(12) if excess.std() >0else0# Maximum drawdown cum = (1+ r).cumprod() running_max = cum.cummax() drawdown = (cum - running_max) / running_max max_dd = drawdown.min()# Sortino ratio downside = excess[excess <0] downside_vol = np.sqrt((downside **2).mean()) * np.sqrt(12) sortino = excess.mean() *12/ downside_vol if downside_vol >0else0# Calmar ratio calmar = ann_ret /abs(max_dd) if max_dd !=0else0# Average turnover and effective N avg_turnover = returns_df['turnover'].mean() avg_eff_n = returns_df['effective_n'].mean()# Skewness and kurtosis skew = r.skew() kurt = r.kurtosis()return {'Ann. Return': ann_ret,'Ann. Volatility': ann_vol,'Sharpe Ratio': sharpe,'Sortino Ratio': sortino,'Max Drawdown': max_dd,'Calmar Ratio': calmar,'Skewness': skew,'Kurtosis': kurt,'Avg. Turnover': avg_turnover,'Effective N': avg_eff_n }# Compute metrics for all portfoliosmetrics_list = []for name, df in portfolios.items(): m = compute_metrics(df) m['Portfolio'] = name metrics_list.append(m)metrics_df = pd.DataFrame(metrics_list).set_index('Portfolio')# Format for displaydisplay_cols = ['Ann. Return', 'Ann. Volatility', 'Sharpe Ratio', 'Sortino Ratio','Max Drawdown', 'Avg. Turnover', 'Effective N']print(metrics_df[display_cols].round(3).to_string())``````{python}#| label: fig-cumulative-returns#| eval: false#| fig-cap: "Đường tích lũy tài sản của các phương pháp xác định trọng số khác nhau áp dụng cho cổ phiếu Việt Nam (2012–2024). Biểu đồ cho thấy lựa chọn phương pháp xác định trọng số có tác động đáng kể đến hiệu quả tích lũy. Các danh mục trọng số bằng nhau vượt trội so với các danh mục trọng số theo vốn hóa tính theo giá trị gộp (trước chi phí), nhưng lợi thế này phải được cân nhắc với chi phí vòng quay cao hơn."#| code-summary: "Plot cumulative returns for all weighting schemes"fig, ax = plt.subplots(figsize=(12, 7))colors = {'VW': '#2C5F8A', 'EW (Monthly)': '#E67E22','EW (Quarterly)': '#F39C12', 'EW (Annual)': '#D4AC0D','Capped VW (5%)': '#8E44AD', 'Capped VW (10%)': '#9B59B6','Fundamental': '#27AE60', 'Min Variance': '#C0392B','Risk Parity': '#1ABC9C'}linestyles = {'VW': '-', 'EW (Monthly)': '-', 'EW (Quarterly)': '--','EW (Annual)': ':', 'Capped VW (5%)': '-','Capped VW (10%)': '--', 'Fundamental': '-','Min Variance': '-', 'Risk Parity': '-'}for name, df in portfolios.items(): cum = (1+ df.set_index('month_end')['port_return']).cumprod() ax.plot(cum.index, cum.values, label=name, color=colors.get(name, 'gray'), linestyle=linestyles.get(name, '-'), linewidth=1.8if name in ['VW', 'EW (Monthly)', 'Min Variance'] else1.2)ax.set_xlabel('Date')ax.set_ylabel('Cumulative Wealth (VND 1 invested)')ax.set_title('Cumulative Performance by Weighting Scheme')ax.legend(loc='upper left', fontsize=9, ncol=2)ax.set_yscale('log')plt.tight_layout()plt.show()```### Đánh đổi giữa rủi ro và lợi nhuận```{python}#| label: fig-risk-return#| eval: false#| fig-cap: "Biểu đồ phân tán rủi ro–lợi nhuận quy năm của tất cả các phương pháp xác định trọng số. Các phương pháp dựa trên rủi ro (phương sai tối thiểu, ngang bằng rủi ro) tập trung ở góc dưới bên trái với biến động thấp hơn, trong khi các phương pháp trọng số bằng nhau cho lợi nhuận cao hơn với cái giá là vòng quay và biến động cao hơn."#| code-summary: "Risk-return scatter plot"fig, ax = plt.subplots(figsize=(9, 7))for name in metrics_df.index: ax.scatter( metrics_df.loc[name, 'Ann. Volatility'], metrics_df.loc[name, 'Ann. Return'], s=metrics_df.loc[name, 'Effective N'] *3, color=colors.get(name, 'gray'), alpha=0.85, edgecolors='white', linewidth=1.5, zorder=5 ) ax.annotate( name, (metrics_df.loc[name, 'Ann. Volatility'] +0.002, metrics_df.loc[name, 'Ann. Return']), fontsize=8 )ax.set_xlabel('Annualized Volatility')ax.set_ylabel('Annualized Return')ax.set_title('Risk-Return Profile (bubble size = Effective N)')plt.tight_layout()plt.show()```## Chi phí giao dịch và hiệu quả sau chi phí {#sec-weight-transaction-costs}### Ước lượng chi phí giao dịch ở Việt NamChi phí giao dịch ở Việt Nam bao gồm các thành phần tường minh (phí môi giới, phí sàn giao dịch, thuế) và các thành phần ngầm (chênh lệch giá mua–bán, tác động giá). Cấu trúc chi phí tường minh tính đến năm 2024 xấp xỉ như sau:| Thành phần | Mức phí | Ghi chú ||-------------------------|------------|----------------------------------|| Phí môi giới | 0.15–0.35% | Tùy công ty môi giới và mức khối lượng || Phí sàn và bù trừ | 0.003% | Do sàn giao dịch quy định cố định || Thuế bán | 0.10% | Đánh trên tổng giá trị bán |: Các thành phần chi phí giao dịch tường minh đối với cổ phiếu Việt Nam. {#tbl-weight-costs}Tổng chi phí tường minh cho một vòng giao dịch (mua + bán) dao động từ khoảng 0.30% đến 0.80%. Các chi phí ngầm, tức chênh lệch giá và tác động giá, có thể lớn hơn đáng kể đối với các cổ phiếu nhỏ và kém thanh khoản.Chúng ta mô hình hóa tổng chi phí giao dịch như một hàm của quy mô lệnh và thanh khoản của cổ phiếu:$$TC_{i,t} = c_{\text{fixed}} + \frac{1}{2} \text{Spread}_{i,t} + \lambda \sqrt{\frac{|\Delta w_{i,t}| \cdot \text{AUM}}{ADV_{i,t}}}$$ {#eq-tc-model}trong đó $c_{\text{fixed}} \approx 0.25\%$ là chi phí tường minh trên mỗi giao dịch, $\text{Spread}_{i,t}$ là chênh lệch giá mua–bán niêm yết, $\Delta w_{i,t}$ là mức thay đổi trọng số, $\text{AUM}$ là quy mô danh mục, $ADV_{i,t}$ là khối lượng giao dịch trung bình hằng ngày tính theo VND, và $\lambda$ là hệ số tác động giá được ước lượng từ mô hình @amihud2002illiquidity.```{python}#| label: transaction-cost-model#| eval: false#| code-summary: "Implement transaction cost model calibrated to Vietnamese market"def estimate_transaction_costs(weight_changes, stock_data, aum_vnd=100e9, fixed_cost=0.0025, impact_coef=0.10):""" Estimate total transaction costs for a rebalancing event. Parameters ---------- weight_changes : Series indexed by ticker, absolute weight changes stock_data : DataFrame with ticker, bid_ask_spread, turnover_value_avg_20d aum_vnd : float, portfolio AUM in VND fixed_cost : float, explicit cost per unit traded (one-way) impact_coef : float, price impact coefficient (lambda) Returns ------- total_cost : float, total TC as fraction of AUM cost_detail : DataFrame with per-stock costs """ costs = []for ticker, dw in weight_changes.items():ifabs(dw) <1e-6:continue trade_vnd =abs(dw) * aum_vnd# Explicit cost (one-way) explicit = fixed_cost * trade_vnd# Spread cost stock_info = stock_data[stock_data['ticker'] == ticker]iflen(stock_info) >0: spread = stock_info['bid_ask_spread'].iloc[0] adv = stock_info['turnover_value_avg_20d'].iloc[0]else: spread =0.005# Default 50 bps adv =1e9# Default VND 1bn spread_cost =0.5* spread * trade_vnd# Price impact (square root model) participation_rate = trade_vnd /max(adv, 1e6) impact_cost = impact_coef * np.sqrt(participation_rate) * trade_vnd total = explicit + spread_cost + impact_cost costs.append({'ticker': ticker,'weight_change': dw,'trade_vnd': trade_vnd,'explicit': explicit,'spread': spread_cost,'impact': impact_cost,'total': total }) cost_df = pd.DataFrame(costs) total_cost = cost_df['total'].sum() / aum_vnd iflen(cost_df) >0else0return total_cost, cost_df```### Hiệu quả sau chi phíChúng ta áp dụng mô hình chi phí giao dịch để tính lợi nhuận sau chi phí của từng phương pháp xác định trọng số tại các mức AUM giả định khác nhau. Điều này rất quan trọng vì những chiến lược có vẻ hấp dẫn khi tính gộp có thể không thể triển khai ở quy mô lớn do tính kém thanh khoản của các cổ phiếu vốn hóa nhỏ Việt Nam.```{python}#| label: net-returns#| eval: false#| code-summary: "Compute net-of-cost returns using simplified proportional cost model"def compute_net_returns(portfolio_df, cost_per_turnover=0.005):""" Approximate net returns using a proportional cost model. Net return = gross return - (turnover * cost_per_unit_turnover) """ df = portfolio_df.copy() df['tc'] = df['turnover'] * cost_per_turnover df['net_return'] = df['port_return'] - df['tc']return df# Compute at different cost assumptionscost_scenarios = {'Low (25 bps)': 0.0025,'Medium (50 bps)': 0.005,'High (100 bps)': 0.01}print("Annualized Net Sharpe Ratios by Cost Scenario:")print("-"*70)for cost_name, cost_rate in cost_scenarios.items(): row = {'Scenario': cost_name}for port_name, port_df in portfolios.items(): net_df = compute_net_returns(port_df, cost_rate) rf_monthly = (1.04) ** (1/12) -1 excess = net_df['net_return'] - rf_monthly sharpe = excess.mean() / excess.std() * np.sqrt(12) if excess.std() >0else0 row[port_name] =round(sharpe, 3)print(f"{cost_name}:")for k, v in row.items():if k !='Scenario':print(f" {k}: {v}")print()``````{python}#| label: fig-turnover-comparison#| eval: false#| fig-cap: "Tỷ lệ vòng quay danh mục hằng tháng theo phương pháp xác định trọng số. Các danh mục trọng số theo vốn hóa có vòng quay thấp nhất vì trọng số trôi tự nhiên theo giá. Các danh mục trọng số bằng nhau tái cân bằng hằng tháng có vòng quay cao nhất. Các phương pháp dựa trên rủi ro tái cân bằng hằng quý nằm ở mức trung gian."#| code-summary: "Compare turnover distributions across schemes"fig, ax = plt.subplots(figsize=(12, 5))turnover_data = {}for name, df in portfolios.items(): turnover_data[name] = df['turnover'].valuespositions =range(len(turnover_data))bp = ax.boxplot( turnover_data.values(), positions=positions, widths=0.6, patch_artist=True, showfliers=False, medianprops={'color': 'black', 'linewidth': 1.5})for i, (patch, name) inenumerate(zip(bp['boxes'], turnover_data.keys())): patch.set_facecolor(colors.get(name, 'gray')) patch.set_alpha(0.7)ax.set_xticks(positions)ax.set_xticklabels(turnover_data.keys(), rotation=45, ha='right', fontsize=9)ax.set_ylabel('Monthly Turnover (one-way)')ax.set_title('Turnover Distribution by Weighting Scheme')plt.tight_layout()plt.show()```### Sự bào mòn do chi phí ở quy mô lớnMối quan hệ giữa AUM của danh mục và hiệu quả có thể triển khai là phi tuyến vì chi phí tác động giá tăng theo quy mô lệnh. Chúng ta mô phỏng hiệu quả tại các mức AUM khác nhau:```{python}#| label: fig-aum-scaling#| eval: false#| fig-cap: "Tỷ số Sharpe như một hàm của AUM danh mục đối với một số phương pháp xác định trọng số được chọn. Các danh mục trọng số theo vốn hóa gần như không đổi theo quy mô nhờ thiên lệch về các cổ phiếu vốn hóa lớn có thanh khoản cao. Các danh mục trọng số bằng nhau suy giảm nhanh khi AUM tăng vì các lệnh tái cân bằng đối với cổ phiếu vốn hóa nhỏ chịu tác động giá quá lớn."#| code-summary: "Simulate performance degradation with AUM for different schemes"aum_levels = [10, 50, 100, 500, 1000, 5000] # VND billionsfig, ax = plt.subplots(figsize=(10, 6))selected_ports = ['VW', 'EW (Monthly)', 'Capped VW (5%)','Min Variance', 'Risk Parity']for name in selected_ports: sharpes = [] df = portfolios[name]for aum in aum_levels:# Cost scales with sqrt(AUM / ADV) base_cost =0.003# Base cost at small AUM scale_factor = np.sqrt(aum /100) # Normalized to VND 100bn cost_rate = base_cost * scale_factor# VW is less affected (large-cap tilt)if name =='VW': cost_rate *=0.3elif'Capped'in name: cost_rate *=0.5elif'Min Variance'in name or'Risk Parity'in name: cost_rate *=0.7 net_df = compute_net_returns(df, cost_rate) rf_m = (1.04) ** (1/12) -1 excess = net_df['net_return'] - rf_m s = excess.mean() / excess.std() * np.sqrt(12) if excess.std() >0else0 sharpes.append(s) ax.plot(aum_levels, sharpes, marker='o', label=name, color=colors.get(name, 'gray'), linewidth=2)ax.set_xlabel('Portfolio AUM (VND Billion)')ax.set_ylabel('Net Sharpe Ratio')ax.set_title('Sharpe Ratio Degradation with AUM')ax.set_xscale('log')ax.legend(fontsize=9)ax.axhline(y=0, color='gray', linewidth=0.5)plt.tight_layout()plt.show()```## Phân tích tần suất tái cân bằng {#sec-weight-rebalancing}### Sự đánh đổi trong tái cân bằngViệc tái cân bằng phục vụ hai mục đích: (i) khôi phục các trọng số mục tiêu để duy trì hồ sơ rủi ro mong muốn, và (ii) thu về "phần thưởng tái cân bằng", tức khoản lợi nhuận mang tính hệ thống từ việc mua thấp bán cao phát sinh khi các trọng số được đặt lại về mục tiêu trong điều kiện lợi nhuận chéo giữa các cổ phiếu có tính hồi quy về giá trị trung bình.Sự đánh đổi là rõ ràng: tái cân bằng càng thường xuyên thì càng bám sát các trọng số mục tiêu và nắm bắt được nhiều phần thưởng tái cân bằng hơn, nhưng đồng thời phát sinh chi phí giao dịch cao hơn. Tần suất tối ưu phụ thuộc vào mức độ hồi quy về giá trị trung bình (quyết định phần thưởng tái cân bằng gộp), mức chi phí giao dịch, và tốc độ các trọng số trôi khỏi mục tiêu.```{python}#| label: fig-rebal-frequency#| eval: false#| fig-cap: "Tác động của tần suất tái cân bằng đối với danh mục trọng số bằng nhau. Panel A cho thấy tỷ số Sharpe gộp và tỷ số Sharpe sau chi phí. Panel B cho thấy sự phân tách phần thưởng tái cân bằng. Tái cân bằng hằng quý mang lại sự đánh đổi tốt nhất giữa việc nắm bắt phần thưởng tái cân bằng và kiểm soát chi phí trên thị trường Việt Nam."#| code-summary: "Analyze rebalancing frequency impact on EW portfolio"frequencies = {'Monthly': 'M','Quarterly': 'Q','Semi-annual': 'Q', # Approximate with every 6 months'Annual': 'A'}# Recompute EW at each frequencyfreq_results = {}for freq_name, freq_code in frequencies.items(): freq_df = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq=freq_code ) freq_results[freq_name] = freq_dffig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Gross vs Net Sharpefreq_names =list(freq_results.keys())gross_sharpes = []net_sharpes = []for fn in freq_names: df = freq_results[fn] rf_m = (1.04) ** (1/12) -1 exc = df['port_return'] - rf_m gross_sharpes.append(exc.mean() / exc.std() * np.sqrt(12)) net_df = compute_net_returns(df, 0.005) exc_net = net_df['net_return'] - rf_m net_sharpes.append(exc_net.mean() / exc_net.std() * np.sqrt(12))x =range(len(freq_names))axes[0].bar([i -0.15for i in x], gross_sharpes, width=0.3, color='#2C5F8A', alpha=0.85, label='Gross')axes[0].bar([i +0.15for i in x], net_sharpes, width=0.3, color='#C0392B', alpha=0.85, label='Net (50 bps)')axes[0].set_xticks(x)axes[0].set_xticklabels(freq_names)axes[0].set_ylabel('Annualized Sharpe Ratio')axes[0].set_title('Panel A: Sharpe Ratio by Rebalancing Frequency')axes[0].legend()# Panel B: Average turnoverturnovers = [freq_results[fn]['turnover'].mean() for fn in freq_names]axes[1].bar(x, turnovers, color='#E67E22', alpha=0.85)axes[1].set_xticks(x)axes[1].set_xticklabels(freq_names)axes[1].set_ylabel('Average Monthly Turnover')axes[1].set_title('Panel B: Turnover by Rebalancing Frequency')plt.tight_layout()plt.show()```### Tái cân bằng dựa trên ngưỡngMột phương án thay thế cho tái cân bằng theo lịch là chỉ tái cân bằng khi các trọng số danh mục trôi vượt quá một dải dung sai. Cách tiếp cận "vùng không giao dịch" này bắt nguồn từ @garleanu2013dynamic, các tác giả đã suy ra chiến lược giao dịch động tối ưu với chi phí giao dịch dạng toàn phương và chỉ ra rằng danh mục tối ưu là trung bình có trọng số của danh mục đang nắm giữ và mục tiêu không có ma sát.```{python}#| label: threshold-rebalancing#| eval: false#| code-summary: "Implement threshold-based rebalancing for EW portfolio"def compute_threshold_rebalanced(monthly_df, weight_fn, threshold=0.01):""" Rebalance only when maximum weight deviation exceeds threshold. Parameters ---------- threshold : float Rebalance when max(|w_actual - w_target|) > threshold. """ months =sorted(monthly_df['month_end'].unique()) results = [] current_weights =None rebalance_count =0for month in months: cs = monthly_df[monthly_df['month_end'] == month].copy() cs = cs.dropna(subset=['monthly_return']).set_index('ticker')iflen(cs) <5:continue target = weight_fn(cs.reset_index()) target = target / target.sum()if current_weights isNone: current_weights = target.copy() rebalance_count +=1else:# Drift weights current_weights = current_weights.reindex(cs.index, fill_value=0) current_weights = current_weights * (1+ cs['monthly_return']) total = current_weights.sum()if total >0: current_weights = current_weights / total# Check if rebalancing needed max_dev = (current_weights - target.reindex( current_weights.index, fill_value=0 )).abs().max()if max_dev > threshold: turnover = (current_weights - target.reindex( current_weights.index, fill_value=0 )).abs().sum() /2 current_weights = target.reindex(cs.index, fill_value=0) current_weights = current_weights / current_weights.sum() rebalance_count +=1else: turnover =0 port_ret = (current_weights.reindex(cs.index, fill_value=0) * cs['monthly_return']).sum() hhi = (current_weights **2).sum() results.append({'month_end': month,'port_return': port_ret,'turnover': turnover,'hhi': hhi,'effective_n': 1/hhi if hhi >0else0,'n_stocks': (current_weights >1e-6).sum() })print(f"Threshold {threshold:.1%}: rebalanced {rebalance_count} / "f"{len(results)} months ({rebalance_count/len(results):.0%})")return pd.DataFrame(results)# Test different thresholdsthresholds = [0.005, 0.01, 0.02, 0.05]threshold_results = {}for t in thresholds: threshold_results[f'{t:.1%}'] = compute_threshold_rebalanced( universe_mid, ew_weights, threshold=t )```## Phần thưởng tái cân bằng: Phân tách {#sec-weight-rebalancing-bonus}Lợi nhuận vượt mức của danh mục EW được tái cân bằng so với danh mục EW mua và nắm giữ (bắt đầu với trọng số bằng nhau nhưng sau đó trôi) có thể được phân tách theo @plyakha2021equal. Định nghĩa $r_i$ là lợi nhuận của cổ phiếu $i$ trong một kỳ:$$R^{EW}_{\text{rebal}} - R^{EW}_{\text{drift}} \approx \frac{1}{2N} \sum_{i=1}^N \text{Var}(r_i) - \frac{1}{2N^2}\sum_{i}\sum_{j}\text{Cov}(r_i, r_j)$$ {#eq-rebal-bonus}Số hạng thứ nhất nắm bắt hiệu ứng "mua thấp, bán cao" từ việc đặt lại trọng số sau khi lợi nhuận phân tán. Số hạng thứ hai là chi phí của việc đảo ngược sự trôi do hiệp phương sai gây ra. Phần thưởng tái cân bằng lớn hơn khi độ phân tán lợi nhuận chéo giữa các cổ phiếu cao (điều đúng ở Việt Nam) và khi các tương quan theo cặp thấp.```{python}#| label: rebalancing-bonus#| eval: false#| code-summary: "Estimate the rebalancing bonus for Vietnamese equities"# Compute buy-and-hold EW portfolio (no rebalancing after initial equal weight)bh_returns = compute_portfolio_returns( universe_mid, ew_weights, rebal_freq='A'# Rebalance once per year only)# The rebalancing bonus is the differencebonus_df = pd.merge( ew_monthly[['month_end', 'port_return']].rename( columns={'port_return': 'rebal_return'}), bh_returns[['month_end', 'port_return']].rename( columns={'port_return': 'bh_return'}), on='month_end')bonus_df['bonus'] = bonus_df['rebal_return'] - bonus_df['bh_return']# Cross-sectional return dispersiondispersion = ( universe_mid .groupby('month_end')['monthly_return'] .std() .reset_index(name='cs_dispersion'))bonus_df = bonus_df.merge(dispersion, on='month_end')ann_bonus = bonus_df['bonus'].mean() *12print(f"Annualized rebalancing bonus (EW monthly vs annual): {ann_bonus:.4f}")print(f"Mean cross-sectional dispersion: {bonus_df['cs_dispersion'].mean():.4f}")``````{python}#| label: fig-rebal-bonus-time#| eval: false#| fig-cap: "Phần thưởng tái cân bằng hằng tháng (EW hằng tháng trừ EW hằng năm) cùng với độ phân tán lợi nhuận chéo giữa các cổ phiếu. Phần thưởng cao hơn trong các giai đoạn phân tán cao, phù hợp với dự đoán lý thuyết rằng việc tái cân bằng thu lợi từ sự hồi quy về giá trị trung bình trong lợi nhuận của từng cổ phiếu."#| code-summary: "Plot rebalancing bonus over time"fig, ax1 = plt.subplots(figsize=(12, 5))ax1.bar(pd.to_datetime(bonus_df['month_end']), bonus_df['bonus'] *100, color='#2C5F8A', alpha=0.6, width=25, label='Rebal. Bonus')ax1.set_ylabel('Rebalancing Bonus (%)', color='#2C5F8A')ax1.set_xlabel('Date')ax2 = ax1.twinx()ax2.plot(pd.to_datetime(bonus_df['month_end']), bonus_df['cs_dispersion'], color='#C0392B', linewidth=1.5, alpha=0.7, label='CS Dispersion')ax2.set_ylabel('Cross-Sectional Return Dispersion', color='#C0392B')ax1.set_title('Rebalancing Bonus and Return Dispersion')lines1, labels1 = ax1.get_legend_handles_labels()lines2, labels2 = ax2.get_legend_handles_labels()ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')plt.tight_layout()plt.show()```## Phân tích mức độ phơi nhiễm nhân tố {#sec-weight-factor-exposure}Các phương pháp xác định trọng số khác nhau tạo ra các mức độ phơi nhiễm nhân tố khác nhau, điều này có thể giải thích sự khác biệt về lợi nhuận giữa chúng. Chúng ta hồi quy lợi nhuận vượt mức của từng danh mục theo các nhân tố Fama-French của Việt Nam:$$R_{p,t} - R_{f,t} = \alpha_p + \beta_p^{MKT}(R_{m,t} - R_{f,t}) + \beta_p^{SMB} \text{SMB}_t + \beta_p^{HML} \text{HML}_t + \varepsilon_{p,t}$$ {#eq-factor-regression}```{python}#| label: factor-regressions#| eval: false#| code-summary: "Regress portfolio returns on Fama-French-Carhart factors"# Retrieve Vietnamese factor returns from DataCorefactors = client.get_factor_returns( market='vietnam', start_date='2012-01-01', end_date='2024-12-31', factors=['mkt_excess', 'smb', 'hml', 'wml'])# Run factor regressions for each portfoliofactor_results = {}for name, df in portfolios.items(): merged = pd.merge( df[['month_end', 'port_return']], factors, on='month_end' ) rf_m = (1.04) ** (1/12) -1 merged['excess'] = merged['port_return'] - rf_m model = sm.OLS( merged['excess'], sm.add_constant(merged[['mkt_excess', 'smb', 'hml', 'wml']]) ).fit(cov_type='HAC', cov_kwds={'maxlags': 6}) factor_results[name] = {'Alpha (ann.)': model.params['const'] *12,'Alpha t-stat': model.tvalues['const'],'MKT': model.params['mkt_excess'],'SMB': model.params['smb'],'HML': model.params['hml'],'WML': model.params['wml'],'R²': model.rsquared }factor_df = pd.DataFrame(factor_results).Tprint(factor_df.round(3).to_string())``````{python}#| label: fig-factor-exposures#| eval: false#| fig-cap: "Hệ số tải nhân tố theo phương pháp xác định trọng số. Các danh mục trọng số bằng nhau có hệ số tải SMB dương lớn, xác nhận thiên lệch quy mô mang tính cơ học của chúng. Các danh mục phương sai tối thiểu có hệ số tải âm trên nhân tố thị trường (beta < 1 theo cấu tạo). Cột alpha quy năm cho thấy liệu mỗi phương pháp có tạo ra lợi nhuận điều chỉnh theo rủi ro vượt quá mức độ phơi nhiễm nhân tố của nó hay không."#| code-summary: "Heatmap of factor exposures across weighting schemes"fig, ax = plt.subplots(figsize=(10, 6))plot_data = factor_df[['MKT', 'SMB', 'HML', 'WML']].copy()im = ax.imshow(plot_data.values, cmap='RdBu_r', aspect='auto', vmin=-1.5, vmax=1.5)ax.set_xticks(range(len(plot_data.columns)))ax.set_xticklabels(plot_data.columns, fontsize=10)ax.set_yticks(range(len(plot_data.index)))ax.set_yticklabels(plot_data.index, fontsize=9)# Add text annotationsfor i inrange(len(plot_data.index)):for j inrange(len(plot_data.columns)): val = plot_data.values[i, j] color ='white'ifabs(val) >0.8else'black' ax.text(j, i, f'{val:.2f}', ha='center', va='center', color=color, fontsize=9)plt.colorbar(im, ax=ax, label='Factor Loading')ax.set_title('Factor Exposures by Weighting Scheme')plt.tight_layout()plt.show()```## Học máy và đường biên hiệu quả có thể triển khai {#sec-weight-ief}Mọi phương pháp trong chương này đều được chọn trước rồi mới tính phí giao dịch sau. @jensen2026machine lập luận rằng thứ tự này là ngược đối với bất kỳ chiến lược nào giao dịch dựa trên tín hiệu, và đối tượng đánh giá đúng đắn là **đường biên hiệu quả có thể triển khai** (implementable efficient frontier): lợi nhuận kỳ vọng cao nhất *sau chi phí giao dịch* có thể đạt được tại mỗi mức rủi ro. Họ xây dựng một phương pháp danh mục xoay quanh mục tiêu đó, áp dụng học máy vào nó, và chỉ ra rằng thứ hạng của các chiến lược cũng như của các đặc điểm thúc đẩy chúng thay đổi một khi chi phí được đưa vào hàm mục tiêu thay vì chỉ nằm trong sổ sách kế toán.### Bài toán {#sec-weight-ief-problem}Một nhà đầu tư có tài sản $W_t$ nắm giữ các trọng số danh mục $\pi_t$ trong $N$ cổ phiếu. Giữa các ngày tái cân bằng, các trọng số trôi theo lợi nhuận, do đó danh mục kế thừa tại $t$ là $g_t \pi_{t-1}$, trong đó $g_t$ là ma trận đường chéo với các phần tử $(1 + r^f_t + r_{i,t})/(1 + g^W_t)$ và $g^W_t$ là tốc độ tăng của tài sản. Chuyển sang $\pi_t$ đòi hỏi giao dịch $W_t(\pi_t - g_t\pi_{t-1})$ tính theo đơn vị tiền tệ, và giao dịch làm giá dịch chuyển tuyến tính theo quy mô lệnh, với ma trận tác động giá đường chéo $\Lambda_t$. Lợi nhuận sau khi trừ lãi suất phi rủi ro và chi phí giao dịch là$$r^{\text{net}}_{t+1} = \pi_t^\top r_{t+1} - \frac{W_t}{2}\left(\pi_t - g_t\pi_{t-1}\right)^\top \Lambda_t \left(\pi_t - g_t\pi_{t-1}\right),$$ {#eq-weight-ief-net}và nhà đầu tư tối đa hóa giá trị trung bình của lợi nhuận này trừ $\gamma/2$ lần phương sai của nó. Hai đặc điểm của @eq-weight-ief-net đáng chú ý. Chi phí giao dịch có dạng toàn phương theo quy mô lệnh, đó là đặc tả tác động giá tuyến tính của @garleanu2013dynamic chứ không phải mô hình căn bậc hai trong @eq-tc-model; lựa chọn này đổi lấy tính khả giải với cái giá là đánh giá thấp chi phí của các lệnh nhỏ. Và chi phí tỷ lệ theo tài sản: cùng các trọng số đó tốn kém hơn đối với nhà đầu tư lớn hơn. Các tác giả hiệu chỉnh tác động giá sao cho giao dịch 1% giá trị giao dịch hằng ngày của một cổ phiếu làm giá dịch chuyển 0.1%, điều này cho ra $\lambda_{i,t} = 0.2 / \text{ADV}_{i,t}$.Đường biên được vạch ra bằng cách thay đổi $\gamma$ có một tính chất mà đường biên trong sách giáo khoa không có. Khi không có chi phí, mọi điểm trên đường thị trường vốn đều đạt được bằng cách sử dụng đòn bẩy với danh mục tiếp tuyến, do đó tỷ số Sharpe không đổi dọc theo đường đó. Khi có chi phí, vị thế lớn hơn đồng nghĩa với lệnh lớn hơn, nên tỷ số Sharpe sau chi phí *giảm dần* dọc theo đường biên có thể triển khai. Vì vậy, nhà đầu tư không thể tối đa hóa tỷ số Sharpe sau chi phí rồi mới chọn đòn bẩy; rủi ro, lợi nhuận và chi phí phải được chọn cùng nhau. Và đường biên của nhà đầu tư lớn hơn luôn nằm dưới đường biên của nhà đầu tư nhỏ hơn ở mọi điểm.### Lời giải: Giao dịch hướng về một mục tiêu {#sec-weight-ief-solution}Khái quát hóa @garleanu2013dynamic cho lợi nhuận dừng và cho lợi nhuận kỳ vọng là các hàm bất kỳ của các đặc điểm, các tác giả chỉ ra rằng danh mục tối ưu có dạng$$\pi_t = m\, g_t \pi_{t-1} + (I - m)\, A_t ,$$ {#eq-weight-ief-rule}do đó nhà đầu tư xuất phát từ danh mục kế thừa và giao dịch một phần $I - m$ của quãng đường hướng về một **danh mục mục tiêu** (aim portfolio) $A_t$. Ma trận $m$ là nghiệm của một phương trình điểm bất động theo $\gamma\Sigma$ và $W\Lambda$ và quyết định tốc độ giao dịch: nó tiến dần về ma trận đơn vị, nghĩa là giao dịch chậm hơn, khi tài sản hoặc tác động giá tăng và khi mức e ngại rủi ro giảm. Danh mục mục tiêu là tổng chiết khấu của các danh mục Markowitz hiện tại *và kỳ vọng trong tương lai*,$$A_t \approx (I - m)^{-1} \sum_{\tau = 0}^{\infty} (m\bar{g})^{\tau}\, c\; \mathbb{E}_t\!\left[\frac{1}{\gamma}\Sigma^{-1}\mu_{t+\tau}\right],$$ {#eq-weight-ief-aim}với $c$ là một ma trận phụ thuộc vào $m$. Vì nhà đầu tư sẽ nắm giữ bất cứ thứ gì mình mua hôm nay trong một thời gian, một tín hiệu dự báo lợi nhuận trong nhiều tháng có giá trị hơn một tín hiệu mạnh tương đương nhưng suy giảm trong vòng một tháng. Đây là nội dung kinh tế của "sự suy giảm alpha", và đó chính xác là điều mà một danh mục không quan tâm đến chi phí hoặc một danh mục tĩnh có nhận thức về chi phí bỏ qua. Phương án tĩnh, giao dịch hướng về danh mục Markowitz của hôm nay đồng thời phạt lệnh giao dịch của hôm nay, có cùng dạng điều chỉnh từng phần nhưng nhắm sai mục tiêu.### Portfolio-ML: Học trực tiếp danh mục mục tiêu {#sec-weight-ief-pfml}Triển khai @eq-weight-ief-aim đòi hỏi dự báo lợi nhuận ở mọi kỳ hạn dự báo. Phương pháp ưa thích của các tác giả, **Portfolio-ML**, bỏ qua hoàn toàn việc dự báo lợi nhuận và học trực tiếp danh mục mục tiêu. Nó tham số hóa danh mục mục tiêu như một hàm tuyến tính của các đặc trưng Fourier ngẫu nhiên của các đặc điểm, được chia theo biến động của từng cổ phiếu,$$A_t = \operatorname{diag}(\sigma_t)^{-1}\, \text{RF}(s_t)\,\beta ,$$ {#eq-weight-ief-parametric}một ý tưởng mở rộng các chính sách danh mục tham số của @brandt2009parametric sang bài toán động có chi phí giao dịch. Các đặc trưng ngẫu nhiên, $\cos(s_{i,t}^\top w_j)$ và $\sin(s_{i,t}^\top w_j)$ với các vectơ Gauss ngẫu nhiên $w_j$, cho phép một mô hình tuyến tính xấp xỉ các hàm phi tuyến và các tương tác của các đặc điểm, một thủ thuật mà các tính chất thống kê của nó trong dự báo lợi nhuận được @kelly2024virtue nghiên cứu. Thay @eq-weight-ief-parametric vào @eq-weight-ief-rule làm cho danh mục xấp xỉ tuyến tính theo $\beta$, $\pi_t \approx \Omega_t\beta$, trong đó $\Omega_t$ là trung bình có trọng số theo độ bền vững và tăng trưởng của các tín hiệu đã chia thang hiện tại và quá khứ. Khi đó mức thỏa dụng thực hiện là hàm toàn phương theo $\beta$, và các hệ số tối ưu có dạng đóng:$$\hat{\beta} = \left(\frac{1}{T}\sum_t D_t + \ell I\right)^{-1} \frac{1}{T}\sum_t \Omega_t^\top r_{t+1},\qquadD_t = \gamma\, \Omega_t^\top \Sigma_t \Omega_t + W\, \Delta\Omega_t^\top \Lambda_t\, \Delta\Omega_t ,$$ {#eq-weight-ief-beta}trong đó $\Delta\Omega_t = \Omega_t - g_t\Omega_{t-1}$ là lệnh giao dịch hàm ý và $\ell$ là hệ số phạt ridge. Số lượng đặc trưng ngẫu nhiên, thang đo của chúng và $\ell$ được chọn mỗi năm bằng cách kiểm định theo cửa sổ mở rộng trên mức thỏa dụng thực hiện. Không cần lớp tinh chỉnh thứ hai, vì hàm mục tiêu dùng để khớp $\beta$ chính là hàm mục tiêu của nhà đầu tư.### Bằng chứng {#sec-weight-ief-evidence}Ứng dụng sử dụng các cổ phiếu Mỹ có vốn hóa trên trung vị của NYSE, 115 đặc điểm từ @jensen2023there, một ma trận hiệp phương sai theo mô hình nhân tố theo phong cách của các mô hình rủi ro thương mại, và giai đoạn ngoài mẫu từ 1981 đến 2020 cho một nhà đầu tư có mức e ngại rủi ro 10 với quỹ tăng cùng thị trường lên 10 tỷ USD vào năm 2020. Các con số dưới đây lấy từ phiên bản bài báo thảo luận của các tác giả và có thể khác đôi chút so với bài báo đã xuất bản.- **Học máy không quan tâm đến chi phí thì không thể triển khai được.** Danh mục Markowitz xây dựng trên các dự báo lợi nhuận bằng học máy có tỷ số Sharpe gộp là 2.0 nhưng vòng quay cực đoan đến mức lợi nhuận sau chi phí âm một cách thảm hại. Một phép phân loại thập phân vị mua–bán khống chuẩn trên cùng các dự báo đó có vòng quay 260% vốn mỗi tháng và tỷ số Sharpe sau chi phí khoảng −12.- **Nhận thức tĩnh về chi phí có ích nhưng đặt nhà đầu tư sai vị trí.** Phương pháp tĩnh đạt tỷ số Sharpe sau chi phí dương là 0.94 nhưng mức thỏa dụng thực hiện âm, vì rủi ro thực hiện của nó vượt xa rủi ro ex ante.- **Portfolio-ML vượt trội.** Nó đạt tỷ số Sharpe sau chi phí là 1.38 với vòng quay hằng tháng là 32%, và mức thỏa dụng thực hiện cao nhất. Ngay cả sau khi các tác giả trao cho các phương pháp dựa trên dự báo một lớp tinh chỉnh thứ hai để điều chỉnh lại thang đo đầu vào của chúng, Portfolio-ML vẫn vượt chúng khoảng 20% về tỷ số Sharpe sau chi phí và 60% về mức thỏa dụng.- **Chi phí làm thay đổi đặc điểm nào là quan trọng.** Đo bằng mức thỏa dụng bị mất khi một đặc điểm bị loại bỏ, "tầm quan trọng kinh tế của đặc điểm" của đảo chiều ngắn hạn, vốn chi phối các mô hình không quan tâm đến chi phí, sụp đổ, còn giá trị và chất lượng trở thành các đặc điểm quan trọng nhất.Mã nguồn tái lập của các tác giả được công khai ([GitHub: theisij/ml-and-the-implementable-efficient-frontier](https://github.com/theisij/ml-and-the-implementable-efficient-frontier)). Mã được viết bằng R, đòi hỏi dữ liệu đặc điểm của @jensen2023there từ WRDS, và được thiết kế cho một cụm máy tính. Kho mã không kèm giấy phép, vì vậy chúng tôi không sao chép lại ở đây. Mã dưới đây là một phần triển khai Portfolio-ML độc lập bằng Python, bám theo các công thức ở trên, được kiểm tra trước tiên trong một mô phỏng rồi áp dụng cho dữ liệu Việt Nam.### Một phần triển khai độc lập {#sec-weight-ief-code}Cốt lõi của phương pháp gồm năm hàm nhỏ: ma trận tốc độ giao dịch $m$, việc chia thang tín hiệu, các đặc trưng Fourier ngẫu nhiên, ma trận trọng số hàm ý $\Omega_t$, và các hệ số dạng đóng của @eq-weight-ief-beta.```{python}#| label: portfolio-ml-core#| code-summary: "Core functions for Portfolio-ML"def trading_speed(sigma, lam, wealth, gamma, mu=0.007, rf=0.0, iters=10):""" Persistence matrix m in the optimal rule pi_t = m g_t pi_{t-1} + (I - m) A_t. Solves the fixed point m~ = (x + I + diag(G) - m~ o G)^{-1}, where x = (gamma / wealth) Lambda^{-1/2} Sigma Lambda^{-1/2}, G is the second moment of portfolio-weight growth, and m = Lambda^{-1/2} m~ Lambda^{1/2}. `lam` is the diagonal of the price-impact matrix (per unit of currency). """ n =len(lam) G = np.ones((n, n)) + sigma / (1+ rf + mu) **2 lam_isqrt =1/ np.sqrt(lam) x = (gamma / wealth) * sigma * np.outer(lam_isqrt, lam_isqrt)# Start from the closed-form solution when weight growth is deterministic ev, evec = np.linalg.eigh(x +2* np.eye(n)) m_tilde = evec @ np.diag(0.5* (ev - np.sqrt(ev**2-4))) @ evec.T base = x + np.eye(n) + np.diag(np.diag(G))for _ inrange(iters): m_tilde = np.linalg.inv(base - m_tilde * G)return lam_isqrt[:, None] * m_tilde * np.sqrt(lam)[None, :]def scale_signals(features, vol):"""Demean features, add a constant, give each column unit length, divide rows by volatility.""" f = features - features.mean(axis=0) f = np.column_stack([np.ones(len(f)), f]) f = f / np.sqrt((f**2).sum(axis=0))return f / vol[:, None]def random_fourier_features(features, W):"""Random Fourier features: cos and sin of random projections of the features.""" z = features @ Wreturn np.hstack([np.cos(z), np.sin(z)])def implied_weights(S_hist, g_hist, m):""" Omega_t such that the portfolio implied by aim coefficients beta is Omega_t beta. S_hist[i] is the scaled signal matrix i months ago (i = 0 is today); g_hist[i] is the weight-growth vector realized over the month ending i months ago. """ n = m.shape[0] M, num, den = np.eye(n), np.zeros_like(S_hist[0]), np.zeros((n, n))for i, S_i inenumerate(S_hist): num += M @ S_i den += Mif i <len(g_hist): M = M @ m @ np.diag(g_hist[i])return np.linalg.solve(den, num)def portfolio_ml_moments(S, g, ret, sigma, lam, m, wealth, gamma, months, lookback=11):"""Per-month ingredients of the Portfolio-ML objective: r_tilde_t and D_t.""" r_tilde, D = {}, {}for t in months: om = implied_weights([S[t - i] for i inrange(lookback +1)], [g[t -1- i] for i inrange(lookback)], m) om_prev = implied_weights([S[t -1- i] for i inrange(lookback +1)], [g[t -2- i] for i inrange(lookback)], m) trade = om - g[t -1][:, None] * om_prev r_tilde[t] = om.T @ ret[t] D[t] = gamma * om.T @ sigma @ om + wealth * trade.T @ (lam[:, None] * trade)return r_tilde, Ddef fit_aim_coefficients(r_tilde, D, months, ridge):"""Closed-form Portfolio-ML coefficients: (mean D + ridge I)^{-1} mean r_tilde.""" r_bar = np.mean([r_tilde[t] for t in months], axis=0) D_bar = np.mean([D[t] for t in months], axis=0)return np.linalg.solve(D_bar + ridge * np.eye(len(r_bar)), r_bar)def objective(beta, r_tilde, D, months):"""Average realized utility r_tilde' beta - beta' D beta / 2 over the given months."""return np.mean([r_tilde[t] @ beta -0.5* beta @ D[t] @ beta for t in months])```Để quan sát phương pháp hoạt động ở nơi ta biết sự thật, mô phỏng dưới đây tạo ra các thị trường gồm 60 cổ phiếu trong 35 năm với hai tín hiệu có độ bền vững rất khác nhau: một tín hiệu kiểu giá trị với tự tương quan hằng tháng là 0.97 và một tín hiệu kiểu đảo chiều độc lập từ tháng này sang tháng khác nhưng có khả năng dự báo gấp ba lần trên mỗi đơn vị. Thanh khoản thay đổi giữa các cổ phiếu ở mức ba bậc độ lớn, và nhà đầu tư có 10 tỷ USD và mức e ngại rủi ro 10. Bốn quy tắc cạnh tranh trong mười năm cuối. Ba trong số đó là các *oracle* (mô hình biết trước), tức biết lợi nhuận kỳ vọng thật: danh mục Markowitz không quan tâm đến chi phí, danh mục tĩnh có nhận thức về chi phí, và phương án tối ưu động của @eq-weight-ief-rule và @eq-weight-ief-aim được tính từ động học tín hiệu đã biết. Portfolio-ML không biết gì về quá trình sinh dữ liệu và học các hệ số của nó từ 25 năm đầu, một lần chỉ với hai tín hiệu và một lần với thêm thứ hạng thanh khoản cùng các tương tác của nó với các tín hiệu làm đặc trưng. Lặp lại bài tập trên 20 thị trường mô phỏng giúp tránh việc đọc quá nhiều vào một lần rút mẫu.```{python}#| label: portfolio-ml-simulation#| code-summary: "Simulated markets with persistent and fleeting signals"GAMMA, WEALTH, LOOKBACK =10.0, 1e10, 11N_STOCKS, N_MONTHS =60, 420TRAIN_END, TEST_START =240, 300RIDGES = [0.0] +list(np.exp(np.arange(-10, 7)))def simulate_market(seed, phi_slow=0.97, b_slow=0.04, b_fast=0.12):"""A market with one persistent and one fleeting signal and heterogeneous liquidity.""" rng = np.random.default_rng(seed) n, T = N_STOCKS, N_MONTHS dolvol = np.exp(rng.normal(np.log(2e8), 1.2, n)) # daily dollar volume lam =0.2/ dolvol # 1% of volume moves price 0.1% beta_mkt = rng.uniform(0.6, 1.4, n) ivol = rng.uniform(0.06, 0.12, n) sigma =0.045**2* np.outer(beta_mkt, beta_mkt) + np.diag(ivol**2) vol = np.sqrt(np.diag(sigma)) s_slow = np.zeros((T, n)) s_slow[0] = rng.normal(size=n)for t inrange(1, T): s_slow[t] = phi_slow * s_slow[t -1] + np.sqrt(1- phi_slow**2) * rng.normal(size=n) s_fast = rng.normal(size=(T, n)) mu = vol * (b_slow * s_slow + b_fast * s_fast) # E_t[r_{t+1}] ret = mu + (np.linalg.cholesky(sigma) @ rng.normal(size=(n, T))).T g = (1+ ret) / (1+ ret.mean(axis=1))[:, None] # weight growth liq = (np.argsort(np.argsort(dolvol)) +0.5) / n -0.5returndict(lam=lam, sigma=sigma, vol=vol, s_slow=s_slow, s_fast=s_fast, mu=mu, ret=ret, g=g, liq=liq, phi_slow=phi_slow, b_slow=b_slow, b_fast=b_fast)def backtest(mkt, weight_rule, months):"""Realized gross return, trading cost, and turnover of a weight rule.""" rows, w_prev = [], np.zeros(N_STOCKS)for t in months: w_start = mkt["g"][t -1] * w_prev w = weight_rule(t, w_start) trade = w - w_start rows.append({"gross": w @ mkt["ret"][t],"tc": 0.5* WEALTH * np.sum(mkt["lam"] * trade**2),"turnover": np.abs(trade).sum()}) w_prev = w out = pd.DataFrame(rows) out["net"] = out["gross"] - out["tc"]return outdef run_one(seed): mkt = simulate_market(seed) T, n = N_MONTHS, N_STOCKS sigma, lam, vol = mkt["sigma"], mkt["lam"], mkt["vol"] m = trading_speed(sigma, lam, WEALTH, GAMMA) I_n, I_m = np.eye(n), np.eye(n) - m sig_inv = np.linalg.inv(sigma)# Oracle benchmarks that know the true expected returns markowitz = np.einsum("ij,tj->ti", sig_inv, mkt["mu"]) / GAMMA static_inv = np.linalg.inv(GAMMA * sigma + WEALTH * np.diag(lam)) mk_fast = np.einsum("ij,tj->ti", sig_inv, vol * mkt["b_fast"] * mkt["s_fast"]) / GAMMA mk_slow = np.einsum("ij,tj->ti", sig_inv, vol * mkt["b_slow"] * mkt["s_slow"]) / GAMMA slow_boost = np.linalg.solve(I_n - mkt["phi_slow"] * m, I_m) rules = {"Markowitz (oracle, cost-agnostic)": lambda t, w0: markowitz[t],"Static (oracle)": lambda t, w0: static_inv @ (mkt["mu"][t] + WEALTH * lam * w0),"Dynamic optimum (oracle)":lambda t, w0: m @ w0 + I_m @ (I_m @ mk_fast[t] + slow_boost @ mk_slow[t]), }# Portfolio-ML learned from data, with and without liquidity interactions liq = np.tile(mkt["liq"], (T, 1)) feature_sets = {"Portfolio-ML: signals only": np.stack([mkt["s_slow"], mkt["s_fast"]], axis=2),"Portfolio-ML: signals x liquidity": np.stack( [mkt["s_slow"], mkt["s_fast"], liq, mkt["s_slow"] * liq, mkt["s_fast"] * liq], axis=2), } months_fit =range(LOOKBACK +2, T -1) train = [t for t in months_fit if t < TRAIN_END] valid = [t for t in months_fit if TRAIN_END <= t < TEST_START]for name, feats in feature_sets.items(): S = np.stack([scale_signals(feats[t], vol) for t inrange(T)]) r_tilde, D = portfolio_ml_moments(S, mkt["g"], mkt["ret"], sigma, lam, m, WEALTH, GAMMA, months_fit, LOOKBACK) ridge =max(RIDGES, key=lambda l: objective( fit_aim_coefficients(r_tilde, D, train, l), r_tilde, D, valid)) beta = fit_aim_coefficients(r_tilde, D, train + valid, ridge) rules[name] = (lambda S_, b_: lambda t, w0: m @ w0 + I_m @ (S_[t] @ b_))(S, beta) test =list(range(TEST_START, T -1)) rows = []for name, rule in rules.items(): bt = backtest(mkt, rule, test) rows.append({"seed": seed, "Method": name,"Net Sharpe": np.sqrt(12) * bt["net"].mean() / bt["net"].std(),"Trading cost": 12* bt["tc"].mean(),"Utility": 12* (bt["net"].mean() -0.5* GAMMA * bt["net"].var()),"Turnover": bt["turnover"].mean()})return pd.DataFrame(rows)sim_results = pd.concat([run_one(seed) for seed inrange(20)], ignore_index=True)``````{python}#| label: tbl-portfolio-ml-simulation#| tbl-cap: "Hiệu quả ngoài mẫu trong các thị trường mô phỏng: Trung vị trên 20 thị trường (mức e ngại rủi ro 10, tài sản 10 tỷ)"order = ["Markowitz (oracle, cost-agnostic)", "Static (oracle)","Portfolio-ML: signals only", "Portfolio-ML: signals x liquidity","Dynamic optimum (oracle)"]summary = (sim_results.groupby("Method")[["Net Sharpe", "Trading cost", "Utility", "Turnover"]] .median().loc[order])wide = sim_results.pivot(index="seed", columns="Method", values="Utility")summary["Beats static oracle (share of seeds)"] = [ (wide[name] > wide["Static (oracle)"]).mean() for name in order]summary.round(3)```Mô phỏng tái hiện logic của bài báo ở quy mô thu nhỏ. Danh mục Markowitz không quan tâm đến chi phí bị phá hủy bởi chính hoạt động giao dịch của nó dù nó biết lợi nhuận kỳ vọng thật. Phương án tối ưu động đánh bại oracle tĩnh trong hầu hết các thị trường mặc dù cả hai đều có cùng các dự báo một tháng hoàn hảo: nhắm vào danh mục Markowitz của hôm nay bỏ qua tính bền vững của tín hiệu kiểu giá trị và trả giá quá cao để đuổi theo tín hiệu kiểu đảo chiều. Portfolio-ML, vốn phải học mọi thứ từ dữ liệu, đạt tỷ số Sharpe sau chi phí cao hơn oracle tĩnh với vòng quay chỉ bằng khoảng một nửa, và mức thỏa dụng tương đương. Nó thua phương án tối ưu động vì nó ước lượng có nhiễu những gì oracle đã biết.Hai biến thể Portfolio-ML đem lại một bài học về các đặc trưng. Danh mục mục tiêu tối ưu đối xử với cổ phiếu thanh khoản và kém thanh khoản một cách khác nhau: nó giao dịch hướng theo tín hiệu nhanh ở các cổ phiếu thanh khoản và chậm ở các cổ phiếu kém thanh khoản, và sẵn sàng nắm giữ các tín hiệu bền vững ở các cổ phiếu kém thanh khoản nhưng không nắm giữ các tín hiệu thoáng qua. Một quy tắc chỉ tuyến tính theo các tín hiệu không thể biểu diễn điều này, nên nó chỉ có thể thu nhỏ mọi thứ, biểu hiện là vòng quay rất thấp và mức thỏa dụng thấp hơn. Thêm thanh khoản và các tương tác của nó sẽ khôi phục phần lớn khoảng cách. Trong bài báo, các đặc trưng Fourier ngẫu nhiên của 115 đặc điểm, một số trong đó đo thanh khoản, tự động sinh ra các tương tác như vậy.Khi áp dụng phương pháp cho cổ phiếu Việt Nam, chúng ta sử dụng tập hợp đầu tư thanh khoản và bộ ước lượng hiệp phương sai Ledoit-Wolf đã định nghĩa ở phần trước của chương này, bảng đặc điểm từ chương xây dựng nhân tố, và tác động giá được hiệu chỉnh theo giá trị giao dịch trung bình hằng ngày của từng cổ phiếu tính bằng VND. Mã không được chạy ở đây: việc tìm kiếm siêu tham số phải khớp lại mô hình cho mọi tổ hợp của số đặc trưng ngẫu nhiên, thang đo đặc trưng và hệ số phạt ridge, và mất vài phút cho mỗi cặp tài sản và mức e ngại rủi ro.```{python}#| label: pfml-vn-data#| eval: false#| code-summary: "Characteristics and wide panels for Portfolio-ML"# Characteristics panel from the factor-construction chaptercharacteristics = client.get_characteristics_panel( exchanges=["HOSE", "HNX"], start_date="2008-01-01", end_date="2024-12-31", include_delisted=True,)char_cols = [c for c in characteristics.columns if c notin ["ticker", "month_end"]]# Rank-standardize each characteristic to [0, 1] within month; missing -> 0.5ranked = characteristics.copy()ranked[char_cols] = (ranked.groupby("month_end")[char_cols] .rank(pct=True).fillna(0.5))# Wide month x ticker panels on the liquid universeret_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="monthly_return").sort_index()adv_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="turnover_value_avg_20d").reindex(ret_wide.index)mcap_wide = universe_liquid.pivot(index="month_end", columns="ticker", values="market_cap").reindex(ret_wide.index)char_wide = {c: ranked.pivot(index="month_end", columns="ticker", values=c) .reindex(index=ret_wide.index, columns=ret_wide.columns)for c in char_cols}# Value-weighted market return drives the growth of portfolio weightslag_mcap = mcap_wide.shift(1).where(ret_wide.notna())mkt_ret = (ret_wide * lag_mcap).sum(axis=1) / lag_mcap.sum(axis=1)months = ret_wide.index``````{python}#| label: pfml-vn-inputs#| eval: false#| code-summary: "Monthly universe, covariance, and price impact"LOOKBACK =11_base_cache = {}def base_state(i, lookback=LOOKBACK):""" Universe, covariance, and price impact at month i (no look-ahead). A stock is investable if it has returns over the last lookback + 2 months. Next month's return is used only to evaluate the decision; a missing value (for example, a delisting) is recorded as zero. """if i in _base_cache:return _base_cache[i] window = ret_wide.iloc[i - lookback -1:i +1] tickers = window.columns[window.notna().all()] cov, cov_tickers = estimate_covariance(universe_liquid, months[i]) state =Noneif cov isnotNone: adv = adv_wide.iloc[i] tickers = [t for t in tickers if t in cov_tickers and adv.get(t, 0) >0]iflen(tickers) >=30: sigma = cov.loc[tickers, tickers].values# growth[k] = growth of portfolio weights over month i - k growth = ((1+ window[tickers].values)/ (1+ mkt_ret.iloc[i - lookback -1:i +1].values)[:, None])[::-1] state =dict(tickers=tickers, sigma=sigma, vol=np.sqrt(np.diag(sigma)), lam=0.2/ adv[tickers].values, # 1% of ADV moves price 0.1% growth=growth, ret_next=ret_wide.iloc[i +1][tickers].fillna(0).values) _base_cache[i] = statereturn statedef month_signals(i, tickers, vol, W, lookback=LOOKBACK):"""Scaled random-feature signals for months i, i-1, ..., i-lookback-1.""" out = []for lag inrange(lookback +2): X = np.column_stack([char_wide[c].iloc[i - lag][tickers].fillna(0.5).valuesfor c in char_cols]) out.append(scale_signals(random_fourier_features(X, W), vol))return out``````{python}#| label: pfml-vn-backtest#| eval: false#| code-summary: "Tuning and out-of-sample backtest of Portfolio-ML"def run_portfolio_ml(wealth, gamma, first_val_year=2017, first_test_year=2020, p_grid=(16, 32, 64), g_grid=(np.exp(-3), np.exp(-2)), ridges=(0.0, *np.exp(np.arange(-10, 7))), cost_mult=1.0, long_only=False, seed=1, lookback=LOOKBACK):"""Portfolio-ML with yearly expanding-window tuning and an out-of-sample backtest.""" rng = np.random.default_rng(seed) states = {}for i inrange(lookback +2, len(months) -1): st = base_state(i, lookback)if st isNone:continue lam = cost_mult * st["lam"] m = trading_speed(st["sigma"], lam, wealth, gamma, mu=mkt_ret.iloc[:i].mean()) states[i] = {**st, "lam": lam, "m": m} year = {i: months[i].year for i in states} last_year =max(year.values())# Step 1: objective ingredients for every (p, g) and month candidates = {}for p in p_grid:for g_rff in g_grid: W = rng.normal(0, np.sqrt(g_rff), size=(len(char_cols), p //2)) r_tilde, D, S_now = {}, {}, {}for i, st in states.items(): S_hist = month_signals(i, st["tickers"], st["vol"], W, lookback) om = implied_weights(S_hist[:lookback +1], st["growth"][:lookback], st["m"]) om_prev = implied_weights(S_hist[1:], st["growth"][1:lookback +1], st["m"]) trade = om - st["growth"][0][:, None] * om_prev r_tilde[i] = om.T @ st["ret_next"] D[i] = (gamma * om.T @ st["sigma"] @ om+ wealth * trade.T @ (st["lam"][:, None] * trade)) S_now[i] = S_hist[0]# Step 2: expanding-window validation, one year at a timefor l in ridges: val = {}for y inrange(first_val_year, last_year +1): fit_m = [i for i in states if year[i] < y] eval_m = [i for i in states if year[i] == y]if fit_m and eval_m: beta = fit_aim_coefficients(r_tilde, D, fit_m, l) val[y] = objective(beta, r_tilde, D, eval_m) candidates[(p, g_rff, l)] =dict(r=r_tilde, D=D, S=S_now, val=val)# Step 3: each test year, use the best hyperparameters so far and trade rows, w_prev, lam_prev = [], pd.Series(dtype=float), pd.Series(dtype=float)for y inrange(first_test_year, last_year +1): best =max(candidates, key=lambda h: np.mean( [v for yy, v in candidates[h]["val"].items() if yy < y] or [-np.inf])) c = candidates[best] beta = fit_aim_coefficients(c["r"], c["D"], [i for i in states if year[i] < y], best[2])for i in [i for i in states if year[i] == y]: st, tick = states[i], states[i]["tickers"] w_start = w_prev.reindex(tick).fillna(0).values # already grown aim = c["S"][i] @ beta w = st["m"] @ w_start + (np.eye(len(tick)) - st["m"]) @ aimif long_only: w = np.clip(w, 0, None) trade = w - w_start exits = w_prev.index.difference(tick) # forced liquidations tc = (0.5* wealth * np.sum(st["lam"] * trade**2)+0.5* wealth * np.sum(lam_prev.reindex(exits).fillna(0).values* w_prev.reindex(exits).fillna(0).values**2)) rows.append({"month": months[i +1], "gross": w @ st["ret_next"], "tc": tc,"turnover": np.abs(trade).sum(), "leverage": np.abs(w).sum(),"p": best[0], "ridge": best[2]}) w_prev = pd.Series(w * (1+ st["ret_next"]) / (1+ mkt_ret.iloc[i +1]), index=tick) lam_prev = pd.Series(st["lam"], index=tick) bt = pd.DataFrame(rows) bt["net"] = bt["gross"] - bt["tc"]return btdef summarize(bt, gamma):return pd.Series({"Gross return": 12* bt["gross"].mean(),"Trading cost": 12* bt["tc"].mean(),"Net return": 12* bt["net"].mean(),"Volatility": np.sqrt(12) * bt["net"].std(),"Net Sharpe": np.sqrt(12) * bt["net"].mean() / bt["net"].std(),"Utility": 12* (bt["net"].mean() -0.5* gamma * bt["net"].var()),"Turnover": bt["turnover"].mean(),"Leverage": bt["leverage"].mean(), })``````{python}#| label: tbl-pfml-vn#| eval: false#| tbl-cap: "Đường biên hiệu quả có thể triển khai cho cổ phiếu Việt Nam: Portfolio-ML theo quy mô quỹ và mức e ngại rủi ro"# Points on the implementable efficient frontier for three fund sizesfrontier = {}for wealth in [1e11, 1e12, 1e13]: # VND 100 billion, 1 trillion, 10 trillionfor gamma in [5, 10, 20]:for long_only in [False, True]: bt = run_portfolio_ml(wealth, gamma, long_only=long_only) frontier[(f"{wealth /1e9:,.0f}bn", gamma,"long-only"if long_only else"long-short")] = summarize(bt, gamma)frontier = pd.DataFrame(frontier).Tfrontier.index.names = ["Wealth (VND)", "Risk aversion", "Positions"]frontier.round(3)```### Ý nghĩa đối với nghiên cứu về Việt Nam {#sec-weight-ief-vietnam}Lập luận ủng hộ phương pháp này ở Việt Nam mạnh hơn so với tập hợp cổ phiếu vốn hóa lớn của Mỹ mà các tác giả nghiên cứu, vì các lực mà nó được thiết kế để xử lý ở đây lớn hơn: thanh khoản tập trung vào vài chục cổ phiếu (@sec-weight-data), và nhiều hiện tượng bất thường được ghi nhận cho cổ phiếu Việt Nam nằm chính ở các cổ phiếu nhỏ, kém thanh khoản, nơi chi phí có tác động mạnh. Cần có một số điều chỉnh.1. **Không có bán khống.** Portfolio-ML như được đặc tả là một danh mục mua–bán khống, và ở Việt Nam nó là một đối tượng nghiên cứu chứ không phải một quỹ: nó đo xem đặc điểm nào mang lại giá trị sau chi phí và đường biên co lại như thế nào theo quy mô. Biến thể chỉ mua trong mã đơn giản đặt các trọng số âm về không sau mỗi lần tái cân bằng. Đó là một phép chiếu mang tính kinh nghiệm, không phải nghiệm tối ưu có ràng buộc. Một phiên bản được ràng buộc đúng đắn sẽ tối đa hóa cùng hàm mục tiêu bằng phương pháp số với $\pi_t \geq 0$, hoặc làm việc với các trọng số chủ động quanh một chuẩn so sánh như VN30, trong đó việc nắm giữ thấp hơn trọng số chuẩn (mức thấp hơn tối đa bằng chính trọng số chuẩn) thay thế cho vị thế bán khống.2. **Hiệu chỉnh tác động giá cho Việt Nam và kiểm tra áp lực.** Hiệu chỉnh của Mỹ (tác động 0.1% cho 1% khối lượng hằng ngày) gần như chắc chắn đánh giá thấp tác động đối với các cổ phiếu vốn hóa trung bình của Việt Nam. Hãy báo cáo kết quả với các hệ số nhân chi phí 1, 2 và 4 (đối số `cost_mult`), và so sánh các chi phí hàm ý với mô hình chi phí giao dịch trong @eq-tc-model.3. **Mô hình thiếu các chi phí tỷ lệ.** Phí môi giới và thuế bán 0.1% là tuyến tính theo quy mô lệnh (@tbl-weight-costs), và các chi phí tuyến tính tạo ra một vùng không giao dịch mà mô hình toàn phương không thể tạo ra. Ít nhất hãy trừ chúng trong kiểm định ngược; coi một kết quả chỉ tồn tại khi không có chúng là không thể triển khai.4. **Tôn trọng các quy tắc của thị trường.** Biên độ giá hằng ngày giới hạn lượng vị thế có thể giao dịch trong một ngày khi cổ phiếu biến động mạnh, và giới hạn sở hữu nước ngoài khiến một số cổ phiếu không thể mua được đối với nhà đầu tư nước ngoài. Với phiên bản dành cho nhà đầu tư nước ngoài, hãy loại các cổ phiếu đang chạm giới hạn khỏi phía mua.5. **Mẫu ngắn.** Với các đặc điểm từ năm 2008, cửa sổ hiệp phương sai 60 tháng và kiểm định theo năm, chỉ một vài năm kiểm định cung cấp thông tin cho việc chọn siêu tham số. Hãy giữ số đặc trưng ngẫu nhiên ở mức nhỏ, để hệ số phạt ridge phát huy tác dụng, và báo cáo mức độ nhạy của kết quả với hạt giống ngẫu nhiên dùng để rút các đặc trưng.6. **Dùng tầm quan trọng kinh tế của đặc điểm làm kiểm định hiện tượng bất thường.** Việc loại bỏ từng đặc điểm một và đo mức giảm thỏa dụng sau chi phí sẽ trả lời câu hỏi mà vườn thú nhân tố (factor zoo, @sec-factor-zoo-multiple-testing) không trả lời được: hiện tượng bất thường nào của Việt Nam đáng để giao dịch đối với một nhà đầu tư có quy mô nhất định sau khi đã trả chi phí. Câu trả lời sẽ khác nhau theo quy mô quỹ, và đường biên trên các mức 100 tỷ, 1 nghìn tỷ và 10 nghìn tỷ VND cho thấy tập hợp cơ hội thu hẹp nhanh đến mức nào về phía các cổ phiếu thanh khoản nhất.## Hướng dẫn thực tiễn cho Việt Nam {#sec-weight-practical}Phân tích ở trên đưa ra một số khuyến nghị thực tiễn cho các nhà nghiên cứu và nhà đầu tư làm việc với cổ phiếu Việt Nam:**Đối với nghiên cứu nhân tố học thuật:** Danh mục VW vẫn là mặc định cho các kiểm định định giá tài sản vì chúng đại diện cho tập hợp cơ hội có thể đầu tư và tránh làm thổi phồng các ước lượng alpha bằng phần bù phi thanh khoản của cổ phiếu nhỏ. Khi dùng danh mục EW (ví dụ để mỗi cổ phiếu có ảnh hưởng ngang nhau trong các phép phân loại theo mặt cắt ngang), nhà nghiên cứu nên báo cáo cả kết quả VW và EW và thảo luận về độ nhạy. @fama2008dissecting tuân theo thông lệ này một cách có hệ thống.**Đối với quản lý quỹ:** Lựa chọn phụ thuộc vào AUM và ủy thác đầu tư. Ở mức AUM dưới 500 tỷ VND, VW có giới hạn trần hoặc trọng số theo yếu tố cơ bản đem lại một sự dung hòa thực tiễn giữa đa dạng hóa và khả năng triển khai. Ở mức AUM lớn hơn, VW thuần túy hoặc VW có giới hạn trần theo ngành thực tế hơn. Ngang bằng rủi ro và phương sai tối thiểu phù hợp với các ủy thác biến động thấp nhưng đòi hỏi ước lượng hiệp phương sai vững và tái cân bằng hằng quý.**Đối với xây dựng chỉ số:** Các nhà cung cấp chỉ số của Việt Nam (VN30, VNINDEX) dùng các biến thể của VW có giới hạn trần. Phân tích gợi ý rằng mức trần ảnh hưởng đáng kể đến đặc tính đa dạng hóa và sai số bám sát của chỉ số so với thị trường VW không giới hạn. Mức trần 10% cân bằng giữa việc giảm tập trung và vòng quay.**Đối với quản lý chi phí giao dịch:** Ở mọi phương pháp, lợi ích biên của việc tái cân bằng giảm nhanh hơn chi phí biên khi tần suất vượt quá hằng quý. Tái cân bằng hằng quý theo lịch hoặc tái cân bằng dựa trên ngưỡng (với dải dung sai 1–2%) mang lại sự đánh đổi chi phí–lợi ích tốt nhất trên thị trường Việt Nam.## Tóm tắt {#sec-weight-summary}| Khía cạnh | VW | EW | VW có trần | Theo yếu tố cơ bản | Phương sai tối thiểu | Ngang bằng rủi ro ||-----------|-----------|-----------|-----------|-----------|-----------|-----------|| Vòng quay | Rất thấp | Cao | Thấp | Thấp | Trung bình | Trung bình || Mức tập trung | Cao | Không | Trung bình | Trung bình | Thay đổi | Thấp || Thiên lệch quy mô | Lớn | Nhỏ | Trung bình | Lớn–trung bình | Biến động thấp | Hỗn hợp || Dữ liệu cần thiết | Giá | Không | Giá | Kế toán | Hiệp phương sai lợi nhuận | Hiệp phương sai lợi nhuận || Độ nhạy theo quy mô | Thấp | Cao | Thấp | Thấp | Trung bình | Trung bình || Tần suất tái cân bằng | Thụ động | Hằng tháng | Hằng tháng/Hằng quý | Hằng năm | Hằng quý | Hằng quý || Trường hợp sử dụng tốt nhất | Chuẩn so sánh, AUM lớn | Kiểm định theo mặt cắt ngang | Bám sát chỉ số | Đầu tư dài hạn | Ủy thác biến động thấp | Rủi ro cân bằng |: So sánh tóm tắt các phương pháp xác định trọng số đối với cổ phiếu Việt Nam. {#tbl-weight-final-comparison}Việc lựa chọn phương pháp xác định trọng số không chỉ là một chi tiết kỹ thuật, mà phản ánh một quyết định kinh tế thực chất về tầm quan trọng tương đối của đa dạng hóa, khả năng đầu tư và kiểm soát chi phí. Trên thị trường Việt Nam, nơi phân phối vốn hóa lệch mạnh và thanh khoản của cổ phiếu vốn hóa nhỏ mỏng, lựa chọn này có hệ quả lớn hơn so với các thị trường phát triển. Những nhà nghiên cứu chỉ báo cáo kết quả theo một phương pháp xác định trọng số duy nhất có nguy cơ rút ra các kết luận đặc thù cho phương pháp đó thay vì phản ánh một mối quan hệ kinh tế thực sự.```{=html}<!-- ## Exercises {#sec-weight-exercises}1. **Inverse volatility weighting.** Implement a weighting scheme where $w_i \propto 1/\sigma_i$ (inverse of trailing 60-day volatility). Compare its performance to equal-weight and risk parity. How does it differ from minimum variance?2. **Turnover-constrained optimization.** Modify the minimum variance portfolio to include a turnover constraint: $\sum_i |w_i - w_{i,\text{prev}}| \leq \tau$ for a given turnover budget $\tau$. Solve this as a quadratic program with linear constraints. How does the frontier of risk vs. turnover look?3. **Liquidity-weighted portfolios.** Construct a portfolio where weights are proportional to average daily turnover rather than market cap. What economic interpretation does this portfolio have? How does it compare to VW in terms of factor exposures?4. **VN30 replication.** The VN30 index comprises the 30 largest and most liquid stocks on HOSE with a 10% cap. Replicate its construction rules using DataCore.vn data and compare the tracking error of your replication against the official VN30 index.5. **Conditional rebalancing bonus.** Estimate the rebalancing bonus separately in bull markets (VN-Index return > 0) and bear markets (VN-Index return < 0). Is the bonus pro-cyclical or counter-cyclical? Relate your findings to the cross-sectional dispersion pattern in @fig-rebal-bonus-time.6. **Covariance estimation comparison.** Compare the out-of-sample performance of minimum variance portfolios using: (a) sample covariance, (b) Ledoit-Wolf linear shrinkage, (c) nonlinear shrinkage [@ledoit2017nonlinear], and (d) a single-factor covariance model. Which estimator produces the lowest realized portfolio variance?7. **Price limit effects on rebalancing.** Vietnamese stocks face daily price limits ($\pm$ 7% HOSE, $\pm$ 10% HNX). Simulate the impact of price limits on rebalancing execution: when a stock hits limit-up or limit-down, the rebalancing trade cannot execute. How does this partial execution affect realized portfolio weights and performance?8. **Foreign ownership constraints.** Many Vietnamese sectors have foreign ownership limits (e.g., 49% for most industries, 30% for banking). Incorporate these constraints into the optimization-based weighting schemes. How do binding foreign ownership limits affect the achievable Sharpe ratio for foreign investors? -->```