12  Trọng số danh mục đầu tư và tái cân bằng danh mục

Ghi chú

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ó:

\[ w_{i,t}^{VW} = \frac{\text{MCap}_{i,t}}{\sum_{j=1}^{N_t} \text{MCap}_{j,t}} \tag{12.1}\]

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ệ.

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:

  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.

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ư.
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 ý

12.2 Xây dựng dữ liệu

Import libraries and configure environment
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
from scipy.optimize import minimize
from sklearn.covariance import LedoitWolf
from linearmodels.panel import PanelOLS
import warnings
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'figure.figsize': (10, 6),
    'figure.dpi': 150,
    'font.size': 11,
    'axes.spines.top': False,
    'axes.spines.right': False
})
Load market and fundamental data
from datacore import DataCoreClient

client = DataCoreClient()

# Daily prices and volume
daily = 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 weighting
fundamentals = 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 benchmarking
market_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()}")

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 filter
    if 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 restrictiveness
universe_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.

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) * 100

axes[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 30
n_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 time
hhi_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()
Hình 12.1

12.3 Triển khai các phương pháp xác định trọng số

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 dates
    if 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}
        if not rebal_dates:
            rebal_dates = set(months[::3])
    elif rebal_freq == "A":
        rebal_dates = {m for m in months if pd.Timestamp(m).month == 6}
        if not rebal_dates:
            rebal_dates = set(months[::12])
    else:
        rebal_dates = set(months)

    results = []
    prev_weights = None

    for month in months:
        cross_section = monthly_df[monthly_df["month_end"] == month].copy()
        cross_section = cross_section.dropna(subset=["monthly_return"])

        if len(cross_section) < 5:
            continue

        if month in rebal_dates or prev_weights is None:
            # 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 / total
        else:
            # Drift weights forward from previous month
            if prev_weights is not None:
                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 / total
            else:
                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 is not None:
            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() > 0
                else prev_drifted
            )
            turnover = (aligned_w - prev_drifted).abs().sum() / 2
        else:
            turnover = 1.0

        # Concentration metrics
        hhi = (aligned_w**2).sum()
        effective_n = 1.0 / hhi if hhi > 0 else 0

        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.2 Danh mục trọng số theo vốn hóa

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}")

12.3.3 Danh mục trọng số bằng nhau

Implement equal-weighted portfolio at different rebalancing frequencies
def ew_weights(cross_section):
    """Equal-weighted: 1/N."""
    tickers = cross_section.set_index('ticker').index
    return pd.Series(1.0, index=tickers)

# Monthly rebalancing
ew_monthly = compute_portfolio_returns(
    universe_mid, ew_weights, rebal_freq='M'
)

# Quarterly rebalancing
ew_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}")

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.

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 _ in range(20):
        excess = w[w > cap] - cap
        if excess.sum() <= 1e-8:
            break
        w[w > cap] = cap
        w[w <= cap] *= (1 + excess.sum() / w[w <= cap].sum())
    return w

capped5 = 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}")

12.3.5 Danh mục trọng số theo yếu tố cơ bản

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:

\[ 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) \tag{12.3}\]

Implement fundamental-weighted portfolio
# Merge fundamentals with monthly data (use most recent fiscal year)
fundamentals["merge_year"] = fundamentals["fiscal_year"] + 1  # Lag by 1 year
monthly_fund = monthly.copy()
monthly_fund["year"] = pd.to_datetime(monthly_fund["month_end"]).dt.year

monthly_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 composite


fw_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):

\[ \hat{\boldsymbol{\Sigma}}^{\text{shrink}} = \delta \mathbf{F} + (1 - \delta) \mathbf{S} \tag{12.4}\]

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:
        return None, 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):

\[ \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 \tag{12.5}\]

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.values
    
    def portfolio_variance(w):
        return w @ Sigma @ w
    
    constraints = [
        {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    ]
    bounds = [(0, max_weight) for _ in range(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 not in cov_cache:
        cov_matrix, tickers = estimate_covariance(monthly, month)
        cov_cache[month] = (cov_matrix, tickers)
    
    cov_matrix, tickers = cov_cache[month]
    if cov_matrix is None:
        # Fallback to equal weight
        return 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)
    if len(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 weights

mv_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à:

\[ RC_i = w_i \cdot \frac{(\boldsymbol{\Sigma} \mathbf{w})_i}{\sqrt{\mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}}} \tag{12.6}\]

Danh mục ngang bằng rủi ro giải \(RC_i = RC_j\) với mọi \(i, j\):

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.values
    
    def risk_parity_objective(w):
        port_var = w @ Sigma @ w
        marginal = Sigma @ w
        risk_contrib = w * marginal
        target_rc = port_var / n
        return np.sum((risk_contrib - target_rc) ** 2)
    
    constraints = [
        {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    ]
    bounds = [(1e-6, max_weight) for _ in range(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 not in cov_cache:
        cov_matrix, tickers = estimate_covariance(monthly, month)
        cov_cache[month] = (cov_matrix, tickers)
    
    cov_matrix, tickers = cov_cache[month]
    if cov_matrix is None:
        return pd.Series(1.0, index=cross_section.set_index('ticker').index)
    
    available = set(cross_section['ticker']) & set(tickers)
    if len(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}")

12.4.4 Danh mục đa dạng hóa tối đa

Choueifaty (2008) đị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}}} \tag{12.7}\]

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:

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:
            return 0
        return -(w @ sigma) / port_vol
    
    constraints = [
        {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    ]
    bounds = [(0, max_weight) for _ in range(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)

12.5 So sánh toàn diện hiệu quả đầu tư

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.

Compute returns for all weighting schemes
# Collect all portfolio return series
portfolios = {
    '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 range
common_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'])}")

12.5.1 Các thước đo hiệu quả

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() > 0 else 0
    
    # 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 > 0 else 0
    
    # Calmar ratio
    calmar = ann_ret / abs(max_dd) if max_dd != 0 else 0
    
    # 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 portfolios
metrics_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 display
display_cols = [
    'Ann. Return', 'Ann. Volatility', 'Sharpe Ratio', 'Sortino Ratio',
    'Max Drawdown', 'Avg. Turnover', 'Effective N'
]
print(metrics_df[display_cols].round(3).to_string())
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.8 if name in ['VW', 'EW (Monthly)', 'Min Variance'] else 1.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()
Hình 12.2

12.5.2 Đánh đổi giữa rủi ro và lợi nhuận

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()
Hình 12.3

12.6 Chi phí giao dịch và hiệu quả sau chi phí

12.6.1 Ước lượng chi phí giao dịch ở Việt Nam

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:

\[ 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}}} \tag{12.8}\]

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
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():
        if abs(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]
        if len(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 if len(cost_df) > 0 else 0
    
    return total_cost, cost_df

12.6.2 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.

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 assumptions
cost_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() > 0 else 0
        row[port_name] = round(sharpe, 3)
    print(f"{cost_name}:")
    for k, v in row.items():
        if k != 'Scenario':
            print(f"  {k}: {v}")
    print()
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'].values

positions = 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) in enumerate(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()
Hình 12.4

12.6.3 Sự bào mòn do chi phí ở quy mô lớn

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 billions

fig, 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.3
        elif 'Capped' in name:
            cost_rate *= 0.5
        elif '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() > 0 else 0
        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 frequency
freq_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_df

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

# Panel A: Gross vs Net Sharpe
freq_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.15 for i in x], gross_sharpes, width=0.3,
            color='#2C5F8A', alpha=0.85, label='Gross')
axes[0].bar([i + 0.15 for 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 turnover
turnovers = [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
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 = 0
    
    for month in months:
        cs = monthly_df[monthly_df['month_end'] == month].copy()
        cs = cs.dropna(subset=['monthly_return']).set_index('ticker')
        
        if len(cs) < 5:
            continue
        
        target = weight_fn(cs.reset_index())
        target = target / target.sum()
        
        if current_weights is None:
            current_weights = target.copy()
            rebalance_count += 1
        else:
            # 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 += 1
            else:
                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 > 0 else 0,
            '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 thresholds
thresholds = [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
    )

12.8 Phần thưởng tái cân bằng: Phân tách

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ỳ:

\[ 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) \tag{12.9}\]

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 difference
bonus_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 dispersion
dispersion = (
    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() * 12
print(f"Annualized rebalancing bonus (EW monthly vs annual): {ann_bonus:.4f}")
print(f"Mean cross-sectional dispersion: {bonus_df['cs_dispersion'].mean():.4f}")
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()
Hình 12.7

12.9 Phân tích mức độ phơi nhiễm nhân tố

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} \tag{12.10}\]

Regress portfolio returns on Fama-French-Carhart factors
# Retrieve Vietnamese factor returns from DataCore
factors = 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 portfolio
factor_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).T
print(factor_df.round(3).to_string())
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 annotations
for i in range(len(plot_data.index)):
    for j in range(len(plot_data.columns)):
        val = plot_data.values[i, j]
        color = 'white' if abs(val) > 0.8 else '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à

\[ 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), \tag{12.11}\]

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

\[ \pi_t = m\, g_t \pi_{t-1} + (I - m)\, A_t , \tag{12.12}\]

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], \tag{12.13}\]

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,

\[ A_t = \operatorname{diag}(\sigma_t)^{-1}\, \text{RF}(s_t)\,\beta , \tag{12.14}\]

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:

\[ \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}, \qquad D_t = \gamma\, \Omega_t^\top \Sigma_t \Omega_t + W\, \Delta\Omega_t^\top \Lambda_t\, \Delta\Omega_t , \tag{12.15}\]

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 _ in range(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 @ W
    return 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 in enumerate(S_hist):
        num += M @ S_i
        den += M
        if 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 in range(lookback + 1)],
                             [g[t - 1 - i] for i in range(lookback)], m)
        om_prev = implied_weights([S[t - 1 - i] for i in range(lookback + 1)],
                                  [g[t - 2 - i] for i in range(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, D


def 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, 11
N_STOCKS, N_MONTHS = 60, 420
TRAIN_END, TEST_START = 240, 300
RIDGES = [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 in range(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.5
    return dict(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 out


def 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 in range(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 in range(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 chapter
characteristics = 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 not in ["ticker", "month_end"]]

# Rank-standardize each characteristic to [0, 1] within month; missing -> 0.5
ranked = 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 universe
ret_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 weights
lag_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 = None
    if cov is not None:
        adv = adv_wide.iloc[i]
        tickers = [t for t in tickers if t in cov_tickers and adv.get(t, 0) > 0]
        if len(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] = state
    return state


def month_signals(i, tickers, vol, W, lookback=LOOKBACK):
    """Scaled random-feature signals for months i, i-1, ..., i-lookback-1."""
    out = []
    for lag in range(lookback + 2):
        X = np.column_stack([char_wide[c].iloc[i - lag][tickers].fillna(0.5).values
                             for 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 in range(lookback + 2, len(months) - 1):
        st = base_state(i, lookback)
        if st is None:
            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 time
            for l in ridges:
                val = {}
                for y in range(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 in range(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"]) @ aim
            if 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 bt


def 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 sizes
frontier = {}
for wealth in [1e11, 1e12, 1e13]:            # VND 100 billion, 1 trillion, 10 trillion
    for 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).T
frontier.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.

  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 Phương trình 12.8.
  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 (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.
  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, 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ự.