Hiện mã
import pandas as pd
import numpy as np
import sqlite3
from datetime import datetime
from itertools import product
from plotnine import *
from mizani.formatters import comma_format, percent_format
from scipy import statsĐộng lượng là một trong những hiện tượng bất thường vững chắc và phổ biến nhất trong kinh tế học tài chính. Những cổ phiếu có kết quả tốt trong ba đến mười hai tháng trước đó có xu hướng tiếp tục có kết quả tốt trong ba đến mười hai tháng tiếp theo, còn những cổ phiếu có kết quả kém có xu hướng tiếp tục kém. Mô thức này, được Jegadeesh và Titman (1993) ghi nhận lần đầu, đã được lặp lại trên hầu như mọi thị trường cổ phiếu, loại tài sản và giai đoạn được khảo sát, qua đó có vị trí trung tâm trong hệ thống kiến thức nền tảng của định giá tài sản thực nghiệm.
Trong chương này, chúng ta triển khai các chiến lược động lượng theo phương pháp của Jegadeesh và Titman (1993). Chúng ta xây dựng các danh mục động lượng dựa trên lợi nhuận tích lũy trong quá khứ, đánh giá hiệu quả của chúng qua các tổ hợp khác nhau của thời kỳ hình thành và thời kỳ nắm giữ, và xem xét liệu phần bù động lượng có tồn tại trên thị trường cổ phiếu Việt Nam hay không.
Hiệu ứng động lượng là xu hướng các tài sản có lợi nhuận gần đây cao tiếp tục tạo ra lợi nhuận cao, và các tài sản có lợi nhuận gần đây thấp tiếp tục tạo ra lợi nhuận thấp. Chính thức hơn, nếu chúng ta định nghĩa lợi nhuận tích lũy của cổ phiếu \(i\) trong \(J\) tháng gần nhất là
\[ R_{i,t-J:t-1} = \prod_{s=t-J}^{t-1} (1 + r_{i,s}) - 1 \tag{18.1}\]
thì động lượng dự báo một mối quan hệ chéo dương giữa \(R_{i,t-J:t-1}\) và lợi nhuận tương lai \(r_{i,t}\). Nghĩa là, các cổ phiếu thuộc thập phân vị cao nhất về lợi nhuận quá khứ (cổ phiếu thắng) sẽ vượt trội các cổ phiếu thuộc thập phân vị thấp nhất (cổ phiếu thua) trong các tháng tiếp theo.
Khung của Jegadeesh và Titman (1993) tham số hóa các chiến lược động lượng theo hai chiều chính:
Do đó, một chiến lược được đặc trưng bởi cặp \((J, K)\). Chẳng hạn, chiến lược \((6, 6)\) xếp hạng cổ phiếu dựa trên lợi nhuận tích lũy trong 6 tháng gần nhất và nắm giữ các danh mục thu được trong 6 tháng.
Một chi tiết triển khai then chốt trong Jegadeesh và Titman (1993) là việc sử dụng các danh mục chồng lấn. Trong mỗi tháng \(t\), một bộ danh mục mới được hình thành dựa trên lợi nhuận \(J\) tháng gần nhất. Tuy nhiên, các danh mục hình thành vào các tháng \(t-1\), \(t-2\), \(\ldots\), \(t-K+1\) vẫn nằm trong thời kỳ nắm giữ và do đó vẫn còn hoạt động. Lợi nhuận của chiến lược động lượng trong tháng \(t\) vì vậy là trung bình trọng số bằng nhau của tất cả \(K\) nhóm (cohort) đang hoạt động:
\[ r_{p,t} = \frac{1}{K} \sum_{k=0}^{K-1} r_{p,t}^{(t-k)} \tag{18.2}\]
trong đó \(r_{p,t}^{(t-k)}\) ký hiệu lợi nhuận trong tháng \(t\) của danh mục được hình thành vào tháng \(t-k\). Cách tiếp cận danh mục chồng lấn này phục vụ hai mục đích. Thứ nhất, nó giảm tác động của bất kỳ ngày hình thành đơn lẻ nào lên hiệu quả của chiến lược. Thứ hai, nó tạo ra một chuỗi lợi nhuận hằng tháng có thể phân tích bằng các phương pháp chuỗi thời gian chuẩn, ngay cả khi thời kỳ nắm giữ \(K\) vượt quá một tháng.
Tài liệu học thuật đưa ra hai nhóm giải thích rộng cho hiệu ứng động lượng.
Hành vi của động lượng ở các thị trường mới nổi khác đáng kể so với các thị trường phát triển, và việc hiểu những khác biệt này là thiết yếu để diễn giải kết quả của thị trường Việt Nam.
Rouwenhorst (1998) cung cấp bằng chứng sớm rằng lợi nhuận động lượng tồn tại ở các thị trường châu Âu. Chan, Hameed, và Tong (2000) mở rộng phân tích sang các thị trường cổ phiếu quốc tế và thấy rằng lợi nhuận động lượng hiện diện ở hầu hết các thị trường phát triển nhưng yếu hơn hoặc không có ở một số thị trường châu Á. Chui, Titman, và Wei (2010) đưa ra một giải thích văn hóa, ghi nhận rằng lợi nhuận động lượng có quan hệ dương với mức độ chủ nghĩa cá nhân của một quốc gia theo đo lường của Hofstede (2001). Các quốc gia có văn hóa tập thể, trong đó có nhiều nước ở Đông Á và Đông Nam Á, có xu hướng thể hiện hiệu ứng động lượng yếu hơn.
Một số đặc điểm vi cấu trúc thị trường phổ biến ở các thị trường mới nổi có thể làm suy yếu động lượng:
Những cân nhắc này là lý do để chúng ta phân tích thực nghiệm một cách cẩn thận xem động lượng có biểu hiện trên cổ phiếu Việt Nam hay không và ở mức độ nào.
Chúng ta bắt đầu bằng việc nạp các gói Python cần thiết. Các gói cốt lõi gồm pandas để xử lý dữ liệu, numpy cho các phép toán số và sqlite3 để kết nối cơ sở dữ liệu. Chúng ta cũng nhập plotnine để tạo các hình ảnh chất lượng xuất bản và scipy cho các kiểm định thống kê.
Chúng ta kết nối với cơ sở dữ liệu SQLite, nơi lưu trữ các bộ dữ liệu đã làm sạch được chuẩn bị ở các chương trước.
Chúng ta nạp dữ liệu giá cổ phiếu hằng tháng từ cơ sở dữ liệu. Bảng prices_monthly chứa lợi nhuận đã điều chỉnh, vốn hóa thị trường và các biến khác của tất cả cổ phiếu niêm yết trên HOSE và HNX.
prices_monthly = pd.read_sql_query(
# can add "exchange" variable
sql="""
SELECT symbol, date, ret, ret_excess, mktcap, mktcap_lag, risk_free
FROM prices_monthly
""",
con=tidy_finance,
parse_dates={"date"}
).dropna()
print(f"Total monthly observations: {len(prices_monthly):,}")
print(f"Unique stocks: {prices_monthly['symbol'].nunique():,}")
print(f"Date range: {prices_monthly['date'].min().date()} "
f"to {prices_monthly['date'].max().date()}")Total monthly observations: 165,499
Unique stocks: 1,457
Date range: 2010-02-28 to 2023-12-31
Trước khi xây dựng danh mục, chúng ta xem xét các biến chính trong bộ dữ liệu. Bảng 18.1 trình bày thống kê mô tả của các biến chính dùng để xây dựng danh mục động lượng.
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| ret | 165499.0 | 0.0042 | 0.1862 | -0.9900 | -0.0703 | 0.0000 | 0.0553 | 12.7500 |
| ret_excess | 165499.0 | 0.0008 | 0.1862 | -0.9933 | -0.0736 | -0.0033 | 0.0519 | 12.7467 |
| mktcap | 165499.0 | 2183.1646 | 13983.9977 | 0.3536 | 60.3728 | 180.6224 | 660.0000 | 463886.6454 |
Chúng ta cũng xem xét phân phối chéo của các cổ phiếu theo thời gian, điều này quan trọng để hiểu liệu chúng ta có đủ độ rộng để xây dựng các danh mục thập phân vị hay không.
stock_counts = (prices_monthly
.groupby("date")["symbol"]
.nunique()
.reset_index()
.rename(columns={"symbol": "n_stocks"})
)
plot_stock_counts = (
ggplot(stock_counts, aes(x="date", y="n_stocks")) +
geom_line(color="#1f77b4") +
labs(
x="", y="Number of stocks",
title="Cross-Sectional Breadth Over Time"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_stock_counts
Chúng ta cũng nạp lợi nhuận các nhân tố Fama-French để đánh giá hiệu quả điều chỉnh theo rủi ro. Các nhân tố này đã được xây dựng ở các chương trước.
factors_ff3_monthly = pd.read_sql_query(
sql="SELECT date, mkt_excess, smb, hml, risk_free FROM factors_ff3_monthly",
con=tidy_finance,
parse_dates={"date"}
)
print(f"Factor observations: {len(factors_ff3_monthly):,}")
print(f"Date range: {factors_ff3_monthly['date'].min().date()} "
f"to {factors_ff3_monthly['date'].max().date()}")Factor observations: 150
Date range: 2011-07-31 to 2023-12-31
Các chiến lược động lượng đòi hỏi lịch sử lợi nhuận liên tục trong thời kỳ hình thành. Chúng ta áp dụng một số bộ lọc để bảo đảm chất lượng dữ liệu:
# Filter for stocks with positive market cap and non-missing returns
prices_clean = (prices_monthly
.dropna(subset=["ret", "mktcap"])
.query("mktcap > 0")
.sort_values(["symbol", "date"])
.reset_index(drop=True)
)
print(f"Observations after filtering: {len(prices_clean):,}")
print(f"Stocks after filtering: {prices_clean['symbol'].nunique():,}")Observations after filtering: 165,499
Stocks after filtering: 1,457
Bước đầu tiên trong việc xây dựng các danh mục động lượng là tính lợi nhuận tích lũy trong thời kỳ hình thành. Với thời kỳ hình thành \(J\) tháng, chúng ta tính lợi nhuận tích lũy của mỗi cổ phiếu \(i\) tại cuối tháng \(t\) như quy định trong Phương trình 35.7.
Chúng ta triển khai bằng một tích trượt (rolling product) của các lợi nhuận gộp. Một chi tiết quan trọng là chúng ta yêu cầu lợi nhuận không bị thiếu trong cả \(J\) tháng của cửa sổ hình thành. Nếu thiếu bất kỳ lợi nhuận hằng tháng nào, lợi nhuận tích lũy được đặt là thiếu, và cổ phiếu bị loại khỏi việc hình thành danh mục trong tháng đó.
def compute_formation_returns(data, J):
"""
Compute J-month cumulative returns for momentum portfolio formation.
Parameters
----------
data : pd.DataFrame
Panel data with columns 'symbol', 'date', 'ret'.
J : int
Formation period length in months (typically 3-12).
Returns
-------
pd.DataFrame
Original data augmented with 'cum_return' column.
"""
df = data.sort_values(["symbol", "date"]).copy()
# Compute rolling J-month cumulative return
# Using gross returns: (1+r1)*(1+r2)*...*(1+rJ) - 1
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"])
return dfHãy áp dụng hàm này cho trường hợp cơ sở với \(J = 6\) tháng:
J = 6 # Formation period: 6 months
prices_with_cumret = compute_formation_returns(prices_clean, J)
# Check the result
print(f"Observations with valid {J}-month cumulative returns: "
f"{prices_with_cumret['cum_return'].notna().sum():,}")
print(f"\nCumulative return distribution:")
print(prices_with_cumret["cum_return"].describe().round(4))Observations with valid 6-month cumulative returns: 158,227
Cumulative return distribution:
count 158227.0000
mean 0.0171
std 0.5053
min -0.9999
25% -0.2196
50% -0.0400
75% 0.1404
max 35.7136
Name: cum_return, dtype: float64
Mỗi tháng, chúng ta phân loại tất cả cổ phiếu có lợi nhuận tích lũy hợp lệ vào các danh mục thập phân vị dựa trên kết quả \(J\) tháng gần nhất. Danh mục 1 chứa thập phân vị thấp nhất (cổ phiếu thua) và danh mục 10 chứa thập phân vị cao nhất (cổ phiếu thắng).
def assign_momentum_portfolios(data, n_portfolios=10):
"""
Assign stocks to momentum portfolios based on formation-period returns.
For each cross-section (month), stocks are sorted into
n_portfolios quantile groups according to their cumulative return.
Portfolio 1 = lowest past returns (Losers)
Portfolio n_portfolios = highest past returns (Winners)
This implementation uses `groupby().transform()` rather than
`groupby().apply()` to preserve the original DataFrame structure
and avoid index mutation issues.
Parameters
----------
data : pd.DataFrame
Panel data containing at minimum:
- 'symbol' : stock identifier
- 'date' : formation month
- 'cum_return' : cumulative return over formation window
n_portfolios : int, optional
Number of portfolios to form (default = 10 for deciles).
Returns
-------
pd.DataFrame
Original data augmented with:
- 'momr' : integer momentum rank (1 to n_portfolios)
"""
# Drop observations without formation-period returns
# These cannot be ranked into portfolios
df = data.dropna(subset=["cum_return"]).copy()
def safe_qcut(x):
"""
Assign quantile portfolio labels within a single cross-section.
Uses pd.qcut to create approximately equal-sized portfolios.
If too few unique return values exist (which can happen in
small markets or illiquid samples), fall back to rank-based
binning to ensure portfolios are still formed.
"""
try:
# Standard quantile sorting
return pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop" # prevents crash if quantile edges duplicate
)
except ValueError:
# Fallback: rank then evenly cut into bins
return pd.cut(
x.rank(method="first"),
bins=n_portfolios,
labels=range(1, n_portfolios + 1)
)
# Cross-sectional portfolio assignment:
# For each month, compute portfolio ranks based on cum_return.
# transform ensures:
# - original row order preserved
# - no index mutation
# - no loss of 'date' column
df["momr"] = (
df.groupby("date")["cum_return"]
.transform(safe_qcut)
.astype(int)
)
return dfStocks assigned to portfolios: 158,227
Portfolio distribution (should be approximately equal):
count
momr
1 15894
2 15815
3 16021
4 15759
5 15738
6 16568
7 15288
8 15582
9 15697
10 15865
Sau khi hình thành danh mục vào cuối tháng \(t\), chúng ta nắm giữ chúng từ đầu tháng \(t+1\) đến cuối tháng \(t+K\). Khoảng cách một tháng giữa thời kỳ hình thành và thời điểm bắt đầu thời kỳ nắm giữ là chuẩn trong tài liệu và tránh hiệu ứng đảo chiều ngắn hạn đã được ghi nhận rõ ở chân trời một tháng (Jegadeesh 1990).
K = 6 # Holding period: 6 months
portfolios_with_dates = portfolios.copy()
portfolios_with_dates = portfolios_with_dates.rename(
columns={"date": "form_date"}
)
# Define holding period start and end dates
portfolios_with_dates["hdate1"] = (
portfolios_with_dates["form_date"] + pd.offsets.MonthBegin(1)
)
portfolios_with_dates["hdate2"] = (
portfolios_with_dates["form_date"] + pd.offsets.MonthEnd(K)
)
print(portfolios_with_dates[
["symbol", "form_date", "cum_return", "momr", "hdate1", "hdate2"]
].head(10)) symbol form_date cum_return momr hdate1 hdate2
5 A32 2019-04-30 -0.061599 4 2019-05-01 2019-10-31
6 A32 2019-05-31 -0.213675 2 2019-06-01 2019-11-30
7 A32 2019-06-30 -0.308720 2 2019-07-01 2019-12-31
8 A32 2019-07-31 -0.249936 2 2019-08-01 2020-01-31
9 A32 2019-08-31 0.061986 8 2019-09-01 2020-02-29
10 A32 2019-09-30 0.030751 8 2019-10-01 2020-03-31
11 A32 2019-10-31 0.030751 8 2019-11-01 2020-04-30
12 A32 2019-11-30 0.032486 8 2019-12-01 2020-05-31
13 A32 2019-12-31 0.180073 9 2020-01-01 2020-06-30
14 A32 2020-01-31 0.412322 10 2020-02-01 2020-07-31
Bây giờ chúng ta tính lợi nhuận của từng danh mục trong thời kỳ nắm giữ. Với mỗi tổ hợp cổ phiếu-ngày hình thành, chúng ta ghép vào các lợi nhuận thực tế đạt được trong cửa sổ nắm giữ. Điều này tạo ra một bảng dữ liệu (panel) lợi nhuận cổ phiếu được đánh chỉ mục theo ngày hình thành, ngày nắm giữ và thứ hạng danh mục động lượng.
# Merge: for each portfolio assignment, get all returns during holding period
portfolio_returns = portfolios_with_dates.merge(
prices_clean[["symbol", "date", "ret"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Keep only returns within the holding period
portfolio_returns = portfolio_returns.query(
"hdate >= hdate1 and hdate <= hdate2"
)
print(f"Portfolio-holding period observations: {len(portfolio_returns):,}")Portfolio-holding period observations: 918,072
Phương pháp của Jegadeesh và Titman (1993) dùng các danh mục trọng số bằng nhau. Với mỗi tháng nắm giữ, mỗi thập phân vị động lượng có \(K\) nhóm đang hoạt động (mỗi nhóm hình thành vào một trong \(K\) tháng gần nhất). Trước tiên chúng ta tính lợi nhuận trọng số bằng nhau trong mỗi nhóm, sau đó lấy trung bình giữa các nhóm để có lợi nhuận danh mục hằng tháng cuối cùng.
# Step 1: Average return within each cohort (form_date × momr × hdate)
cohort_returns = (portfolio_returns
.groupby(["hdate", "momr", "form_date"])
.agg(cohort_ret=("hret", "mean"))
.reset_index()
)
# Step 2: Average across cohorts for each momentum portfolio × month
ewret = (cohort_returns
.groupby(["hdate", "momr"])
.agg(
ewret=("cohort_ret", "mean"),
ewret_std=("cohort_ret", "std"),
n_cohorts=("cohort_ret", "count")
)
.reset_index()
.rename(columns={"hdate": "date"})
)
print(f"Monthly portfolio return observations: {len(ewret):,}")
print(f"Date range: {ewret['date'].min().date()} to {ewret['date'].max().date()}")Monthly portfolio return observations: 1,610
Date range: 2010-08-31 to 2023-12-31
Chúng ta nên kiểm tra rằng mỗi tháng có số nhóm đang hoạt động đúng như kỳ vọng. Khi chiến lược đã chạy ít nhất \(K\) tháng, mỗi thập phân vị động lượng phải có đúng \(K\) nhóm đang hoạt động.
cohort_check = (ewret
.groupby("date")["n_cohorts"]
.mean()
.reset_index()
.rename(columns={"n_cohorts": "avg_cohorts"})
)
plot_cohorts = (
ggplot(cohort_check, aes(x="date", y="avg_cohorts")) +
geom_line(color="#1f77b4") +
geom_hline(yintercept=K, linetype="dashed", color="red") +
labs(
x="", y="Average number of cohorts",
title=f"Active Cohorts per Portfolio (K={K})"
) +
theme_minimal() +
theme(figure_size=(10, 4))
)
plot_cohorts
Bây giờ chúng ta xem xét lợi nhuận hằng tháng trung bình của từng danh mục thập phân vị động lượng. Bảng 18.2 trình bày giá trị trung bình, thống kê \(t\) và giá trị \(p\) cho mỗi danh mục trong mười danh mục.
def compute_portfolio_stats(group):
"""Compute mean, t-stat, and p-value for a return series."""
n = len(group)
mean_ret = group.mean()
std_ret = group.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1)) if not np.isnan(t_stat) else np.nan
return pd.Series({
"N": n,
"Mean (%)": mean_ret * 100,
"Std (%)": std_ret * 100,
"t-stat": t_stat,
"p-value": p_val
})
momentum_stats = (ewret
.groupby("momr")["ewret"]
.apply(compute_portfolio_stats)
.unstack()
.round(4)
)
momentum_stats| N | Mean (%) | Std (%) | t-stat | p-value | |
|---|---|---|---|---|---|
| momr | |||||
| 1 | 161.0 | 1.4190 | 6.5319 | 2.7565 | 0.0065 |
| 2 | 161.0 | 0.6602 | 5.9895 | 1.3985 | 0.1639 |
| 3 | 161.0 | 0.3658 | 5.5080 | 0.8427 | 0.4007 |
| 4 | 161.0 | 0.1623 | 5.0085 | 0.4112 | 0.6815 |
| 5 | 161.0 | 0.0111 | 4.7561 | 0.0297 | 0.9763 |
| 6 | 161.0 | 0.0408 | 4.6864 | 0.1104 | 0.9122 |
| 7 | 161.0 | -0.0674 | 4.8881 | -0.1749 | 0.8614 |
| 8 | 161.0 | -0.2066 | 4.9208 | -0.5329 | 0.5949 |
| 9 | 161.0 | -0.2228 | 5.3013 | -0.5332 | 0.5946 |
| 10 | 161.0 | -0.6812 | 5.7131 | -1.5130 | 0.1323 |
Phép kiểm tra then chốt của hiệu ứng động lượng là liệu chênh lệch giữa cổ phiếu thắng và cổ phiếu thua, tức danh mục động lượng mua–bán khống (long–short), có tạo ra lợi nhuận dương có ý nghĩa thống kê hay không. Chúng ta xây dựng danh mục chênh lệch này bằng cách mua thập phân vị cao nhất (danh mục 10) và bán khống thập phân vị thấp nhất (danh mục 1).
# Pivot to wide format
ewret_wide = ewret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
ewret_wide.columns = ["date"] + [f"port{i}" for i in range(1, 11)]
# Compute long-short return
ewret_wide["winners"] = ewret_wide["port10"]
ewret_wide["losers"] = ewret_wide["port1"]
ewret_wide["long_short"] = ewret_wide["winners"] - ewret_wide["losers"]ls_stats = pd.DataFrame()
for col in ["winners", "losers", "long_short"]:
series = ewret_wide[col].dropna()
n = len(series)
mean_val = series.mean()
std_val = series.std()
t_val = mean_val / (std_val / np.sqrt(n))
p_val = 2 * (1 - stats.t.cdf(abs(t_val), df=n-1))
ls_stats[col] = [n, mean_val * 100, std_val * 100, t_val, p_val]
ls_stats.index = ["N", "Mean (%)", "Std (%)", "t-stat", "p-value"]
ls_stats.columns = ["Winners", "Losers", "Long-Short"]
ls_stats = ls_stats.round(4)
ls_stats| Winners | Losers | Long-Short | |
|---|---|---|---|
| N | 161.0000 | 161.0000 | 161.0000 |
| Mean (%) | -0.6812 | 1.4190 | -2.1002 |
| Std (%) | 5.7131 | 6.5319 | 4.8969 |
| t-stat | -1.5130 | 2.7565 | -5.4420 |
| p-value | 0.1323 | 0.0065 | 0.0000 |
Diễn biến theo chuỗi thời gian của lợi nhuận tích lũy cung cấp bằng chứng trực quan về hiệu quả của chiến lược động lượng. Hình 18.3 cho thấy lợi nhuận tích lũy của các danh mục thắng và thua, trong khi Hình 18.4 cho thấy lợi nhuận tích lũy của chiến lược động lượng mua–bán khống.
cumret_plot_data = (ewret_wide
[["date", "cumret_winners", "cumret_losers"]]
.melt(id_vars="date", var_name="portfolio", value_name="cumret")
)
cumret_plot_data["portfolio"] = cumret_plot_data["portfolio"].map({
"cumret_winners": "Winners (P10)",
"cumret_losers": "Losers (P1)"
})
plot_cumret = (
ggplot(cumret_plot_data,
aes(x="date", y="cumret", color="portfolio")) +
geom_line(size=1) +
scale_y_continuous(labels=percent_format()) +
scale_color_manual(values=["#d62728", "#1f77b4"]) +
labs(
x="", y="Cumulative return",
color="Portfolio",
title="Cumulative Returns: Winners vs. Losers"
) +
theme_minimal() +
theme(
figure_size=(10, 6),
legend_position="bottom"
)
)
plot_cumret
plot_ls = (
ggplot(ewret_wide, aes(x="date", y="cumret_long_short")) +
geom_line(size=1, color="#2ca02c") +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Cumulative return",
title="Cumulative Return: Long-Short Momentum Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 6))
)
plot_ls
Ngoài giá trị trung bình, toàn bộ phân phối của lợi nhuận mua–bán khống hằng tháng cho thấy hồ sơ rủi ro của chiến lược động lượng. Hình 39.1 thể hiện biểu đồ tần suất của lợi nhuận hằng tháng.
mean_ls = ewret_wide["long_short"].mean()
plot_dist = (
ggplot(ewret_wide, aes(x="long_short")) +
geom_histogram(bins=50, fill="#1f77b4", alpha=0.7) +
geom_vline(xintercept=mean_ls, linetype="dashed", color="red") +
scale_x_continuous(labels=percent_format()) +
labs(
x="Monthly long-short return",
y="Frequency",
title="Distribution of Monthly Momentum Returns"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_dist
Jegadeesh và Titman (1993) đánh giá các chiến lược động lượng trên một lưới toàn diện gồm các thời kỳ hình thành \(J \in \{3, 6, 9, 12\}\) và các thời kỳ nắm giữ \(K \in \{3, 6, 9, 12\}\). Điều này tạo ra 16 đặc tả chiến lược khác nhau, cho phép chúng ta đánh giá độ vững của hiệu ứng động lượng qua các chân trời khác nhau.
Bây giờ chúng ta triển khai một hàm tính toàn bộ chiến lược động lượng cho bất kỳ cặp \((J, K)\) nào, gói các bước đã phát triển ở trên thành một quy trình duy nhất có thể tái sử dụng.
from joblib import Parallel, delayed
import time
def momentum_strategy(data, J, K, n_portfolios=10):
"""
Implement the full Jegadeesh-Titman momentum strategy.
Parameters
----------
data : pd.DataFrame
Panel of stock returns with columns: symbol, date, ret.
J : int
Formation period in months.
K : int
Holding period in months.
n_portfolios : int
Number of portfolios (default: 10 for deciles).
Returns
-------
pd.DataFrame
Monthly returns for each momentum portfolio and the long-short spread.
"""
# Step 1: Compute formation period cumulative returns
df = data.sort_values(["symbol", "date"]).copy()
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"]).dropna(subset=["cum_return"])
# Step 2: Assign to momentum portfolios using transform
df["momr"] = df.groupby("date", observed=True)["cum_return"].transform(
lambda x: pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop"
)
).astype('Int64')
# If any NaNs in momr, fill with rank-based assignment
mask = df["momr"].isna()
if mask.any():
df.loc[mask, "momr"] = df.loc[mask].groupby("date")["cum_return"].transform(
lambda x: pd.qcut(
x.rank(method="first"),
q=min(n_portfolios, len(x.unique())),
labels=False,
duplicates="drop"
)
).astype('Int64') + 1
# Step 3: Define holding period dates
df = df.rename(columns={"date": "form_date"})
df["hdate1"] = df["form_date"] + pd.offsets.MonthBegin(1)
df["hdate2"] = df["form_date"] + pd.offsets.MonthEnd(K)
# Step 4: Merge with holding period returns
port_ret = df[["symbol", "form_date", "momr", "hdate1", "hdate2"]].merge(
data[["symbol", "date", "ret"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing
port_ret = port_ret[
(port_ret["hdate"] >= port_ret["hdate1"]) &
(port_ret["hdate"] <= port_ret["hdate2"])
]
# Step 5: Compute equally weighted returns (two-stage averaging)
cohort_ret = (port_ret
.groupby(["hdate", "momr", "form_date"])
.agg(cohort_ret=("hret", "mean"))
.reset_index()
)
monthly_ret = (cohort_ret
.groupby(["hdate", "momr"])
.agg(ewret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"hdate": "date"})
)
# Step 6: Compute long-short spread
wide = monthly_ret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
# Handle variable number of portfolios
n_cols = len(wide.columns) - 1
wide.columns = ["date"] + [f"port{i}" for i in range(1, n_cols + 1)]
wide["winners"] = wide[f"port{n_cols}"]
wide["losers"] = wide["port1"]
wide["long_short"] = wide["winners"] - wide["losers"]
return monthly_ret, wideBây giờ chúng ta chạy chiến lược động lượng cho cả 16 tổ hợp \((J, K)\). Việc này tốn nhiều tính toán, nên chúng ta lưu các thống kê tóm tắt chính của mỗi đặc tả.
J_values = [3, 6, 9, 12]
K_values = [3, 6, 9, 12]
results_grid = []
for J_val, K_val in product(J_values, K_values):
print(f"Computing J={J_val}, K={K_val}...", end=" ")
try:
_, wide_result = momentum_strategy(prices_clean, J_val, K_val)
for portfolio_name in ["winners", "losers", "long_short"]:
series = wide_result[portfolio_name].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = (2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
if not np.isnan(t_stat) else np.nan)
results_grid.append({
"J": J_val,
"K": K_val,
"portfolio": portfolio_name,
"n_months": n,
"mean_ret": mean_ret,
"std_ret": std_ret,
"t_stat": t_stat,
"p_value": p_val
})
print("Done.")
except Exception as e:
print(f"Error: {e}")
results_df = pd.DataFrame(results_grid)def compute_single_strategy(data, J, K):
"""
Compute statistics for a single (J, K) strategy.
Returns a list of result dicts, one per portfolio.
"""
try:
_, wide_result = momentum_strategy(data, J, K)
results = []
for portfolio_name in ["winners", "losers", "long_short"]:
series = wide_result[portfolio_name].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = (2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
if not np.isnan(t_stat) else np.nan)
results.append({
"J": J,
"K": K,
"portfolio": portfolio_name,
"n_months": n,
"mean_ret": mean_ret,
"std_ret": std_ret,
"t_stat": t_stat,
"p_value": p_val
})
return results
except Exception as e:
print(f"Error in J={J}, K={K}: {e}")
return []J_values = [3, 6, 9, 12]
K_values = [3, 6, 9, 12]
# Create list of (J, K) pairs
params = list(product(J_values, K_values))
print(f"Running {len(params)} momentum strategies in parallel with 4 cores...")
start_time = time.time()
# Parallel execution with 4 workers
results_list = Parallel(n_jobs=4, verbose=10)(
delayed(compute_single_strategy)(prices_clean, J, K)
for J, K in params
)
# Flatten results
results_grid = [item for sublist in results_list for item in sublist]
results_df = pd.DataFrame(results_grid)
elapsed = time.time() - start_time
print(f"\nCompleted in {elapsed:.2f} seconds")Running 16 momentum strategies in parallel with 4 cores...
[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=4)]: Done 5 tasks | elapsed: 8.4s
[Parallel(n_jobs=4)]: Done 11 out of 16 | elapsed: 14.4s remaining: 6.5s
[Parallel(n_jobs=4)]: Done 13 out of 16 | elapsed: 18.7s remaining: 4.3s
Completed in 19.68 seconds
[Parallel(n_jobs=4)]: Done 16 out of 16 | elapsed: 19.6s finished
Results Summary by Formation Period:
mean_ret std_ret t_stat n_months
mean mean mean <lambda_0> first
J
6 -0.004661 0.055672 -1.517507 9 161
9 -0.003265 0.056520 -1.071904 9 158
12 -0.002759 0.058194 -0.931273 9 155
Bảng 18.4 trình bày lợi nhuận hằng tháng trung bình của danh mục cổ phiếu thắng (danh mục 10) qua tất cả các tổ hợp \((J, K)\).
winners_grid = (results_df
.query("portfolio == 'winners'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
winners_grid.columns = [f"K={k}" for k in winners_grid.columns]
winners_grid.index = [f"J={j}" for j in winners_grid.index]
winners_grid| K=3 | K=6 | K=9 | K=12 | |
|---|---|---|---|---|
| J=6 | -1.18\n(-2.65) | -0.68\n(-1.51) | -0.55\n(-1.23) | -0.39\n(-0.87) |
| J=9 | -1.00\n(-2.36) | -0.51\n(-1.22) | -0.28\n(-0.68) | -0.16\n(-0.39) |
| J=12 | -0.88\n(-2.09) | -0.39\n(-0.91) | -0.24\n(-0.56) | -0.14\n(-0.34) |
Bảng 18.5 trình bày kết quả tương ứng cho danh mục cổ phiếu thua.
losers_grid = (results_df
.query("portfolio == 'losers'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
losers_grid.columns = [f"K={k}" for k in losers_grid.columns]
losers_grid.index = [f"J={j}" for j in losers_grid.index]
losers_grid| K=3 | K=6 | K=9 | K=12 | |
|---|---|---|---|---|
| J=6 | 1.87\n(3.49) | 1.42\n(2.76) | 1.10\n(2.19) | 0.99\n(1.99) |
| J=9 | 1.94\n(3.51) | 1.38\n(2.56) | 1.21\n(2.31) | 1.15\n(2.22) |
| J=12 | 1.57\n(2.71) | 1.29\n(2.28) | 1.19\n(2.13) | 1.15\n(2.09) |
Bảng 18.6 trình bày kết quả quan trọng nhất: lợi nhuận hằng tháng trung bình của danh mục động lượng mua–bán khống qua tất cả các đặc tả.
ls_grid = (results_df
.query("portfolio == 'long_short'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
ls_grid.columns = [f"K={k}" for k in ls_grid.columns]
ls_grid.index = [f"J={j}" for j in ls_grid.index]
ls_grid| K=3 | K=6 | K=9 | K=12 | |
|---|---|---|---|---|
| J=6 | -3.04\n(-7.38) | -2.10\n(-5.44) | -1.65\n(-4.94) | -1.37\n(-4.61) |
| J=9 | -2.94\n(-6.41) | -1.89\n(-4.55) | -1.49\n(-3.98) | -1.31\n(-3.87) |
| J=12 | -2.45\n(-5.42) | -1.68\n(-3.92) | -1.43\n(-3.60) | -1.30\n(-3.55) |
Hình 18.6 cung cấp bản tóm tắt trực quan về phần bù động lượng mua–bán khống qua tất cả các tổ hợp \((J, K)\).
ls_heatmap_data = (results_df
.query("portfolio == 'long_short'")
.assign(
mean_pct=lambda x: x["mean_ret"] * 100,
significant=lambda x: x["p_value"] < 0.05,
label=lambda x: x.apply(
lambda row: f"{row['mean_ret']*100:.2f}{'*' if row['p_value'] < 0.05 else ''}",
axis=1
)
)
)
plot_heatmap = (
ggplot(ls_heatmap_data,
aes(x="K.astype(str)", y="J.astype(str)", fill="mean_pct")) +
geom_tile(color="white", size=2) +
geom_text(aes(label="label"), size=10, color="white") +
scale_fill_gradient2(
low="#d62728", mid="#f7f7f7", high="#1f77b4", midpoint=0,
name="Monthly\nReturn (%)"
) +
labs(
x="Holding Period (K months)",
y="Formation Period (J months)",
title="Momentum Premium Across J×K Specifications"
) +
theme_minimal() +
theme(figure_size=(8, 6))
)
plot_heatmap
Một câu hỏi tự nhiên là liệu phần bù động lượng có được giải thích bởi mức độ phơi nhiễm với nhân tố thị trường hay không. Chúng ta ước lượng alpha CAPM của danh mục động lượng mua–bán khống bằng cách hồi quy lợi nhuận của nó theo lợi nhuận vượt mức của thị trường:
\[ r_{\text{WML},t} = \alpha + \beta \cdot r_{\text{MKT},t} + \epsilon_t \tag{18.3}\]
# Merge momentum returns with factor data (baseline J=6, K=6)
ewret_factors = (ewret_wide
[["date", "winners", "losers", "long_short"]]
.merge(factors_ff3_monthly, on="date", how="inner")
)
# CAPM regression for long-short portfolio
from statsmodels.formula.api import ols as ols_formula
import statsmodels.api as sm
X_capm = sm.add_constant(ewret_factors["mkt_excess"])
y_ls = ewret_factors["long_short"]
capm_model = sm.OLS(y_ls, X_capm).fit(cov_type="HAC", cov_kwds={"maxlags": 6})
print("CAPM Regression: Long-Short Momentum Returns")
print("=" * 60)
print(f"Alpha (monthly): {capm_model.params['const']*100:.4f}% "
f"(t={capm_model.tvalues['const']:.2f})")
print(f"Market Beta: {capm_model.params['mkt_excess']:.4f} "
f"(t={capm_model.tvalues['mkt_excess']:.2f})")
print(f"R-squared: {capm_model.rsquared:.4f}")
print(f"N observations: {capm_model.nobs:.0f}")CAPM Regression: Long-Short Momentum Returns
============================================================
Alpha (monthly): -2.2703% (t=-5.05)
Market Beta: -0.1779 (t=-1.75)
R-squared: 0.0470
N observations: 150
Chúng ta mở rộng việc điều chỉnh rủi ro sang mô hình ba nhân tố Fama-French, gồm các nhân tố quy mô (SMB) và giá trị (HML) bên cạnh nhân tố thị trường:
\[ r_{\text{WML},t} = \alpha + \beta_1 \cdot r_{\text{MKT},t} + \beta_2 \cdot \text{SMB}_t + \beta_3 \cdot \text{HML}_t + \epsilon_t \tag{18.4}\]
X_ff3 = sm.add_constant(
ewret_factors[["mkt_excess", "smb", "hml"]]
)
y_ls = ewret_factors["long_short"]
ff3_model = sm.OLS(y_ls, X_ff3).fit(cov_type="HAC", cov_kwds={"maxlags": 6})
# Display results as a clean table
ff3_results = pd.DataFrame({
"Coefficient": ff3_model.params,
"Std Error": ff3_model.bse,
"t-stat": ff3_model.tvalues,
"p-value": ff3_model.pvalues
}).round(4)
ff3_results.index = ["Alpha", "MKT", "SMB", "HML"]
ff3_results| Coefficient | Std Error | t-stat | p-value | |
|---|---|---|---|---|
| Alpha | -0.0234 | 0.0041 | -5.7189 | 0.0000 |
| MKT | -0.1269 | 0.1558 | -0.8145 | 0.4154 |
| SMB | 0.1123 | 0.1882 | 0.5969 | 0.5506 |
| HML | 0.0716 | 0.1414 | 0.5065 | 0.6125 |
Các hệ số tải nhân tố từ hồi quy ba nhân tố cho thấy đặc điểm rủi ro của chiến lược động lượng trên thị trường Việt Nam. Một số mô thức thường được quan sát:
Beta thị trường: Các danh mục động lượng thường có mức phơi nhiễm thị trường vừa phải. Ở Hoa Kỳ, Grundy và Martin (2001) ghi nhận rằng beta thị trường của danh mục mua–bán khống nhìn chung gần bằng không nhưng biến thiên mạnh theo thời gian, tăng vọt khi thị trường đảo chiều.
Phơi nhiễm quy mô (SMB): Các chiến lược động lượng thường có hệ số tải dương trên nhân tố quy mô, phản ánh xu hướng các cổ phiếu nhỏ hơn thể hiện mô thức động lượng mạnh hơn.
Phơi nhiễm giá trị (HML): Danh mục động lượng mua–bán khống thường có hệ số tải âm trên HML, cho thấy cổ phiếu thắng có xu hướng là cổ phiếu tăng trưởng còn cổ phiếu thua có xu hướng là cổ phiếu giá trị. Điều này tạo ra sự đối nghịch tự nhiên giữa các chiến lược động lượng và giá trị.
Một phát hiện quan trọng trong tài liệu về động lượng là lợi nhuận động lượng thay đổi theo điều kiện thị trường. Cooper, Gutierrez Jr, và Hameed (2004) ghi nhận rằng các chiến lược động lượng hoạt động tốt sau khi thị trường tăng (thị trường UP) nhưng chịu thua lỗ nặng sau khi thị trường giảm (thị trường DOWN). Sự bất đối xứng này đặc biệt liên quan đến các thị trường mới nổi, vốn trải qua các trạng thái thị trường cực đoan hơn.
Chúng ta định nghĩa các trạng thái thị trường dựa trên lợi nhuận thị trường tích lũy trong 12 tháng trước đó:
\[ \text{Market State}_t = \begin{cases} \text{UP} & \text{if } \prod_{s=t-12}^{t-1}(1 + r_{m,s}) - 1 > 0 \\ \text{DOWN} & \text{otherwise} \end{cases} \tag{18.5}\]
# Compute 12-month lagged market return
market_returns = factors_ff3_monthly[["date", "mkt_excess"]].copy()
market_returns = market_returns.sort_values("date")
market_returns["mkt_cum_12m"] = (
(1 + market_returns["mkt_excess"])
.rolling(window=12, min_periods=12)
.apply(np.prod, raw=True)
- 1
)
market_returns["market_state"] = np.where(
market_returns["mkt_cum_12m"] > 0, "UP", "DOWN"
)
# Merge with momentum returns
ewret_states = ewret_wide[["date", "long_short"]].merge(
market_returns[["date", "market_state"]], on="date", how="inner"
).dropna()state_stats = []
for state in ["UP", "DOWN"]:
subset = ewret_states.query(f"market_state == '{state}'")["long_short"]
n = len(subset)
mean_ret = subset.mean()
std_ret = subset.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1)) if not np.isnan(t_stat) else np.nan
state_stats.append({
"Market State": state,
"N Months": n,
"Mean (%)": mean_ret * 100,
"Std (%)": std_ret * 100,
"t-stat": t_stat,
"p-value": p_val
})
state_stats_df = pd.DataFrame(state_stats).round(4)
state_stats_df| Market State | N Months | Mean (%) | Std (%) | t-stat | p-value | |
|---|---|---|---|---|---|---|
| 0 | UP | 39 | -1.1972 | 4.5814 | -1.6320 | 0.1109 |
| 1 | DOWN | 111 | -2.4050 | 4.8669 | -5.2063 | 0.0000 |
plot_states = (
ggplot(ewret_states, aes(x="market_state", y="long_short",
fill="market_state")) +
geom_boxplot(alpha=0.7) +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
scale_fill_manual(values={"UP": "#1f77b4", "DOWN": "#d62728"}) +
labs(
x="Market State (Prior 12-Month Return)",
y="Monthly Long-Short Return",
title="Momentum Returns by Market State"
) +
theme_minimal() +
theme(
figure_size=(8, 6),
legend_position="none"
)
)
plot_states
Một trong những đặc điểm rủi ro quan trọng nhất của các chiến lược động lượng là tính dễ bị thua lỗ đột ngột, nghiêm trọng, được gọi là sụp đổ động lượng. Daniel và Moskowitz (2016) ghi nhận rằng các chiến lược động lượng chịu những khoản thua lỗ hiếm gặp nhưng cực đoan, thường xảy ra trong các đợt thị trường hồi phục sau thị trường giá xuống. Các đợt sụp đổ này xảy ra vì danh mục cổ phiếu thua, vốn là vị thế bán khống, chứa nhiều cổ phiếu beta cao, những cổ phiếu tăng vọt khi thị trường đảo chiều.
Chúng ta xác định các đợt sụt giảm tồi tệ nhất của chiến lược động lượng và xem xét bối cảnh thị trường của chúng.
worst_months = (ewret_wide
[["date", "long_short", "winners", "losers"]]
.merge(factors_ff3_monthly[["date", "mkt_excess"]], on="date", how="left")
.sort_values("long_short")
.head(10)
.assign(
long_short_pct=lambda x: (x["long_short"] * 100).round(2),
winners_pct=lambda x: (x["winners"] * 100).round(2),
losers_pct=lambda x: (x["losers"] * 100).round(2),
mkt_pct=lambda x: (x["mkt_excess"] * 100).round(2)
)
[["date", "long_short_pct", "winners_pct", "losers_pct", "mkt_pct"]]
.rename(columns={
"date": "Date",
"long_short_pct": "L/S (%)",
"winners_pct": "Winners (%)",
"losers_pct": "Losers (%)",
"mkt_pct": "Market (%)"
})
)
worst_months| Date | L/S (%) | Winners (%) | Losers (%) | Market (%) | |
|---|---|---|---|---|---|
| 153 | 2023-05-31 | -16.39 | -2.69 | 13.70 | 2.78 |
| 39 | 2013-11-30 | -14.26 | 4.24 | 18.50 | 2.57 |
| 20 | 2012-04-30 | -13.68 | 1.51 | 15.19 | 4.88 |
| 78 | 2017-02-28 | -13.56 | 2.10 | 15.67 | 1.49 |
| 107 | 2019-07-31 | -13.15 | -2.46 | 10.69 | 1.61 |
| 140 | 2022-04-30 | -11.71 | -17.57 | -5.87 | -11.19 |
| 149 | 2023-01-31 | -11.53 | 1.37 | 12.90 | 6.96 |
| 28 | 2012-12-31 | -11.52 | 4.03 | 15.54 | -1.61 |
| 0 | 2010-08-31 | -11.31 | -25.03 | -13.72 | NaN |
| 10 | 2011-06-30 | -10.44 | -3.15 | 7.29 | NaN |
Mức sụt giảm tối đa đo mức giảm từ đỉnh xuống đáy tồi tệ nhất mà chiến lược đã trải qua. Chỉ số này đặc biệt hữu ích đối với những người thực hành khi đánh giá rủi ro của các chiến lược động lượng.
# Compute running maximum and drawdown
ewret_wide["cum_wealth"] = (1 + ewret_wide["long_short"]).cumprod()
ewret_wide["running_max"] = ewret_wide["cum_wealth"].cummax()
ewret_wide["drawdown"] = (
ewret_wide["cum_wealth"] / ewret_wide["running_max"] - 1
)
max_dd = ewret_wide["drawdown"].min()
max_dd_date = ewret_wide.loc[ewret_wide["drawdown"].idxmin(), "date"]
print(f"Maximum drawdown: {max_dd*100:.2f}%")
print(f"Date of maximum drawdown: {max_dd_date.date()}")Maximum drawdown: -97.08%
Date of maximum drawdown: 2023-10-31
plot_dd = (
ggplot(ewret_wide, aes(x="date", y="drawdown")) +
geom_area(fill="#d62728", alpha=0.5) +
geom_line(color="#d62728", size=0.5) +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Drawdown",
title="Momentum Strategy Drawdown"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_dd
Cách triển khai cơ sở của Jegadeesh và Titman (1993) dùng các danh mục trọng số bằng nhau. Tuy nhiên, lợi nhuận trọng số bằng nhau có thể bị chi phối bởi các cổ phiếu nhỏ, kém thanh khoản, vốn khó giao dịch trong thực tế. Các danh mục trọng số theo vốn hóa, trong đó đóng góp của mỗi cổ phiếu tỷ lệ với vốn hóa thị trường, cung cấp một chuẩn so sánh có thể đầu tư hơn và đại diện tốt hơn cho lợi nhuận mà các nhà đầu tư lớn thực sự có thể đạt được.
def momentum_strategy_vw(data, J, K, n_portfolios=10):
"""
Value-weighted momentum strategy implementation.
Same as the equally-weighted version but uses lagged market
capitalization as weights when computing portfolio returns.
Parameters
----------
data : pd.DataFrame
Panel with columns: symbol, date, ret, mktcap_lag.
J : int
Formation period in months.
K : int
Holding period in months.
n_portfolios : int
Number of portfolios.
Returns
-------
pd.DataFrame
Monthly value-weighted portfolio returns.
"""
# Step 1: Formation period returns
df = data.sort_values(["symbol", "date"]).copy()
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"]).dropna(subset=["cum_return"])
# Step 2: Portfolio assignment using transform (fast)
df["momr"] = df.groupby("date", observed=True)["cum_return"].transform(
lambda x: pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop"
)
).astype('Int64')
# Fill NaNs with rank-based assignment
mask = df["momr"].isna()
if mask.any():
df.loc[mask, "momr"] = df.loc[mask].groupby("date")["cum_return"].transform(
lambda x: pd.qcut(
x.rank(method="first"),
q=min(n_portfolios, len(x.unique())),
labels=False,
duplicates="drop"
)
).astype('Int64') + 1
# Step 3: Holding period
df = df.rename(columns={"date": "form_date"})
df["hdate1"] = df["form_date"] + pd.offsets.MonthBegin(1)
df["hdate2"] = df["form_date"] + pd.offsets.MonthEnd(K)
# Step 4: Merge with holding period returns AND weights
port_ret = df[
["symbol", "form_date", "momr", "hdate1", "hdate2"]
].merge(
data[["symbol", "date", "ret", "mktcap_lag"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing instead of query (faster)
port_ret = port_ret[
(port_ret["hdate"] >= port_ret["hdate1"]) &
(port_ret["hdate"] <= port_ret["hdate2"])
]
port_ret = port_ret.dropna(subset=["mktcap_lag"])
port_ret = port_ret[port_ret["mktcap_lag"] > 0]
# Step 5: Value-weighted returns within each cohort
def vw_mean(group):
weights = group["mktcap_lag"]
if weights.sum() == 0:
return np.nan
return np.average(group["hret"], weights=weights)
cohort_ret = (port_ret
.groupby(["hdate", "momr", "form_date"])
.apply(vw_mean, include_groups=False)
.reset_index(name="cohort_ret")
)
monthly_ret = (cohort_ret
.groupby(["hdate", "momr"])
.agg(vwret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"hdate": "date"})
)
# Step 6: Long-short
wide = monthly_ret.pivot(
index="date", columns="momr", values="vwret"
).reset_index()
# Handle variable number of portfolios
n_cols = len(wide.columns) - 1
wide.columns = ["date"] + [f"port{i}" for i in range(1, n_cols + 1)]
wide["winners"] = wide[f"port{n_cols}"]
wide["losers"] = wide["port1"]
wide["long_short"] = wide["winners"] - wide["losers"]
return monthly_ret, wideValue-Weighted Momentum Strategy (J=6, K=6)
==================================================
Portfolio Statistics:
winners losers long_short
count 161.0000 161.0000 161.0000
mean -0.0129 0.0006 -0.0135
std 0.0763 0.0713 0.0653
min -0.2519 -0.2178 -0.3813
25% -0.0540 -0.0397 -0.0465
50% -0.0067 0.0010 -0.0126
75% 0.0354 0.0443 0.0173
max 0.1765 0.2498 0.2498
comparison = []
for scheme, df in [("EW", ewret_wide), ("VW", vw_results)]:
for col in ["winners", "losers", "long_short"]:
series = df[col].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n))
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
comparison.append({
"Weighting": scheme,
"Portfolio": col.replace("_", " ").title(),
"Mean (%)": round(mean_ret * 100, 4),
"Std (%)": round(std_ret * 100, 4),
"t-stat": round(t_stat, 2),
"p-value": round(p_val, 4)
})
pd.DataFrame(comparison)| Weighting | Portfolio | Mean (%) | Std (%) | t-stat | p-value | |
|---|---|---|---|---|---|---|
| 0 | EW | Winners | -0.6812 | 5.7131 | -1.51 | 0.1323 |
| 1 | EW | Losers | 1.4190 | 6.5319 | 2.76 | 0.0065 |
| 2 | EW | Long Short | -2.1002 | 4.8969 | -5.44 | 0.0000 |
| 3 | VW | Winners | -1.2943 | 7.6274 | -2.15 | 0.0328 |
| 4 | VW | Losers | 0.0589 | 7.1275 | 0.10 | 0.9166 |
| 5 | VW | Long Short | -1.3533 | 6.5312 | -2.63 | 0.0094 |
vw_results = vw_results.sort_values("date")
vw_results["cumret_ls_vw"] = (1 + vw_results["long_short"]).cumprod() - 1
ew_data = (ewret_wide[["date", "long_short"]]
.rename(columns={"long_short": "cumret"})
.assign(scheme="Equally Weighted")
)
ew_data["cumret"] = (1 + ew_data["cumret"]).cumprod() - 1
vw_data = (vw_results[["date", "cumret_ls_vw"]]
.rename(columns={"cumret_ls_vw": "cumret"})
.assign(scheme="Value Weighted")
)
ew_vs_vw = pd.concat([ew_data, vw_data], ignore_index=True)
plot_ew_vw = (
ggplot(ew_vs_vw, aes(x="date", y="cumret", color="scheme")) +
geom_line(size=1) +
scale_y_continuous(labels=percent_format()) +
scale_color_manual(values=["#1f77b4", "#ff7f0e"]) +
labs(
x="", y="Cumulative return",
color="Weighting Scheme",
title="EW vs. VW Momentum Long-Short Strategy"
) +
theme_minimal() +
theme(
figure_size=(10, 6),
legend_position="bottom"
)
)
plot_ew_vw
Trong khi các chiến lược động lượng thường được đánh giá ở tần suất hằng tháng theo Jegadeesh và Titman (1993), việc phân tích các mô thức lợi nhuận hằng ngày cung cấp thêm hiểu biết về động lực của lợi nhuận động lượng. Dữ liệu hằng ngày cho phép chúng ta xem lợi nhuận động lượng tích lũy ra sao trong thời kỳ nắm giữ, đo độ biến động trong tháng của chiến lược và tính các thước đo rủi ro chính xác hơn.
prices_daily = pd.read_sql_query(
sql=("SELECT symbol, date, ret, ret_excess, mktcap_lag "
"FROM prices_daily"),
con=tidy_finance,
parse_dates={"date"}
)
print(f"Daily observations: {len(prices_daily):,}")
print(f"Unique stocks: {prices_daily['symbol'].nunique():,}")
print(f"Date range: {prices_daily['date'].min().date()} "
f"to {prices_daily['date'].max().date()}")Daily observations: 3,462,157
Unique stocks: 1,459
Date range: 2010-01-05 to 2023-12-29
Thay vì hình thành các danh mục động lượng ở tần suất hằng ngày (đòi hỏi tái cân bằng hằng ngày và không khả thi), chúng ta dùng việc gán danh mục hằng tháng và theo dõi lợi nhuận hằng ngày của chúng. Điều này cho ta chuỗi lợi nhuận hằng ngày của chiến lược động lượng hằng tháng.
# # # Use the monthly portfolio assignments from the J=6 baseline
monthly_assignments = portfolios_with_dates[
["symbol", "form_date", "momr", "hdate1", "hdate2"]
].copy()
# Merge with daily returns
daily_mom_returns = monthly_assignments.merge(
prices_daily[["symbol", "date", "ret"]].rename(
columns={"ret": "dret", "date": "ddate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing instead of query
daily_mom_returns = daily_mom_returns[
(daily_mom_returns["ddate"] >= daily_mom_returns["hdate1"]) &
(daily_mom_returns["ddate"] <= daily_mom_returns["hdate2"])
]
print(f"Daily portfolio return observations: {len(daily_mom_returns):,}")Daily portfolio return observations: 19,112,536
# Compute daily equally weighted portfolio returns
# Stage 1: Average within each cohort
daily_cohort_ret = (daily_mom_returns
.groupby(["ddate", "momr", "form_date"])
.agg(cohort_ret=("dret", "mean"))
.reset_index()
)
# Stage 2: Average across cohorts
daily_ewret = (daily_cohort_ret
.groupby(["ddate", "momr"])
.agg(ewret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"ddate": "date"})
)
# Compute daily long-short returns
daily_wide = daily_ewret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
daily_wide.columns = ["date"] + [f"port{i}" for i in range(1, 11)]
daily_wide["winners"] = daily_wide["port10"]
daily_wide["losers"] = daily_wide["port1"]
daily_wide["long_short"] = daily_wide["winners"] - daily_wide["losers"]
print(f"Daily long-short return observations: {len(daily_wide):,}")Daily long-short return observations: 3,352
daily_wide = daily_wide.sort_values("date")
daily_wide["cumret_ls"] = (1 + daily_wide["long_short"]).cumprod() - 1
plot_daily_cumret = (
ggplot(daily_wide, aes(x="date", y="cumret_ls")) +
geom_line(size=0.5, color="#2ca02c") +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Cumulative return",
title="Daily Cumulative Return: Momentum Long-Short Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 6))
)
plot_daily_cumret
Dữ liệu hằng ngày cho phép ước lượng chính xác hơn các thước đo rủi ro nhờ lấy mẫu ở tần suất cao hơn.
daily_ls = daily_wide["long_short"].dropna()
# Annualized metrics
ann_mean = daily_ls.mean() * 252
ann_vol = daily_ls.std() * np.sqrt(252)
sharpe = ann_mean / ann_vol if ann_vol > 0 else np.nan
# Drawdown
daily_wealth = (1 + daily_ls).cumprod()
daily_running_max = daily_wealth.cummax()
daily_dd = (daily_wealth / daily_running_max - 1).min()
# Higher moments
skew = daily_ls.skew()
kurt = daily_ls.kurtosis()
# VaR and CVaR
var_95 = daily_ls.quantile(0.05)
cvar_95 = daily_ls[daily_ls <= var_95].mean()
risk_metrics = pd.DataFrame({
"Metric": [
"Annualized Mean Return",
"Annualized Volatility",
"Sharpe Ratio",
"Maximum Drawdown",
"Skewness",
"Excess Kurtosis",
"Daily VaR (5%)",
"Daily CVaR (5%)"
],
"Value": [
f"{ann_mean*100:.2f}%",
f"{ann_vol*100:.2f}%",
f"{sharpe:.2f}",
f"{daily_dd*100:.2f}%",
f"{skew:.2f}",
f"{kurt:.2f}",
f"{var_95*100:.2f}%",
f"{cvar_95*100:.2f}%"
]
})
risk_metrics| Metric | Value | |
|---|---|---|
| 0 | Annualized Mean Return | -26.90% |
| 1 | Annualized Volatility | 15.01% |
| 2 | Sharpe Ratio | -1.79 |
| 3 | Maximum Drawdown | -97.62% |
| 4 | Skewness | -11.23 |
| 5 | Excess Kurtosis | 378.11 |
| 6 | Daily VaR (5%) | -1.33% |
| 7 | Daily CVaR (5%) | -2.10% |
Dùng lợi nhuận hằng ngày, chúng ta có thể tính biến động thực hiện hằng tháng của chiến lược động lượng và xem xét nó thay đổi ra sao theo thời gian.
daily_wide["year_month"] = daily_wide["date"].dt.to_period("M")
realized_vol = (daily_wide
.groupby("year_month")["long_short"]
.std()
.reset_index()
.rename(columns={"long_short": "realized_vol"})
)
realized_vol["date"] = realized_vol["year_month"].dt.to_timestamp()
realized_vol["realized_vol_ann"] = realized_vol["realized_vol"] * np.sqrt(252)
plot_rvol = (
ggplot(realized_vol, aes(x="date", y="realized_vol_ann")) +
geom_line(color="#d62728", size=0.8) +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Annualized Realized Volatility",
title="Realized Volatility of Momentum Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_rvol
Chúng ta lưu lợi nhuận danh mục động lượng vào cơ sở dữ liệu để dùng trong các chương sau, gồm xây dựng mô hình nhân tố và tối ưu hóa danh mục.
# Save monthly equally weighted momentum portfolio returns
ewret_to_save = ewret[["date", "momr", "ewret"]].copy()
ewret_to_save.to_sql(
name="momentum_portfolios_monthly",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(ewret_to_save):,} monthly momentum portfolio observations.")
# Save the long-short return series
momentum_factor = ewret_wide[["date", "long_short"]].dropna().copy()
momentum_factor = momentum_factor.rename(columns={"long_short": "wml"})
momentum_factor.to_sql(
name="momentum_factor_monthly",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(momentum_factor):,} monthly WML factor observations.")
# Save the daily long-short return series
daily_momentum_factor = daily_wide[["date", "long_short"]].dropna().copy()
daily_momentum_factor = daily_momentum_factor.rename(
columns={"long_short": "wml"}
)
daily_momentum_factor.to_sql(
name="momentum_factor_daily",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(daily_momentum_factor):,} daily WML factor observations.")Saved 1,610 monthly momentum portfolio observations.
Saved 161 monthly WML factor observations.
Saved 3,352 daily WML factor observations.
Các chiến lược động lượng kéo theo tỷ lệ vòng quay danh mục đáng kể, vì cổ phiếu ra vào các danh mục thập phân vị cực đoan mỗi tháng. Korajczyk và Sadka (2004) xem xét liệu lợi nhuận động lượng có còn sau chi phí giao dịch hay không và thấy rằng khả năng sinh lời giảm đáng kể đối với các nhà đầu tư tổ chức lớn, dù các danh mục nhỏ hơn vẫn có thể thu được lợi nhuận đáng kể.
Trên thị trường Việt Nam, chi phí giao dịch gồm:
Cách triển khai cơ sở của chúng ta giả định rằng các danh mục có thể được hình thành và tái cân bằng tức thời vào cuối mỗi tháng. Trên thực tế, có một độ trễ giữa việc quan sát lợi nhuận của thời kỳ hình thành và việc thực hiện các giao dịch danh mục. Khoảng cách một tháng giữa thời kỳ hình thành và thời điểm bắt đầu thời kỳ nắm giữ (Jegadeesh (1990)) phần nào giải quyết mối lo này, nhưng người thực hành nên cân nhắc thêm các độ trễ triển khai khác.
Bộ dữ liệu của chúng ta từ DataCore gồm cả cổ phiếu đang giao dịch lẫn cổ phiếu đã hủy niêm yết, giúp giảm thiên kiến sống sót. Tuy nhiên, cách xử lý các cổ phiếu hủy niêm yết có thể ảnh hưởng đến kết quả động lượng. Các cổ phiếu bị hủy niêm yết trong thời kỳ nắm giữ có thể tạo ra lợi nhuận cực đoan (dương khi bị thâu tóm và âm khi thất bại). Chúng ta giữ lại lợi nhuận của cổ phiếu hủy niêm yết như được báo cáo trong cơ sở dữ liệu, nhất quán với cách xử lý trong Jegadeesh và Titman (1993).
Thị trường chứng khoán Việt Nam có lịch sử tương đối ngắn so với thị trường Hoa Kỳ được nghiên cứu trong Jegadeesh và Titman (1993). Mẫu của chúng ta kéo dài khoảng hai thập kỷ, so với gần ba thập kỷ trong nghiên cứu gốc. Giai đoạn mẫu ngắn hơn này kéo theo khoảng tin cậy rộng hơn và độ nhạy lớn hơn với các giai đoạn cụ thể (như cuộc khủng hoảng tài chính 2007–2009, vốn tác động nặng nề đến cổ phiếu Việt Nam). Cần diễn giải kết quả với lưu ý này.
Chương này đã trình bày cách triển khai và phân tích các chiến lược động lượng trên thị trường cổ phiếu Việt Nam, theo phương pháp của Jegadeesh và Titman (1993). Các phát hiện chính và đóng góp về phương pháp là:
Phương pháp: Chúng ta đã triển khai đầy đủ phương pháp danh mục chồng lấn của Jegadeesh và Titman (1993), gồm quy trình lấy trung bình hai giai đoạn (trong nhóm, rồi giữa các nhóm) để xử lý \(K\) danh mục đang hoạt động trong mỗi tháng.
Kết quả cơ sở: Chiến lược \(J=6\), \(K=6\) cung cấp một chuẩn so sánh tự nhiên. Chênh lệch giữa danh mục thắng và thua cho thấy động lượng chéo có tồn tại trên cổ phiếu Việt Nam hay không.
Độ vững qua các chân trời: Bằng việc tính toàn bộ lưới \(J \times K\) với \(J, K \in \{3, 6, 9, 12\}\), chúng ta đã đánh giá liệu phần bù động lượng có vững trước lựa chọn thời kỳ hình thành và nắm giữ hay không, theo cách tiếp cận trong Bảng 1 của Jegadeesh và Titman (1993).
Điều chỉnh rủi ro: Alpha CAPM và alpha ba nhân tố Fama-French đo xem phần bù động lượng có được giải thích bởi các nhân tố rủi ro chuẩn hay không, dựa trên phân tích của Fama và French (1996), trong đó các tác giả cho thấy mô hình ba nhân tố của họ không giải thích được động lượng.
Sự phụ thuộc vào trạng thái thị trường: Theo Cooper, Gutierrez Jr, và Hameed (2004), chúng ta đã xem xét liệu lợi nhuận động lượng có thay đổi theo điều kiện thị trường hay không, điều đặc biệt liên quan ở các thị trường mới nổi có chu kỳ bùng nổ–suy thoái rõ rệt.
Trọng số theo vốn hóa: Việc so sánh các chiến lược trọng số bằng nhau và trọng số theo vốn hóa giải quyết khả năng triển khai thực tế và tách riêng vai trò của quy mô doanh nghiệp trong việc tạo ra lợi nhuận động lượng.
Phân tích theo ngày: Bằng việc theo dõi các danh mục động lượng ở tần suất hằng ngày, chúng ta đã tính các thước đo rủi ro chính xác gồm biến động thực hiện, sụt giảm tối đa, VaR và CVaR, cung cấp hồ sơ rủi ro đầy đủ của chiến lược.
Bối cảnh thị trường mới nổi: Xuyên suốt phân tích, chúng ta đã nhấn mạnh các đặc điểm riêng của thị trường Việt Nam—giới hạn biên độ giao dịch, hạn chế sở hữu nước ngoài, lịch sử mẫu ngắn hơn và chi phí giao dịch cao hơn—ảnh hưởng đến cách diễn giải và tính khả thi thực tế của các chiến lược động lượng.
# Chiến lược động lượng
Động lượng là một trong những hiện tượng bất thường vững chắc và phổ biến nhất trong kinh tế học tài chính. Những cổ phiếu có kết quả tốt trong ba đến mười hai tháng trước đó có xu hướng tiếp tục có kết quả tốt trong ba đến mười hai tháng tiếp theo, còn những cổ phiếu có kết quả kém có xu hướng tiếp tục kém. Mô thức này, được @jegadeesh1993returns ghi nhận lần đầu, đã được lặp lại trên hầu như mọi thị trường cổ phiếu, loại tài sản và giai đoạn được khảo sát, qua đó có vị trí trung tâm trong hệ thống kiến thức nền tảng của định giá tài sản thực nghiệm.
Trong chương này, chúng ta triển khai các chiến lược động lượng theo phương pháp của @jegadeesh1993returns. Chúng ta xây dựng các danh mục động lượng dựa trên lợi nhuận tích lũy trong quá khứ, đánh giá hiệu quả của chúng qua các tổ hợp khác nhau của thời kỳ hình thành và thời kỳ nắm giữ, và xem xét liệu phần bù động lượng có tồn tại trên thị trường cổ phiếu Việt Nam hay không.
## Cơ sở lý thuyết
### Hiệu ứng động lượng
Hiệu ứng động lượng là xu hướng các tài sản có lợi nhuận gần đây cao tiếp tục tạo ra lợi nhuận cao, và các tài sản có lợi nhuận gần đây thấp tiếp tục tạo ra lợi nhuận thấp. Chính thức hơn, nếu chúng ta định nghĩa lợi nhuận tích lũy của cổ phiếu $i$ trong $J$ tháng gần nhất là
$$
R_{i,t-J:t-1} = \prod_{s=t-J}^{t-1} (1 + r_{i,s}) - 1
$$ {#eq-cumret}
thì động lượng dự báo một mối quan hệ chéo dương giữa $R_{i,t-J:t-1}$ và lợi nhuận tương lai $r_{i,t}$. Nghĩa là, các cổ phiếu thuộc thập phân vị cao nhất về lợi nhuận quá khứ (cổ phiếu thắng) sẽ vượt trội các cổ phiếu thuộc thập phân vị thấp nhất (cổ phiếu thua) trong các tháng tiếp theo.
Khung của @jegadeesh1993returns tham số hóa các chiến lược động lượng theo hai chiều chính:
1. **Thời kỳ hình thành** ($J$): Số tháng dùng để tính lợi nhuận quá khứ nhằm xếp hạng cổ phiếu. Giá trị điển hình từ 3 đến 12 tháng.
2. **Thời kỳ nắm giữ** ($K$): Số tháng các danh mục động lượng được nắm giữ sau khi hình thành. Giá trị điển hình cũng từ 3 đến 12 tháng.
Do đó, một chiến lược được đặc trưng bởi cặp $(J, K)$. Chẳng hạn, chiến lược $(6, 6)$ xếp hạng cổ phiếu dựa trên lợi nhuận tích lũy trong 6 tháng gần nhất và nắm giữ các danh mục thu được trong 6 tháng.
### Danh mục chồng lấn và lợi nhuận theo thời gian lịch
Một chi tiết triển khai then chốt trong @jegadeesh1993returns là việc sử dụng các danh mục chồng lấn. Trong mỗi tháng $t$, một bộ danh mục mới được hình thành dựa trên lợi nhuận $J$ tháng gần nhất. Tuy nhiên, các danh mục hình thành vào các tháng $t-1$, $t-2$, $\ldots$, $t-K+1$ vẫn nằm trong thời kỳ nắm giữ và do đó vẫn còn hoạt động. Lợi nhuận của chiến lược động lượng trong tháng $t$ vì vậy là trung bình trọng số bằng nhau của tất cả $K$ nhóm (cohort) đang hoạt động:
$$
r_{p,t} = \frac{1}{K} \sum_{k=0}^{K-1} r_{p,t}^{(t-k)}
$$ {#eq-overlapping}
trong đó $r_{p,t}^{(t-k)}$ ký hiệu lợi nhuận trong tháng $t$ của danh mục được hình thành vào tháng $t-k$. Cách tiếp cận danh mục chồng lấn này phục vụ hai mục đích. Thứ nhất, nó giảm tác động của bất kỳ ngày hình thành đơn lẻ nào lên hiệu quả của chiến lược. Thứ hai, nó tạo ra một chuỗi lợi nhuận hằng tháng có thể phân tích bằng các phương pháp chuỗi thời gian chuẩn, ngay cả khi thời kỳ nắm giữ $K$ vượt quá một tháng.
### Các cách giải thích lý thuyết
Tài liệu học thuật đưa ra hai nhóm giải thích rộng cho hiệu ứng động lượng.
1. **Các giải thích hành vi** quy động lượng cho những thiên lệch nhận thức mang tính hệ thống ở nhà đầu tư. @daniel1998investor đề xuất một mô hình dựa trên sự tự tin thái quá và sự tự quy kết thiên lệch của nhà đầu tư: nhà đầu tư đặt trọng số quá lớn vào các tín hiệu riêng và quy các kết quả xác nhận cho năng lực của bản thân, dẫn đến phản ứng dưới mức ban đầu rồi phản ứng thái quá muộn. @hong1999unified xây dựng một mô hình trong đó thông tin lan truyền dần dần giữa các nhà đầu tư không đồng nhất, tạo ra phản ứng dưới mức với tin tức riêng của doanh nghiệp. @barberis1998model chính thức hóa một mô hình kết hợp thiên lệch bảo thủ (cập nhật niềm tin chậm trước bằng chứng mới) và kinh nghiệm suy đoán theo tính đại diện (ngoại suy các xu hướng gần đây).
2. **Các giải thích dựa trên rủi ro** lập luận rằng lợi nhuận động lượng là sự bù đắp cho rủi ro hệ thống biến thiên theo thời gian. @johnson2002rational chỉ ra rằng động lượng có thể xuất hiện trong khung duy lý nếu lợi nhuận kỳ vọng mang tính ngẫu nhiên và biến thiên theo thời gian. @grundy2001understanding ghi nhận rằng các danh mục động lượng có mức độ phơi nhiễm nhân tố biến thiên theo thời gian đáng kể, cho thấy ít nhất một phần phần bù động lượng phản ánh rủi ro động. Tuy nhiên, các mô hình rủi ro chuẩn như mô hình ba nhân tố Fama-French nhìn chung khó giải thích lợi nhuận động lượng, điều này thúc đẩy việc phát triển các nhân tố động lượng tường minh như nhân tố Winners-Minus-Losers (WML) trong @carhart1997persistence.
### Động lượng ở các thị trường mới nổi
Hành vi của động lượng ở các thị trường mới nổi khác đáng kể so với các thị trường phát triển, và việc hiểu những khác biệt này là thiết yếu để diễn giải kết quả của thị trường Việt Nam.
@rouwenhorst1998international cung cấp bằng chứng sớm rằng lợi nhuận động lượng tồn tại ở các thị trường châu Âu. @chan2000profitability mở rộng phân tích sang các thị trường cổ phiếu quốc tế và thấy rằng lợi nhuận động lượng hiện diện ở hầu hết các thị trường phát triển nhưng yếu hơn hoặc không có ở một số thị trường châu Á. @chui2010individualism đưa ra một giải thích văn hóa, ghi nhận rằng lợi nhuận động lượng có quan hệ dương với mức độ chủ nghĩa cá nhân của một quốc gia theo đo lường của @hofstede2001culture. Các quốc gia có văn hóa tập thể, trong đó có nhiều nước ở Đông Á và Đông Nam Á, có xu hướng thể hiện hiệu ứng động lượng yếu hơn.
Một số đặc điểm vi cấu trúc thị trường phổ biến ở các thị trường mới nổi có thể làm suy yếu động lượng:
- **Giới hạn biên độ giao dịch** hạn chế biến động giá hằng ngày, có thể làm chậm quá trình điều chỉnh tạo ra động lượng. HOSE của Việt Nam áp dụng biên độ $\pm 7\%$ mỗi ngày, trong khi HNX cho phép $\pm 10\%$.
- **Thanh khoản thấp hơn và chi phí giao dịch cao hơn** có thể làm xói mòn lợi nhuận động lượng, như được ghi nhận bởi @lesmond2004illusory.
- **Giới hạn sở hữu nước ngoài** chia tách cơ sở nhà đầu tư, có thể làm thay đổi động lực lan truyền thông tin vốn nền tảng cho động lượng.
- **Lịch sử thị trường ngắn hơn** hạn chế sức mạnh thống kê sẵn có để phát hiện hiệu ứng này.
Những cân nhắc này là lý do để chúng ta phân tích thực nghiệm một cách cẩn thận xem động lượng có biểu hiện trên cổ phiếu Việt Nam hay không và ở mức độ nào.
## Thiết lập môi trường
Chúng ta bắt đầu bằng việc nạp các gói Python cần thiết. Các gói cốt lõi gồm `pandas` để xử lý dữ liệu, `numpy` cho các phép toán số và `sqlite3` để kết nối cơ sở dữ liệu. Chúng ta cũng nhập `plotnine` để tạo các hình ảnh chất lượng xuất bản và `scipy` cho các kiểm định thống kê.
```{python}
import pandas as pd
import numpy as np
import sqlite3
from datetime import datetime
from itertools import product
from plotnine import *
from mizani.formatters import comma_format, percent_format
from scipy import stats
```
Chúng ta kết nối với cơ sở dữ liệu SQLite, nơi lưu trữ các bộ dữ liệu đã làm sạch được chuẩn bị ở các chương trước.
```{python}
tidy_finance = sqlite3.connect(
database="data/tidy_finance_python.sqlite"
)
```
## Chuẩn bị dữ liệu
### Nạp lợi nhuận cổ phiếu hằng tháng
Chúng ta nạp dữ liệu giá cổ phiếu hằng tháng từ cơ sở dữ liệu. Bảng `prices_monthly` chứa lợi nhuận đã điều chỉnh, vốn hóa thị trường và các biến khác của tất cả cổ phiếu niêm yết trên HOSE và HNX.
```{python}
prices_monthly = pd.read_sql_query(
# can add "exchange" variable
sql="""
SELECT symbol, date, ret, ret_excess, mktcap, mktcap_lag, risk_free
FROM prices_monthly
""",
con=tidy_finance,
parse_dates={"date"}
).dropna()
print(f"Total monthly observations: {len(prices_monthly):,}")
print(f"Unique stocks: {prices_monthly['symbol'].nunique():,}")
print(f"Date range: {prices_monthly['date'].min().date()} "
f"to {prices_monthly['date'].max().date()}")
```
### Kiểm tra dữ liệu
Trước khi xây dựng danh mục, chúng ta xem xét các biến chính trong bộ dữ liệu. @tbl-data-summary trình bày thống kê mô tả của các biến chính dùng để xây dựng danh mục động lượng.
```{python}
#| label: tbl-data-summary
#| tbl-cap: "Thống kê mô tả của dữ liệu lợi nhuận cổ phiếu hằng tháng. Bảng báo cáo số quan sát, trung bình, độ lệch chuẩn, giá trị nhỏ nhất, phân vị 25, trung vị, phân vị 75 và giá trị lớn nhất của lợi nhuận hằng tháng (ret), lợi nhuận vượt mức (ret_excess) và vốn hóa thị trường (mktcap, tính bằng tỷ VND)."
summary_stats = (prices_monthly
[["ret", "ret_excess", "mktcap"]]
.describe()
.T
.round(4)
)
summary_stats
```
Chúng ta cũng xem xét phân phối chéo của các cổ phiếu theo thời gian, điều này quan trọng để hiểu liệu chúng ta có đủ độ rộng để xây dựng các danh mục thập phân vị hay không.
```{python}
#| label: fig-stock-count
#| fig-cap: "Số cổ phiếu có dữ liệu lợi nhuận theo thời gian. Hình thể hiện mặt cắt ngang hằng tháng của các cổ phiếu có thể dùng để xây dựng danh mục động lượng. Cần một số lượng cổ phiếu tối thiểu để hình thành các danh mục thập phân vị có ý nghĩa."
#| fig-alt: "Biểu đồ đường thể hiện số cổ phiếu có lợi nhuận hằng tháng theo thời gian, nhìn chung tăng từ một số lượng nhỏ vào đầu những năm 2000 lên vài trăm trong những năm gần đây."
stock_counts = (prices_monthly
.groupby("date")["symbol"]
.nunique()
.reset_index()
.rename(columns={"symbol": "n_stocks"})
)
plot_stock_counts = (
ggplot(stock_counts, aes(x="date", y="n_stocks")) +
geom_line(color="#1f77b4") +
labs(
x="", y="Number of stocks",
title="Cross-Sectional Breadth Over Time"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_stock_counts
```
### Nạp dữ liệu nhân tố
Chúng ta cũng nạp lợi nhuận các nhân tố Fama-French để đánh giá hiệu quả điều chỉnh theo rủi ro. Các nhân tố này đã được xây dựng ở các chương trước.
```{python}
factors_ff3_monthly = pd.read_sql_query(
sql="SELECT date, mkt_excess, smb, hml, risk_free FROM factors_ff3_monthly",
con=tidy_finance,
parse_dates={"date"}
)
print(f"Factor observations: {len(factors_ff3_monthly):,}")
print(f"Date range: {factors_ff3_monthly['date'].min().date()} "
f"to {factors_ff3_monthly['date'].max().date()}")
```
### Bộ lọc chất lượng dữ liệu
Các chiến lược động lượng đòi hỏi lịch sử lợi nhuận liên tục trong thời kỳ hình thành. Chúng ta áp dụng một số bộ lọc để bảo đảm chất lượng dữ liệu:
1. **Bộ lọc giá tối thiểu**: Chúng ta loại các cổ phiếu penny có giá dưới 1.000 VND, vốn chịu nhiễu vi cấu trúc cực đoan.
2. **Có sẵn lợi nhuận**: Chúng ta yêu cầu lợi nhuận không bị thiếu trong toàn bộ thời kỳ hình thành.
3. **Vốn hóa thị trường**: Chúng ta yêu cầu vốn hóa thị trường kỳ trước (có độ trễ) phải dương để đặt trọng số danh mục.
```{python}
# Filter for stocks with positive market cap and non-missing returns
prices_clean = (prices_monthly
.dropna(subset=["ret", "mktcap"])
.query("mktcap > 0")
.sort_values(["symbol", "date"])
.reset_index(drop=True)
)
print(f"Observations after filtering: {len(prices_clean):,}")
print(f"Stocks after filtering: {prices_clean['symbol'].nunique():,}")
```
## Xây dựng danh mục động lượng
### Tính lợi nhuận thời kỳ hình thành
Bước đầu tiên trong việc xây dựng các danh mục động lượng là tính lợi nhuận tích lũy trong thời kỳ hình thành. Với thời kỳ hình thành $J$ tháng, chúng ta tính lợi nhuận tích lũy của mỗi cổ phiếu $i$ tại cuối tháng $t$ như quy định trong @eq-cumret.
Chúng ta triển khai bằng một tích trượt (rolling product) của các lợi nhuận gộp. Một chi tiết quan trọng là chúng ta yêu cầu lợi nhuận không bị thiếu trong cả $J$ tháng của cửa sổ hình thành. Nếu thiếu bất kỳ lợi nhuận hằng tháng nào, lợi nhuận tích lũy được đặt là thiếu, và cổ phiếu bị loại khỏi việc hình thành danh mục trong tháng đó.
```{python}
def compute_formation_returns(data, J):
"""
Compute J-month cumulative returns for momentum portfolio formation.
Parameters
----------
data : pd.DataFrame
Panel data with columns 'symbol', 'date', 'ret'.
J : int
Formation period length in months (typically 3-12).
Returns
-------
pd.DataFrame
Original data augmented with 'cum_return' column.
"""
df = data.sort_values(["symbol", "date"]).copy()
# Compute rolling J-month cumulative return
# Using gross returns: (1+r1)*(1+r2)*...*(1+rJ) - 1
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"])
return df
```
Hãy áp dụng hàm này cho trường hợp cơ sở với $J = 6$ tháng:
```{python}
J = 6 # Formation period: 6 months
prices_with_cumret = compute_formation_returns(prices_clean, J)
# Check the result
print(f"Observations with valid {J}-month cumulative returns: "
f"{prices_with_cumret['cum_return'].notna().sum():,}")
print(f"\nCumulative return distribution:")
print(prices_with_cumret["cum_return"].describe().round(4))
```
### Gán các danh mục thập phân vị động lượng
Mỗi tháng, chúng ta phân loại tất cả cổ phiếu có lợi nhuận tích lũy hợp lệ vào các danh mục thập phân vị dựa trên kết quả $J$ tháng gần nhất. Danh mục 1 chứa thập phân vị thấp nhất (cổ phiếu thua) và danh mục 10 chứa thập phân vị cao nhất (cổ phiếu thắng).
```{python}
def assign_momentum_portfolios(data, n_portfolios=10):
"""
Assign stocks to momentum portfolios based on formation-period returns.
For each cross-section (month), stocks are sorted into
n_portfolios quantile groups according to their cumulative return.
Portfolio 1 = lowest past returns (Losers)
Portfolio n_portfolios = highest past returns (Winners)
This implementation uses `groupby().transform()` rather than
`groupby().apply()` to preserve the original DataFrame structure
and avoid index mutation issues.
Parameters
----------
data : pd.DataFrame
Panel data containing at minimum:
- 'symbol' : stock identifier
- 'date' : formation month
- 'cum_return' : cumulative return over formation window
n_portfolios : int, optional
Number of portfolios to form (default = 10 for deciles).
Returns
-------
pd.DataFrame
Original data augmented with:
- 'momr' : integer momentum rank (1 to n_portfolios)
"""
# Drop observations without formation-period returns
# These cannot be ranked into portfolios
df = data.dropna(subset=["cum_return"]).copy()
def safe_qcut(x):
"""
Assign quantile portfolio labels within a single cross-section.
Uses pd.qcut to create approximately equal-sized portfolios.
If too few unique return values exist (which can happen in
small markets or illiquid samples), fall back to rank-based
binning to ensure portfolios are still formed.
"""
try:
# Standard quantile sorting
return pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop" # prevents crash if quantile edges duplicate
)
except ValueError:
# Fallback: rank then evenly cut into bins
return pd.cut(
x.rank(method="first"),
bins=n_portfolios,
labels=range(1, n_portfolios + 1)
)
# Cross-sectional portfolio assignment:
# For each month, compute portfolio ranks based on cum_return.
# transform ensures:
# - original row order preserved
# - no index mutation
# - no loss of 'date' column
df["momr"] = (
df.groupby("date")["cum_return"]
.transform(safe_qcut)
.astype(int)
)
return df
```
```{python}
portfolios = assign_momentum_portfolios(prices_with_cumret)
print(f"Stocks assigned to portfolios: {len(portfolios):,}")
print(f"\nPortfolio distribution (should be approximately equal):")
print(portfolios.groupby("momr")["symbol"].count().to_frame("count"))
```
### Xác định các ngày của thời kỳ nắm giữ
Sau khi hình thành danh mục vào cuối tháng $t$, chúng ta nắm giữ chúng từ đầu tháng $t+1$ đến cuối tháng $t+K$. Khoảng cách một tháng giữa thời kỳ hình thành và thời điểm bắt đầu thời kỳ nắm giữ là chuẩn trong tài liệu và tránh hiệu ứng đảo chiều ngắn hạn đã được ghi nhận rõ ở chân trời một tháng [@jegadeesh1990evidence].
```{python}
K = 6 # Holding period: 6 months
portfolios_with_dates = portfolios.copy()
portfolios_with_dates = portfolios_with_dates.rename(
columns={"date": "form_date"}
)
# Define holding period start and end dates
portfolios_with_dates["hdate1"] = (
portfolios_with_dates["form_date"] + pd.offsets.MonthBegin(1)
)
portfolios_with_dates["hdate2"] = (
portfolios_with_dates["form_date"] + pd.offsets.MonthEnd(K)
)
print(portfolios_with_dates[
["symbol", "form_date", "cum_return", "momr", "hdate1", "hdate2"]
].head(10))
```
### Tính lợi nhuận danh mục trong thời kỳ nắm giữ
Bây giờ chúng ta tính lợi nhuận của từng danh mục trong thời kỳ nắm giữ. Với mỗi tổ hợp cổ phiếu-ngày hình thành, chúng ta ghép vào các lợi nhuận thực tế đạt được trong cửa sổ nắm giữ. Điều này tạo ra một bảng dữ liệu (panel) lợi nhuận cổ phiếu được đánh chỉ mục theo ngày hình thành, ngày nắm giữ và thứ hạng danh mục động lượng.
```{python}
# Merge: for each portfolio assignment, get all returns during holding period
portfolio_returns = portfolios_with_dates.merge(
prices_clean[["symbol", "date", "ret"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Keep only returns within the holding period
portfolio_returns = portfolio_returns.query(
"hdate >= hdate1 and hdate <= hdate2"
)
print(f"Portfolio-holding period observations: {len(portfolio_returns):,}")
```
### Tính lợi nhuận danh mục trọng số bằng nhau
Phương pháp của @jegadeesh1993returns dùng các danh mục trọng số bằng nhau. Với mỗi tháng nắm giữ, mỗi thập phân vị động lượng có $K$ nhóm đang hoạt động (mỗi nhóm hình thành vào một trong $K$ tháng gần nhất). Trước tiên chúng ta tính lợi nhuận trọng số bằng nhau trong mỗi nhóm, sau đó lấy trung bình giữa các nhóm để có lợi nhuận danh mục hằng tháng cuối cùng.
```{python}
# Step 1: Average return within each cohort (form_date × momr × hdate)
cohort_returns = (portfolio_returns
.groupby(["hdate", "momr", "form_date"])
.agg(cohort_ret=("hret", "mean"))
.reset_index()
)
# Step 2: Average across cohorts for each momentum portfolio × month
ewret = (cohort_returns
.groupby(["hdate", "momr"])
.agg(
ewret=("cohort_ret", "mean"),
ewret_std=("cohort_ret", "std"),
n_cohorts=("cohort_ret", "count")
)
.reset_index()
.rename(columns={"hdate": "date"})
)
print(f"Monthly portfolio return observations: {len(ewret):,}")
print(f"Date range: {ewret['date'].min().date()} to {ewret['date'].max().date()}")
```
Chúng ta nên kiểm tra rằng mỗi tháng có số nhóm đang hoạt động đúng như kỳ vọng. Khi chiến lược đã chạy ít nhất $K$ tháng, mỗi thập phân vị động lượng phải có đúng $K$ nhóm đang hoạt động.
```{python}
#| label: fig-cohort-count
#| fig-cap: "Số nhóm đang hoạt động của mỗi danh mục động lượng theo thời gian. Sau giai đoạn khởi động ban đầu K tháng, mỗi danh mục phải có đúng K nhóm đang hoạt động đóng góp vào lợi nhuận hằng tháng."
#| fig-alt: "Biểu đồ đường thể hiện số nhóm đang hoạt động của mỗi danh mục động lượng theo thời gian, tăng dần từ 1 đến K trong những tháng đầu rồi duy trì ổn định ở mức K."
cohort_check = (ewret
.groupby("date")["n_cohorts"]
.mean()
.reset_index()
.rename(columns={"n_cohorts": "avg_cohorts"})
)
plot_cohorts = (
ggplot(cohort_check, aes(x="date", y="avg_cohorts")) +
geom_line(color="#1f77b4") +
geom_hline(yintercept=K, linetype="dashed", color="red") +
labs(
x="", y="Average number of cohorts",
title=f"Active Cohorts per Portfolio (K={K})"
) +
theme_minimal() +
theme(figure_size=(10, 4))
)
plot_cohorts
```
## Kết quả cơ sở: Chiến lược J=6, K=6
### Thống kê mô tả theo thập phân vị động lượng
Bây giờ chúng ta xem xét lợi nhuận hằng tháng trung bình của từng danh mục thập phân vị động lượng. @tbl-momentum-deciles trình bày giá trị trung bình, thống kê $t$ và giá trị $p$ cho mỗi danh mục trong mười danh mục.
```{python}
#| label: tbl-momentum-deciles
#| tbl-cap: "Lợi nhuận hằng tháng trung bình của các danh mục thập phân vị động lượng cho chiến lược J=6, K=6. Danh mục 1 chứa các cổ phiếu thua trong quá khứ và danh mục 10 chứa các cổ phiếu thắng trong quá khứ. Bảng báo cáo số tháng, lợi nhuận hằng tháng trung bình, thống kê t và giá trị p của mỗi thập phân vị."
def compute_portfolio_stats(group):
"""Compute mean, t-stat, and p-value for a return series."""
n = len(group)
mean_ret = group.mean()
std_ret = group.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1)) if not np.isnan(t_stat) else np.nan
return pd.Series({
"N": n,
"Mean (%)": mean_ret * 100,
"Std (%)": std_ret * 100,
"t-stat": t_stat,
"p-value": p_val
})
momentum_stats = (ewret
.groupby("momr")["ewret"]
.apply(compute_portfolio_stats)
.unstack()
.round(4)
)
momentum_stats
```
### Danh mục động lượng mua–bán khống
Phép kiểm tra then chốt của hiệu ứng động lượng là liệu chênh lệch giữa cổ phiếu thắng và cổ phiếu thua, tức danh mục động lượng mua–bán khống (long–short), có tạo ra lợi nhuận dương có ý nghĩa thống kê hay không. Chúng ta xây dựng danh mục chênh lệch này bằng cách mua thập phân vị cao nhất (danh mục 10) và bán khống thập phân vị thấp nhất (danh mục 1).
```{python}
# Pivot to wide format
ewret_wide = ewret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
ewret_wide.columns = ["date"] + [f"port{i}" for i in range(1, 11)]
# Compute long-short return
ewret_wide["winners"] = ewret_wide["port10"]
ewret_wide["losers"] = ewret_wide["port1"]
ewret_wide["long_short"] = ewret_wide["winners"] - ewret_wide["losers"]
```
```{python}
#| label: tbl-long-short
#| tbl-cap: "Hiệu quả của chiến lược động lượng mua–bán khống (J=6, K=6). Winners là thập phân vị cao nhất, Losers là thập phân vị thấp nhất, và Long-Short là Winners trừ Losers. Bảng báo cáo số tháng, lợi nhuận hằng tháng trung bình, thống kê t và giá trị p."
ls_stats = pd.DataFrame()
for col in ["winners", "losers", "long_short"]:
series = ewret_wide[col].dropna()
n = len(series)
mean_val = series.mean()
std_val = series.std()
t_val = mean_val / (std_val / np.sqrt(n))
p_val = 2 * (1 - stats.t.cdf(abs(t_val), df=n-1))
ls_stats[col] = [n, mean_val * 100, std_val * 100, t_val, p_val]
ls_stats.index = ["N", "Mean (%)", "Std (%)", "t-stat", "p-value"]
ls_stats.columns = ["Winners", "Losers", "Long-Short"]
ls_stats = ls_stats.round(4)
ls_stats
```
### Lợi nhuận tích lũy
Diễn biến theo chuỗi thời gian của lợi nhuận tích lũy cung cấp bằng chứng trực quan về hiệu quả của chiến lược động lượng. @fig-cumret-winners-losers cho thấy lợi nhuận tích lũy của các danh mục thắng và thua, trong khi @fig-cumret-long-short cho thấy lợi nhuận tích lũy của chiến lược động lượng mua–bán khống.
```{python}
# Compute cumulative returns
ewret_wide = ewret_wide.sort_values("date").reset_index(drop=True)
for col in ["winners", "losers", "long_short"]:
ewret_wide[f"cumret_{col}"] = (1 + ewret_wide[col]).cumprod() - 1
```
```{python}
#| label: fig-cumret-winners-losers
#| fig-cap: "Lợi nhuận tích lũy của các danh mục động lượng thắng và thua. Danh mục thắng (màu xanh dương) chứa các cổ phiếu thuộc thập phân vị cao nhất về lợi nhuận 6 tháng gần nhất, và danh mục thua (màu đỏ) chứa các cổ phiếu thuộc thập phân vị thấp nhất. Khoảng cách giữa hai đường phản ánh phần bù động lượng tích lũy."
#| fig-alt: "Biểu đồ đường thể hiện lợi nhuận tích lũy của các danh mục động lượng thắng và thua theo thời gian. Danh mục thắng nhìn chung tích lũy lợi nhuận cao hơn danh mục thua, dù cả hai đều biến động đáng kể."
cumret_plot_data = (ewret_wide
[["date", "cumret_winners", "cumret_losers"]]
.melt(id_vars="date", var_name="portfolio", value_name="cumret")
)
cumret_plot_data["portfolio"] = cumret_plot_data["portfolio"].map({
"cumret_winners": "Winners (P10)",
"cumret_losers": "Losers (P1)"
})
plot_cumret = (
ggplot(cumret_plot_data,
aes(x="date", y="cumret", color="portfolio")) +
geom_line(size=1) +
scale_y_continuous(labels=percent_format()) +
scale_color_manual(values=["#d62728", "#1f77b4"]) +
labs(
x="", y="Cumulative return",
color="Portfolio",
title="Cumulative Returns: Winners vs. Losers"
) +
theme_minimal() +
theme(
figure_size=(10, 6),
legend_position="bottom"
)
)
plot_cumret
```
```{python}
#| label: fig-cumret-long-short
#| fig-cap: "Lợi nhuận tích lũy của chiến lược động lượng mua–bán khống (Winners trừ Losers). Các giai đoạn đi lên cho thấy lợi nhuận động lượng; các đợt sụt giảm mạnh thường trùng với đảo chiều thị trường hoặc các giai đoạn khủng hoảng."
#| fig-alt: "Biểu đồ đường thể hiện lợi nhuận tích lũy của chiến lược động lượng mua–bán khống theo thời gian, với các giai đoạn tích lũy xen kẽ những đợt sụt giảm mạnh."
plot_ls = (
ggplot(ewret_wide, aes(x="date", y="cumret_long_short")) +
geom_line(size=1, color="#2ca02c") +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Cumulative return",
title="Cumulative Return: Long-Short Momentum Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 6))
)
plot_ls
```
### Phân phối lợi nhuận hằng tháng
Ngoài giá trị trung bình, toàn bộ phân phối của lợi nhuận mua–bán khống hằng tháng cho thấy hồ sơ rủi ro của chiến lược động lượng. @fig-return-distribution thể hiện biểu đồ tần suất của lợi nhuận hằng tháng.
```{python}
#| label: fig-return-distribution
#| fig-cap: "Phân phối lợi nhuận động lượng mua–bán khống hằng tháng. Biểu đồ tần suất thể hiện phân phối tần suất của lợi nhuận Winners trừ Losers hằng tháng. Đường nét đứt thẳng đứng chỉ lợi nhuận hằng tháng trung bình."
#| fig-alt: "Biểu đồ tần suất của lợi nhuận động lượng mua–bán khống hằng tháng, tập trung xấp xỉ quanh mức không hoặc hơi dương, với các đuôi kéo dài về cả hai phía."
mean_ls = ewret_wide["long_short"].mean()
plot_dist = (
ggplot(ewret_wide, aes(x="long_short")) +
geom_histogram(bins=50, fill="#1f77b4", alpha=0.7) +
geom_vline(xintercept=mean_ls, linetype="dashed", color="red") +
scale_x_continuous(labels=percent_format()) +
labs(
x="Monthly long-short return",
y="Frequency",
title="Distribution of Monthly Momentum Returns"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_dist
```
## Mở rộng sang nhiều thời kỳ hình thành và nắm giữ
### Lưới J × K
@jegadeesh1993returns đánh giá các chiến lược động lượng trên một lưới toàn diện gồm các thời kỳ hình thành $J \in \{3, 6, 9, 12\}$ và các thời kỳ nắm giữ $K \in \{3, 6, 9, 12\}$. Điều này tạo ra 16 đặc tả chiến lược khác nhau, cho phép chúng ta đánh giá độ vững của hiệu ứng động lượng qua các chân trời khác nhau.
Bây giờ chúng ta triển khai một hàm tính toàn bộ chiến lược động lượng cho bất kỳ cặp $(J, K)$ nào, gói các bước đã phát triển ở trên thành một quy trình duy nhất có thể tái sử dụng.
```{python}
from joblib import Parallel, delayed
import time
def momentum_strategy(data, J, K, n_portfolios=10):
"""
Implement the full Jegadeesh-Titman momentum strategy.
Parameters
----------
data : pd.DataFrame
Panel of stock returns with columns: symbol, date, ret.
J : int
Formation period in months.
K : int
Holding period in months.
n_portfolios : int
Number of portfolios (default: 10 for deciles).
Returns
-------
pd.DataFrame
Monthly returns for each momentum portfolio and the long-short spread.
"""
# Step 1: Compute formation period cumulative returns
df = data.sort_values(["symbol", "date"]).copy()
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"]).dropna(subset=["cum_return"])
# Step 2: Assign to momentum portfolios using transform
df["momr"] = df.groupby("date", observed=True)["cum_return"].transform(
lambda x: pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop"
)
).astype('Int64')
# If any NaNs in momr, fill with rank-based assignment
mask = df["momr"].isna()
if mask.any():
df.loc[mask, "momr"] = df.loc[mask].groupby("date")["cum_return"].transform(
lambda x: pd.qcut(
x.rank(method="first"),
q=min(n_portfolios, len(x.unique())),
labels=False,
duplicates="drop"
)
).astype('Int64') + 1
# Step 3: Define holding period dates
df = df.rename(columns={"date": "form_date"})
df["hdate1"] = df["form_date"] + pd.offsets.MonthBegin(1)
df["hdate2"] = df["form_date"] + pd.offsets.MonthEnd(K)
# Step 4: Merge with holding period returns
port_ret = df[["symbol", "form_date", "momr", "hdate1", "hdate2"]].merge(
data[["symbol", "date", "ret"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing
port_ret = port_ret[
(port_ret["hdate"] >= port_ret["hdate1"]) &
(port_ret["hdate"] <= port_ret["hdate2"])
]
# Step 5: Compute equally weighted returns (two-stage averaging)
cohort_ret = (port_ret
.groupby(["hdate", "momr", "form_date"])
.agg(cohort_ret=("hret", "mean"))
.reset_index()
)
monthly_ret = (cohort_ret
.groupby(["hdate", "momr"])
.agg(ewret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"hdate": "date"})
)
# Step 6: Compute long-short spread
wide = monthly_ret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
# Handle variable number of portfolios
n_cols = len(wide.columns) - 1
wide.columns = ["date"] + [f"port{i}" for i in range(1, n_cols + 1)]
wide["winners"] = wide[f"port{n_cols}"]
wide["losers"] = wide["port1"]
wide["long_short"] = wide["winners"] - wide["losers"]
return monthly_ret, wide
```
### Chạy toàn bộ lưới
Bây giờ chúng ta chạy chiến lược động lượng cho cả 16 tổ hợp $(J, K)$. Việc này tốn nhiều tính toán, nên chúng ta lưu các thống kê tóm tắt chính của mỗi đặc tả.
#### Tính toán tuần tự
```{python}
#| eval: false
#| label: momemtum-calculation-squential
J_values = [3, 6, 9, 12]
K_values = [3, 6, 9, 12]
results_grid = []
for J_val, K_val in product(J_values, K_values):
print(f"Computing J={J_val}, K={K_val}...", end=" ")
try:
_, wide_result = momentum_strategy(prices_clean, J_val, K_val)
for portfolio_name in ["winners", "losers", "long_short"]:
series = wide_result[portfolio_name].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = (2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
if not np.isnan(t_stat) else np.nan)
results_grid.append({
"J": J_val,
"K": K_val,
"portfolio": portfolio_name,
"n_months": n,
"mean_ret": mean_ret,
"std_ret": std_ret,
"t_stat": t_stat,
"p_value": p_val
})
print("Done.")
except Exception as e:
print(f"Error: {e}")
results_df = pd.DataFrame(results_grid)
```
#### Tính toán song song
```{python}
def compute_single_strategy(data, J, K):
"""
Compute statistics for a single (J, K) strategy.
Returns a list of result dicts, one per portfolio.
"""
try:
_, wide_result = momentum_strategy(data, J, K)
results = []
for portfolio_name in ["winners", "losers", "long_short"]:
series = wide_result[portfolio_name].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = (2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
if not np.isnan(t_stat) else np.nan)
results.append({
"J": J,
"K": K,
"portfolio": portfolio_name,
"n_months": n,
"mean_ret": mean_ret,
"std_ret": std_ret,
"t_stat": t_stat,
"p_value": p_val
})
return results
except Exception as e:
print(f"Error in J={J}, K={K}: {e}")
return []
```
```{python}
J_values = [3, 6, 9, 12]
K_values = [3, 6, 9, 12]
# Create list of (J, K) pairs
params = list(product(J_values, K_values))
print(f"Running {len(params)} momentum strategies in parallel with 4 cores...")
start_time = time.time()
# Parallel execution with 4 workers
results_list = Parallel(n_jobs=4, verbose=10)(
delayed(compute_single_strategy)(prices_clean, J, K)
for J, K in params
)
# Flatten results
results_grid = [item for sublist in results_list for item in sublist]
results_df = pd.DataFrame(results_grid)
elapsed = time.time() - start_time
print(f"\nCompleted in {elapsed:.2f} seconds")
```
```{python}
# Summary statistics
print("\nResults Summary by Formation Period:")
print(results_df.groupby("J").agg({
"mean_ret": "mean",
"std_ret": "mean",
"t_stat": ["mean", lambda x: (x.abs() > 1.96).sum()],
"n_months": "first"
}).round(6))
```
### Lợi nhuận danh mục cổ phiếu thắng
@tbl-winners-grid trình bày lợi nhuận hằng tháng trung bình của danh mục cổ phiếu thắng (danh mục 10) qua tất cả các tổ hợp $(J, K)$.
```{python}
#| label: tbl-winners-grid
#| tbl-cap: "Lợi nhuận hằng tháng trung bình (%) của danh mục cổ phiếu thắng qua các thời kỳ hình thành (J) và thời kỳ nắm giữ (K). Thống kê t được báo cáo trong ngoặc đơn."
winners_grid = (results_df
.query("portfolio == 'winners'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
winners_grid.columns = [f"K={k}" for k in winners_grid.columns]
winners_grid.index = [f"J={j}" for j in winners_grid.index]
winners_grid
```
### Lợi nhuận danh mục cổ phiếu thua
@tbl-losers-grid trình bày kết quả tương ứng cho danh mục cổ phiếu thua.
```{python}
#| label: tbl-losers-grid
#| tbl-cap: "Lợi nhuận hằng tháng trung bình (%) của danh mục cổ phiếu thua qua các thời kỳ hình thành (J) và thời kỳ nắm giữ (K). Thống kê t được báo cáo trong ngoặc đơn."
losers_grid = (results_df
.query("portfolio == 'losers'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
losers_grid.columns = [f"K={k}" for k in losers_grid.columns]
losers_grid.index = [f"J={j}" for j in losers_grid.index]
losers_grid
```
### Lợi nhuận động lượng mua–bán khống
@tbl-ls-grid trình bày kết quả quan trọng nhất: lợi nhuận hằng tháng trung bình của danh mục động lượng mua–bán khống qua tất cả các đặc tả.
```{python}
#| label: tbl-ls-grid
#| tbl-cap: "Lợi nhuận hằng tháng trung bình (%) của danh mục động lượng mua–bán khống (Winners trừ Losers) qua các thời kỳ hình thành (J) và thời kỳ nắm giữ (K). Thống kê t được báo cáo trong ngoặc đơn. Bảng này lặp lại định dạng của Bảng 1 trong Jegadeesh và Titman (1993)."
ls_grid = (results_df
.query("portfolio == 'long_short'")
.assign(
display=lambda x: (
x["mean_ret"].apply(lambda v: f"{v*100:.2f}") +
"\n(" + x["t_stat"].apply(lambda v: f"{v:.2f}") + ")"
)
)
.pivot(index="J", columns="K", values="display")
)
ls_grid.columns = [f"K={k}" for k in ls_grid.columns]
ls_grid.index = [f"J={j}" for j in ls_grid.index]
ls_grid
```
### Trực quan hóa phần bù động lượng qua các đặc tả
@fig-momentum-heatmap cung cấp bản tóm tắt trực quan về phần bù động lượng mua–bán khống qua tất cả các tổ hợp $(J, K)$.
```{python}
#| label: fig-momentum-heatmap
#| fig-cap: "Bản đồ nhiệt của lợi nhuận động lượng mua–bán khống hằng tháng trung bình (%) qua các thời kỳ hình thành (J) và thời kỳ nắm giữ (K). Màu càng đậm cho thấy lợi nhuận động lượng càng cao. Dấu sao biểu thị ý nghĩa thống kê ở mức 5%."
#| fig-alt: "Bản đồ nhiệt với thời kỳ hình thành J trên trục tung và thời kỳ nắm giữ K trên trục hoành, màu của các ô cho biết độ lớn của phần bù động lượng."
ls_heatmap_data = (results_df
.query("portfolio == 'long_short'")
.assign(
mean_pct=lambda x: x["mean_ret"] * 100,
significant=lambda x: x["p_value"] < 0.05,
label=lambda x: x.apply(
lambda row: f"{row['mean_ret']*100:.2f}{'*' if row['p_value'] < 0.05 else ''}",
axis=1
)
)
)
plot_heatmap = (
ggplot(ls_heatmap_data,
aes(x="K.astype(str)", y="J.astype(str)", fill="mean_pct")) +
geom_tile(color="white", size=2) +
geom_text(aes(label="label"), size=10, color="white") +
scale_fill_gradient2(
low="#d62728", mid="#f7f7f7", high="#1f77b4", midpoint=0,
name="Monthly\nReturn (%)"
) +
labs(
x="Holding Period (K months)",
y="Formation Period (J months)",
title="Momentum Premium Across J×K Specifications"
) +
theme_minimal() +
theme(figure_size=(8, 6))
)
plot_heatmap
```
## Hiệu quả điều chỉnh theo rủi ro
### Alpha CAPM
Một câu hỏi tự nhiên là liệu phần bù động lượng có được giải thích bởi mức độ phơi nhiễm với nhân tố thị trường hay không. Chúng ta ước lượng alpha CAPM của danh mục động lượng mua–bán khống bằng cách hồi quy lợi nhuận của nó theo lợi nhuận vượt mức của thị trường:
$$
r_{\text{WML},t} = \alpha + \beta \cdot r_{\text{MKT},t} + \epsilon_t
$$ {#eq-capm-alpha}
```{python}
# Merge momentum returns with factor data (baseline J=6, K=6)
ewret_factors = (ewret_wide
[["date", "winners", "losers", "long_short"]]
.merge(factors_ff3_monthly, on="date", how="inner")
)
# CAPM regression for long-short portfolio
from statsmodels.formula.api import ols as ols_formula
import statsmodels.api as sm
X_capm = sm.add_constant(ewret_factors["mkt_excess"])
y_ls = ewret_factors["long_short"]
capm_model = sm.OLS(y_ls, X_capm).fit(cov_type="HAC", cov_kwds={"maxlags": 6})
print("CAPM Regression: Long-Short Momentum Returns")
print("=" * 60)
print(f"Alpha (monthly): {capm_model.params['const']*100:.4f}% "
f"(t={capm_model.tvalues['const']:.2f})")
print(f"Market Beta: {capm_model.params['mkt_excess']:.4f} "
f"(t={capm_model.tvalues['mkt_excess']:.2f})")
print(f"R-squared: {capm_model.rsquared:.4f}")
print(f"N observations: {capm_model.nobs:.0f}")
```
### Alpha mô hình ba nhân tố Fama-French
Chúng ta mở rộng việc điều chỉnh rủi ro sang mô hình ba nhân tố Fama-French, gồm các nhân tố quy mô (SMB) và giá trị (HML) bên cạnh nhân tố thị trường:
$$
r_{\text{WML},t} = \alpha + \beta_1 \cdot r_{\text{MKT},t} + \beta_2 \cdot \text{SMB}_t + \beta_3 \cdot \text{HML}_t + \epsilon_t
$$ {#eq-ff3-alpha}
```{python}
#| label: tbl-ff3-regression
#| tbl-cap: "Hồi quy ba nhân tố Fama-French cho danh mục động lượng mua–bán khống. Biến phụ thuộc là lợi nhuận hằng tháng của danh mục Winners trừ Losers. Alpha là hệ số chặn, biểu thị lợi nhuận bất thường đã điều chỉnh rủi ro. Sai số chuẩn HAC với 6 độ trễ được sử dụng."
X_ff3 = sm.add_constant(
ewret_factors[["mkt_excess", "smb", "hml"]]
)
y_ls = ewret_factors["long_short"]
ff3_model = sm.OLS(y_ls, X_ff3).fit(cov_type="HAC", cov_kwds={"maxlags": 6})
# Display results as a clean table
ff3_results = pd.DataFrame({
"Coefficient": ff3_model.params,
"Std Error": ff3_model.bse,
"t-stat": ff3_model.tvalues,
"p-value": ff3_model.pvalues
}).round(4)
ff3_results.index = ["Alpha", "MKT", "SMB", "HML"]
ff3_results
```
```{python}
print(f"\nR-squared: {ff3_model.rsquared:.4f}")
print(f"Adjusted R-squared: {ff3_model.rsquared_adj:.4f}")
print(f"Alpha (annualized): {ff3_model.params['const'] * 12 * 100:.2f}%")
```
### Diễn giải mức độ phơi nhiễm rủi ro
Các hệ số tải nhân tố từ hồi quy ba nhân tố cho thấy đặc điểm rủi ro của chiến lược động lượng trên thị trường Việt Nam. Một số mô thức thường được quan sát:
1. **Beta thị trường**: Các danh mục động lượng thường có mức phơi nhiễm thị trường vừa phải. Ở Hoa Kỳ, @grundy2001understanding ghi nhận rằng beta thị trường của danh mục mua–bán khống nhìn chung gần bằng không nhưng biến thiên mạnh theo thời gian, tăng vọt khi thị trường đảo chiều.
2. **Phơi nhiễm quy mô (SMB)**: Các chiến lược động lượng thường có hệ số tải dương trên nhân tố quy mô, phản ánh xu hướng các cổ phiếu nhỏ hơn thể hiện mô thức động lượng mạnh hơn.
3. **Phơi nhiễm giá trị (HML)**: Danh mục động lượng mua–bán khống thường có hệ số tải âm trên HML, cho thấy cổ phiếu thắng có xu hướng là cổ phiếu tăng trưởng còn cổ phiếu thua có xu hướng là cổ phiếu giá trị. Điều này tạo ra sự đối nghịch tự nhiên giữa các chiến lược động lượng và giá trị.
## Động lượng và các trạng thái thị trường
### Hiệu quả có điều kiện
Một phát hiện quan trọng trong tài liệu về động lượng là lợi nhuận động lượng thay đổi theo điều kiện thị trường. @cooper2004market ghi nhận rằng các chiến lược động lượng hoạt động tốt sau khi thị trường tăng (thị trường UP) nhưng chịu thua lỗ nặng sau khi thị trường giảm (thị trường DOWN). Sự bất đối xứng này đặc biệt liên quan đến các thị trường mới nổi, vốn trải qua các trạng thái thị trường cực đoan hơn.
Chúng ta định nghĩa các trạng thái thị trường dựa trên lợi nhuận thị trường tích lũy trong 12 tháng trước đó:
$$
\text{Market State}_t = \begin{cases} \text{UP} & \text{if } \prod_{s=t-12}^{t-1}(1 + r_{m,s}) - 1 > 0 \\ \text{DOWN} & \text{otherwise} \end{cases}
$$ {#eq-market-state}
```{python}
# Compute 12-month lagged market return
market_returns = factors_ff3_monthly[["date", "mkt_excess"]].copy()
market_returns = market_returns.sort_values("date")
market_returns["mkt_cum_12m"] = (
(1 + market_returns["mkt_excess"])
.rolling(window=12, min_periods=12)
.apply(np.prod, raw=True)
- 1
)
market_returns["market_state"] = np.where(
market_returns["mkt_cum_12m"] > 0, "UP", "DOWN"
)
# Merge with momentum returns
ewret_states = ewret_wide[["date", "long_short"]].merge(
market_returns[["date", "market_state"]], on="date", how="inner"
).dropna()
```
```{python}
#| label: tbl-market-states
#| tbl-cap: "Lợi nhuận động lượng mua–bán khống có điều kiện theo trạng thái thị trường. Thị trường UP được định nghĩa là các giai đoạn lợi nhuận thị trường tích lũy trong 12 tháng trước đó dương; thị trường DOWN là các giai đoạn có lợi nhuận 12 tháng trước đó âm."
state_stats = []
for state in ["UP", "DOWN"]:
subset = ewret_states.query(f"market_state == '{state}'")["long_short"]
n = len(subset)
mean_ret = subset.mean()
std_ret = subset.std()
t_stat = mean_ret / (std_ret / np.sqrt(n)) if std_ret > 0 else np.nan
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1)) if not np.isnan(t_stat) else np.nan
state_stats.append({
"Market State": state,
"N Months": n,
"Mean (%)": mean_ret * 100,
"Std (%)": std_ret * 100,
"t-stat": t_stat,
"p-value": p_val
})
state_stats_df = pd.DataFrame(state_stats).round(4)
state_stats_df
```
```{python}
#| label: fig-momentum-states
#| fig-cap: "Phân phối lợi nhuận động lượng hằng tháng theo trạng thái thị trường. Biểu đồ hộp thể hiện phân phối lợi nhuận động lượng mua–bán khống riêng cho trạng thái thị trường UP và DOWN, được xác định bằng dấu của lợi nhuận thị trường tích lũy 12 tháng trước đó."
#| fig-alt: "Các biểu đồ hộp đặt cạnh nhau so sánh phân phối lợi nhuận động lượng ở trạng thái thị trường UP so với DOWN."
plot_states = (
ggplot(ewret_states, aes(x="market_state", y="long_short",
fill="market_state")) +
geom_boxplot(alpha=0.7) +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
scale_fill_manual(values={"UP": "#1f77b4", "DOWN": "#d62728"}) +
labs(
x="Market State (Prior 12-Month Return)",
y="Monthly Long-Short Return",
title="Momentum Returns by Market State"
) +
theme_minimal() +
theme(
figure_size=(8, 6),
legend_position="none"
)
)
plot_states
```
## Sụp đổ động lượng
### Hiểu các đợt sụt giảm của động lượng
Một trong những đặc điểm rủi ro quan trọng nhất của các chiến lược động lượng là tính dễ bị thua lỗ đột ngột, nghiêm trọng, được gọi là sụp đổ động lượng. @daniel2016momentum ghi nhận rằng các chiến lược động lượng chịu những khoản thua lỗ hiếm gặp nhưng cực đoan, thường xảy ra trong các đợt thị trường hồi phục sau thị trường giá xuống. Các đợt sụp đổ này xảy ra vì danh mục cổ phiếu thua, vốn là vị thế bán khống, chứa nhiều cổ phiếu beta cao, những cổ phiếu tăng vọt khi thị trường đảo chiều.
Chúng ta xác định các đợt sụt giảm tồi tệ nhất của chiến lược động lượng và xem xét bối cảnh thị trường của chúng.
```{python}
#| label: tbl-worst-months
#| tbl-cap: "Mười tháng tồi tệ nhất của chiến lược động lượng mua–bán khống. Bảng báo cáo ngày, lợi nhuận mua–bán khống, lợi nhuận cổ phiếu thắng, lợi nhuận cổ phiếu thua và lợi nhuận thị trường cùng kỳ của mười tháng có khoản thua lỗ động lượng lớn nhất."
worst_months = (ewret_wide
[["date", "long_short", "winners", "losers"]]
.merge(factors_ff3_monthly[["date", "mkt_excess"]], on="date", how="left")
.sort_values("long_short")
.head(10)
.assign(
long_short_pct=lambda x: (x["long_short"] * 100).round(2),
winners_pct=lambda x: (x["winners"] * 100).round(2),
losers_pct=lambda x: (x["losers"] * 100).round(2),
mkt_pct=lambda x: (x["mkt_excess"] * 100).round(2)
)
[["date", "long_short_pct", "winners_pct", "losers_pct", "mkt_pct"]]
.rename(columns={
"date": "Date",
"long_short_pct": "L/S (%)",
"winners_pct": "Winners (%)",
"losers_pct": "Losers (%)",
"mkt_pct": "Market (%)"
})
)
worst_months
```
### Phân tích sụt giảm tối đa
Mức sụt giảm tối đa đo mức giảm từ đỉnh xuống đáy tồi tệ nhất mà chiến lược đã trải qua. Chỉ số này đặc biệt hữu ích đối với những người thực hành khi đánh giá rủi ro của các chiến lược động lượng.
```{python}
# Compute running maximum and drawdown
ewret_wide["cum_wealth"] = (1 + ewret_wide["long_short"]).cumprod()
ewret_wide["running_max"] = ewret_wide["cum_wealth"].cummax()
ewret_wide["drawdown"] = (
ewret_wide["cum_wealth"] / ewret_wide["running_max"] - 1
)
max_dd = ewret_wide["drawdown"].min()
max_dd_date = ewret_wide.loc[ewret_wide["drawdown"].idxmin(), "date"]
print(f"Maximum drawdown: {max_dd*100:.2f}%")
print(f"Date of maximum drawdown: {max_dd_date.date()}")
```
```{python}
#| label: fig-drawdown
#| fig-cap: "Mức sụt giảm của chiến lược động lượng mua–bán khống theo thời gian. Biểu đồ thể hiện phần trăm giảm so với đỉnh trước đó của giá trị tài sản tích lũy. Mức sụt giảm càng sâu thể hiện các đợt sụp đổ động lượng càng nghiêm trọng."
#| fig-alt: "Biểu đồ chuỗi thời gian thể hiện mức sụt giảm của chiến lược động lượng, với nhiều đáy sâu tương ứng với các giai đoạn sụp đổ động lượng."
plot_dd = (
ggplot(ewret_wide, aes(x="date", y="drawdown")) +
geom_area(fill="#d62728", alpha=0.5) +
geom_line(color="#d62728", size=0.5) +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Drawdown",
title="Momentum Strategy Drawdown"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_dd
```
## Danh mục động lượng trọng số theo vốn hóa
Cách triển khai cơ sở của @jegadeesh1993returns dùng các danh mục trọng số bằng nhau. Tuy nhiên, lợi nhuận trọng số bằng nhau có thể bị chi phối bởi các cổ phiếu nhỏ, kém thanh khoản, vốn khó giao dịch trong thực tế. Các danh mục trọng số theo vốn hóa, trong đó đóng góp của mỗi cổ phiếu tỷ lệ với vốn hóa thị trường, cung cấp một chuẩn so sánh có thể đầu tư hơn và đại diện tốt hơn cho lợi nhuận mà các nhà đầu tư lớn thực sự có thể đạt được.
```{python}
def momentum_strategy_vw(data, J, K, n_portfolios=10):
"""
Value-weighted momentum strategy implementation.
Same as the equally-weighted version but uses lagged market
capitalization as weights when computing portfolio returns.
Parameters
----------
data : pd.DataFrame
Panel with columns: symbol, date, ret, mktcap_lag.
J : int
Formation period in months.
K : int
Holding period in months.
n_portfolios : int
Number of portfolios.
Returns
-------
pd.DataFrame
Monthly value-weighted portfolio returns.
"""
# Step 1: Formation period returns
df = data.sort_values(["symbol", "date"]).copy()
df["gross_ret"] = 1 + df["ret"]
df["cum_return"] = (
df.groupby("symbol")["gross_ret"]
.rolling(window=J, min_periods=J)
.apply(np.prod, raw=True)
.reset_index(level=0, drop=True)
- 1
)
df = df.drop(columns=["gross_ret"]).dropna(subset=["cum_return"])
# Step 2: Portfolio assignment using transform (fast)
df["momr"] = df.groupby("date", observed=True)["cum_return"].transform(
lambda x: pd.qcut(
x,
q=n_portfolios,
labels=range(1, n_portfolios + 1),
duplicates="drop"
)
).astype('Int64')
# Fill NaNs with rank-based assignment
mask = df["momr"].isna()
if mask.any():
df.loc[mask, "momr"] = df.loc[mask].groupby("date")["cum_return"].transform(
lambda x: pd.qcut(
x.rank(method="first"),
q=min(n_portfolios, len(x.unique())),
labels=False,
duplicates="drop"
)
).astype('Int64') + 1
# Step 3: Holding period
df = df.rename(columns={"date": "form_date"})
df["hdate1"] = df["form_date"] + pd.offsets.MonthBegin(1)
df["hdate2"] = df["form_date"] + pd.offsets.MonthEnd(K)
# Step 4: Merge with holding period returns AND weights
port_ret = df[
["symbol", "form_date", "momr", "hdate1", "hdate2"]
].merge(
data[["symbol", "date", "ret", "mktcap_lag"]].rename(
columns={"ret": "hret", "date": "hdate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing instead of query (faster)
port_ret = port_ret[
(port_ret["hdate"] >= port_ret["hdate1"]) &
(port_ret["hdate"] <= port_ret["hdate2"])
]
port_ret = port_ret.dropna(subset=["mktcap_lag"])
port_ret = port_ret[port_ret["mktcap_lag"] > 0]
# Step 5: Value-weighted returns within each cohort
def vw_mean(group):
weights = group["mktcap_lag"]
if weights.sum() == 0:
return np.nan
return np.average(group["hret"], weights=weights)
cohort_ret = (port_ret
.groupby(["hdate", "momr", "form_date"])
.apply(vw_mean, include_groups=False)
.reset_index(name="cohort_ret")
)
monthly_ret = (cohort_ret
.groupby(["hdate", "momr"])
.agg(vwret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"hdate": "date"})
)
# Step 6: Long-short
wide = monthly_ret.pivot(
index="date", columns="momr", values="vwret"
).reset_index()
# Handle variable number of portfolios
n_cols = len(wide.columns) - 1
wide.columns = ["date"] + [f"port{i}" for i in range(1, n_cols + 1)]
wide["winners"] = wide[f"port{n_cols}"]
wide["losers"] = wide["port1"]
wide["long_short"] = wide["winners"] - wide["losers"]
return monthly_ret, wide
```
```{python}
# Run value-weighted J=6, K=6 strategy
_, vw_results = momentum_strategy_vw(prices_clean, J=6, K=6)
print("Value-Weighted Momentum Strategy (J=6, K=6)")
print("=" * 50)
print("\nPortfolio Statistics:")
print(vw_results[["winners", "losers", "long_short"]].describe().round(4))
```
```{python}
#| label: tbl-ew-vs-vw
#| tbl-cap: "So sánh các chiến lược động lượng trọng số bằng nhau (EW) và trọng số theo vốn hóa (VW) cho J=6, K=6. Bảng báo cáo lợi nhuận hằng tháng trung bình, thống kê t và giá trị p của các danh mục thắng, thua và mua–bán khống theo cả hai cách đặt trọng số."
comparison = []
for scheme, df in [("EW", ewret_wide), ("VW", vw_results)]:
for col in ["winners", "losers", "long_short"]:
series = df[col].dropna()
n = len(series)
mean_ret = series.mean()
std_ret = series.std()
t_stat = mean_ret / (std_ret / np.sqrt(n))
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), df=n-1))
comparison.append({
"Weighting": scheme,
"Portfolio": col.replace("_", " ").title(),
"Mean (%)": round(mean_ret * 100, 4),
"Std (%)": round(std_ret * 100, 4),
"t-stat": round(t_stat, 2),
"p-value": round(p_val, 4)
})
pd.DataFrame(comparison)
```
```{python}
#| label: fig-ew-vs-vw
#| fig-cap: "Lợi nhuận tích lũy của các chiến lược động lượng mua–bán khống trọng số bằng nhau (EW) so với trọng số theo vốn hóa (VW). Sự khác biệt giữa hai đường phản ánh tác động của quy mô doanh nghiệp lên khả năng sinh lời của động lượng."
#| fig-alt: "Biểu đồ đường so sánh lợi nhuận tích lũy của các chiến lược động lượng mua–bán khống EW và VW theo thời gian."
vw_results = vw_results.sort_values("date")
vw_results["cumret_ls_vw"] = (1 + vw_results["long_short"]).cumprod() - 1
ew_data = (ewret_wide[["date", "long_short"]]
.rename(columns={"long_short": "cumret"})
.assign(scheme="Equally Weighted")
)
ew_data["cumret"] = (1 + ew_data["cumret"]).cumprod() - 1
vw_data = (vw_results[["date", "cumret_ls_vw"]]
.rename(columns={"cumret_ls_vw": "cumret"})
.assign(scheme="Value Weighted")
)
ew_vs_vw = pd.concat([ew_data, vw_data], ignore_index=True)
plot_ew_vw = (
ggplot(ew_vs_vw, aes(x="date", y="cumret", color="scheme")) +
geom_line(size=1) +
scale_y_continuous(labels=percent_format()) +
scale_color_manual(values=["#1f77b4", "#ff7f0e"]) +
labs(
x="", y="Cumulative return",
color="Weighting Scheme",
title="EW vs. VW Momentum Long-Short Strategy"
) +
theme_minimal() +
theme(
figure_size=(10, 6),
legend_position="bottom"
)
)
plot_ew_vw
```
## Phân tích động lượng theo ngày
Trong khi các chiến lược động lượng thường được đánh giá ở tần suất hằng tháng theo @jegadeesh1993returns, việc phân tích các mô thức lợi nhuận hằng ngày cung cấp thêm hiểu biết về động lực của lợi nhuận động lượng. Dữ liệu hằng ngày cho phép chúng ta xem lợi nhuận động lượng tích lũy ra sao trong thời kỳ nắm giữ, đo độ biến động trong tháng của chiến lược và tính các thước đo rủi ro chính xác hơn.
### Nạp dữ liệu hằng ngày
```{python}
prices_daily = pd.read_sql_query(
sql=("SELECT symbol, date, ret, ret_excess, mktcap_lag "
"FROM prices_daily"),
con=tidy_finance,
parse_dates={"date"}
)
print(f"Daily observations: {len(prices_daily):,}")
print(f"Unique stocks: {prices_daily['symbol'].nunique():,}")
print(f"Date range: {prices_daily['date'].min().date()} "
f"to {prices_daily['date'].max().date()}")
```
### Lợi nhuận hằng ngày của các danh mục động lượng hằng tháng
Thay vì hình thành các danh mục động lượng ở tần suất hằng ngày (đòi hỏi tái cân bằng hằng ngày và không khả thi), chúng ta dùng việc gán danh mục hằng tháng và theo dõi lợi nhuận hằng ngày của chúng. Điều này cho ta chuỗi lợi nhuận hằng ngày của chiến lược động lượng hằng tháng.
```{python}
# # # Use the monthly portfolio assignments from the J=6 baseline
monthly_assignments = portfolios_with_dates[
["symbol", "form_date", "momr", "hdate1", "hdate2"]
].copy()
# Merge with daily returns
daily_mom_returns = monthly_assignments.merge(
prices_daily[["symbol", "date", "ret"]].rename(
columns={"ret": "dret", "date": "ddate"}
),
on="symbol",
how="inner"
)
# Use boolean indexing instead of query
daily_mom_returns = daily_mom_returns[
(daily_mom_returns["ddate"] >= daily_mom_returns["hdate1"]) &
(daily_mom_returns["ddate"] <= daily_mom_returns["hdate2"])
]
print(f"Daily portfolio return observations: {len(daily_mom_returns):,}")
```
```{python}
# Compute daily equally weighted portfolio returns
# Stage 1: Average within each cohort
daily_cohort_ret = (daily_mom_returns
.groupby(["ddate", "momr", "form_date"])
.agg(cohort_ret=("dret", "mean"))
.reset_index()
)
# Stage 2: Average across cohorts
daily_ewret = (daily_cohort_ret
.groupby(["ddate", "momr"])
.agg(ewret=("cohort_ret", "mean"))
.reset_index()
.rename(columns={"ddate": "date"})
)
# Compute daily long-short returns
daily_wide = daily_ewret.pivot(
index="date", columns="momr", values="ewret"
).reset_index()
daily_wide.columns = ["date"] + [f"port{i}" for i in range(1, 11)]
daily_wide["winners"] = daily_wide["port10"]
daily_wide["losers"] = daily_wide["port1"]
daily_wide["long_short"] = daily_wide["winners"] - daily_wide["losers"]
print(f"Daily long-short return observations: {len(daily_wide):,}")
```
### Lợi nhuận tích lũy hằng ngày
```{python}
#| label: fig-daily-cumret
#| fig-cap: "Lợi nhuận tích lũy hằng ngày của chiến lược động lượng mua–bán khống. Hình này thể hiện cùng chiến lược như phân tích hằng tháng nhưng được theo dõi ở tần suất hằng ngày, cho thấy động lực trong tháng và thời điểm chính xác của các khoản lãi và lỗ động lượng."
#| fig-alt: "Biểu đồ đường thể hiện lợi nhuận tích lũy hằng ngày của chiến lược động lượng mua–bán khống, với độ chi tiết mịn hơn so với phiên bản hằng tháng."
daily_wide = daily_wide.sort_values("date")
daily_wide["cumret_ls"] = (1 + daily_wide["long_short"]).cumprod() - 1
plot_daily_cumret = (
ggplot(daily_wide, aes(x="date", y="cumret_ls")) +
geom_line(size=0.5, color="#2ca02c") +
geom_hline(yintercept=0, linetype="dashed", color="gray") +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Cumulative return",
title="Daily Cumulative Return: Momentum Long-Short Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 6))
)
plot_daily_cumret
```
### Các thước đo rủi ro quy năm từ dữ liệu hằng ngày
Dữ liệu hằng ngày cho phép ước lượng chính xác hơn các thước đo rủi ro nhờ lấy mẫu ở tần suất cao hơn.
```{python}
#| label: tbl-daily-risk
#| tbl-cap: "Các thước đo rủi ro quy năm của chiến lược động lượng mua–bán khống tính từ lợi nhuận hằng ngày. Biến động được quy năm bằng quy tắc căn bậc hai của 252. Tỷ số Sharpe, sụt giảm tối đa, độ lệch và độ nhọn cung cấp hồ sơ rủi ro toàn diện."
daily_ls = daily_wide["long_short"].dropna()
# Annualized metrics
ann_mean = daily_ls.mean() * 252
ann_vol = daily_ls.std() * np.sqrt(252)
sharpe = ann_mean / ann_vol if ann_vol > 0 else np.nan
# Drawdown
daily_wealth = (1 + daily_ls).cumprod()
daily_running_max = daily_wealth.cummax()
daily_dd = (daily_wealth / daily_running_max - 1).min()
# Higher moments
skew = daily_ls.skew()
kurt = daily_ls.kurtosis()
# VaR and CVaR
var_95 = daily_ls.quantile(0.05)
cvar_95 = daily_ls[daily_ls <= var_95].mean()
risk_metrics = pd.DataFrame({
"Metric": [
"Annualized Mean Return",
"Annualized Volatility",
"Sharpe Ratio",
"Maximum Drawdown",
"Skewness",
"Excess Kurtosis",
"Daily VaR (5%)",
"Daily CVaR (5%)"
],
"Value": [
f"{ann_mean*100:.2f}%",
f"{ann_vol*100:.2f}%",
f"{sharpe:.2f}",
f"{daily_dd*100:.2f}%",
f"{skew:.2f}",
f"{kurt:.2f}",
f"{var_95*100:.2f}%",
f"{cvar_95*100:.2f}%"
]
})
risk_metrics
```
### Biến động thực hiện của lợi nhuận động lượng
Dùng lợi nhuận hằng ngày, chúng ta có thể tính biến động thực hiện hằng tháng của chiến lược động lượng và xem xét nó thay đổi ra sao theo thời gian.
```{python}
#| label: fig-realized-vol
#| fig-cap: "Biến động thực hiện hằng tháng của chiến lược động lượng mua–bán khống, tính từ lợi nhuận hằng ngày trong mỗi tháng. Giá trị cao hơn cho thấy các giai đoạn bất định lớn hơn của lợi nhuận động lượng, thường trùng với căng thẳng thị trường."
#| fig-alt: "Biểu đồ chuỗi thời gian của biến động thực hiện hằng tháng, cho thấy các đỉnh nhọn trong các giai đoạn căng thẳng thị trường."
daily_wide["year_month"] = daily_wide["date"].dt.to_period("M")
realized_vol = (daily_wide
.groupby("year_month")["long_short"]
.std()
.reset_index()
.rename(columns={"long_short": "realized_vol"})
)
realized_vol["date"] = realized_vol["year_month"].dt.to_timestamp()
realized_vol["realized_vol_ann"] = realized_vol["realized_vol"] * np.sqrt(252)
plot_rvol = (
ggplot(realized_vol, aes(x="date", y="realized_vol_ann")) +
geom_line(color="#d62728", size=0.8) +
scale_y_continuous(labels=percent_format()) +
labs(
x="", y="Annualized Realized Volatility",
title="Realized Volatility of Momentum Strategy"
) +
theme_minimal() +
theme(figure_size=(10, 5))
)
plot_rvol
```
## Lưu kết quả vào cơ sở dữ liệu
Chúng ta lưu lợi nhuận danh mục động lượng vào cơ sở dữ liệu để dùng trong các chương sau, gồm xây dựng mô hình nhân tố và tối ưu hóa danh mục.
```{python}
# Save monthly equally weighted momentum portfolio returns
ewret_to_save = ewret[["date", "momr", "ewret"]].copy()
ewret_to_save.to_sql(
name="momentum_portfolios_monthly",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(ewret_to_save):,} monthly momentum portfolio observations.")
# Save the long-short return series
momentum_factor = ewret_wide[["date", "long_short"]].dropna().copy()
momentum_factor = momentum_factor.rename(columns={"long_short": "wml"})
momentum_factor.to_sql(
name="momentum_factor_monthly",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(momentum_factor):,} monthly WML factor observations.")
# Save the daily long-short return series
daily_momentum_factor = daily_wide[["date", "long_short"]].dropna().copy()
daily_momentum_factor = daily_momentum_factor.rename(
columns={"long_short": "wml"}
)
daily_momentum_factor.to_sql(
name="momentum_factor_daily",
con=tidy_finance,
if_exists="replace",
index=False
)
print(f"Saved {len(daily_momentum_factor):,} daily WML factor observations.")
```
## Những cân nhắc thực tiễn
### Chi phí giao dịch
Các chiến lược động lượng kéo theo tỷ lệ vòng quay danh mục đáng kể, vì cổ phiếu ra vào các danh mục thập phân vị cực đoan mỗi tháng. @korajczyk2004momentum xem xét liệu lợi nhuận động lượng có còn sau chi phí giao dịch hay không và thấy rằng khả năng sinh lời giảm đáng kể đối với các nhà đầu tư tổ chức lớn, dù các danh mục nhỏ hơn vẫn có thể thu được lợi nhuận đáng kể.
Trên thị trường Việt Nam, chi phí giao dịch gồm:
- **Phí môi giới**: Thường là 0,15%–0,25% giá trị giao dịch đối với nhà đầu tư tổ chức.
- **Phí sàn giao dịch**: Khoảng 0,03% mỗi giao dịch.
- **Tác động thị trường**: Đặc biệt liên quan đến các cổ phiếu nhỏ hơn, kém thanh khoản hơn vốn chiếm đa số trong các danh mục động lượng cực đoan. Thanh khoản thấp hơn của Việt Nam so với các thị trường phát triển có thể khuếch đại chi phí này.
- **Giới hạn biên độ giao dịch**: Biên độ giá hằng ngày $\pm 7\%$ trên HOSE có thể ngăn việc thực hiện giao dịch ngay lập tức, gây ra sai số bám sát (tracking error) so với danh mục lý thuyết.
### Độ trễ triển khai
Cách triển khai cơ sở của chúng ta giả định rằng các danh mục có thể được hình thành và tái cân bằng tức thời vào cuối mỗi tháng. Trên thực tế, có một độ trễ giữa việc quan sát lợi nhuận của thời kỳ hình thành và việc thực hiện các giao dịch danh mục. Khoảng cách một tháng giữa thời kỳ hình thành và thời điểm bắt đầu thời kỳ nắm giữ (@jegadeesh1990evidence) phần nào giải quyết mối lo này, nhưng người thực hành nên cân nhắc thêm các độ trễ triển khai khác.
### Thiên kiến sống sót
Bộ dữ liệu của chúng ta từ DataCore gồm cả cổ phiếu đang giao dịch lẫn cổ phiếu đã hủy niêm yết, giúp giảm thiên kiến sống sót. Tuy nhiên, cách xử lý các cổ phiếu hủy niêm yết có thể ảnh hưởng đến kết quả động lượng. Các cổ phiếu bị hủy niêm yết trong thời kỳ nắm giữ có thể tạo ra lợi nhuận cực đoan (dương khi bị thâu tóm và âm khi thất bại). Chúng ta giữ lại lợi nhuận của cổ phiếu hủy niêm yết như được báo cáo trong cơ sở dữ liệu, nhất quán với cách xử lý trong @jegadeesh1993returns.
### Cân nhắc về mẫu nhỏ
Thị trường chứng khoán Việt Nam có lịch sử tương đối ngắn so với thị trường Hoa Kỳ được nghiên cứu trong @jegadeesh1993returns. Mẫu của chúng ta kéo dài khoảng hai thập kỷ, so với gần ba thập kỷ trong nghiên cứu gốc. Giai đoạn mẫu ngắn hơn này kéo theo khoảng tin cậy rộng hơn và độ nhạy lớn hơn với các giai đoạn cụ thể (như cuộc khủng hoảng tài chính 2007–2009, vốn tác động nặng nề đến cổ phiếu Việt Nam). Cần diễn giải kết quả với lưu ý này.
## Những điểm chính
Chương này đã trình bày cách triển khai và phân tích các chiến lược động lượng trên thị trường cổ phiếu Việt Nam, theo phương pháp của @jegadeesh1993returns. Các phát hiện chính và đóng góp về phương pháp là:
1. **Phương pháp**: Chúng ta đã triển khai đầy đủ phương pháp danh mục chồng lấn của @jegadeesh1993returns, gồm quy trình lấy trung bình hai giai đoạn (trong nhóm, rồi giữa các nhóm) để xử lý $K$ danh mục đang hoạt động trong mỗi tháng.
2. **Kết quả cơ sở**: Chiến lược $J=6$, $K=6$ cung cấp một chuẩn so sánh tự nhiên. Chênh lệch giữa danh mục thắng và thua cho thấy động lượng chéo có tồn tại trên cổ phiếu Việt Nam hay không.
3. **Độ vững qua các chân trời**: Bằng việc tính toàn bộ lưới $J \times K$ với $J, K \in \{3, 6, 9, 12\}$, chúng ta đã đánh giá liệu phần bù động lượng có vững trước lựa chọn thời kỳ hình thành và nắm giữ hay không, theo cách tiếp cận trong Bảng 1 của @jegadeesh1993returns.
4. **Điều chỉnh rủi ro**: Alpha CAPM và alpha ba nhân tố Fama-French đo xem phần bù động lượng có được giải thích bởi các nhân tố rủi ro chuẩn hay không, dựa trên phân tích của @fama1996multifactor, trong đó các tác giả cho thấy mô hình ba nhân tố của họ không giải thích được động lượng.
5. **Sự phụ thuộc vào trạng thái thị trường**: Theo @cooper2004market, chúng ta đã xem xét liệu lợi nhuận động lượng có thay đổi theo điều kiện thị trường hay không, điều đặc biệt liên quan ở các thị trường mới nổi có chu kỳ bùng nổ–suy thoái rõ rệt.
6. **Trọng số theo vốn hóa**: Việc so sánh các chiến lược trọng số bằng nhau và trọng số theo vốn hóa giải quyết khả năng triển khai thực tế và tách riêng vai trò của quy mô doanh nghiệp trong việc tạo ra lợi nhuận động lượng.
7. **Phân tích theo ngày**: Bằng việc theo dõi các danh mục động lượng ở tần suất hằng ngày, chúng ta đã tính các thước đo rủi ro chính xác gồm biến động thực hiện, sụt giảm tối đa, VaR và CVaR, cung cấp hồ sơ rủi ro đầy đủ của chiến lược.
8. **Bối cảnh thị trường mới nổi**: Xuyên suốt phân tích, chúng ta đã nhấn mạnh các đặc điểm riêng của thị trường Việt Nam—giới hạn biên độ giao dịch, hạn chế sở hữu nước ngoài, lịch sử mẫu ngắn hơn và chi phí giao dịch cao hơn—ảnh hưởng đến cách diễn giải và tính khả thi thực tế của các chiến lược động lượng.
```{=html}
<!-- ## Exercises
1. **Skip-month momentum**: Modify the implementation to skip one month between the formation and holding periods (i.e., form portfolios based on months $t-J-1$ through $t-2$ rather than $t-J$ through $t-1$). Does this improve momentum profits by avoiding short-term reversal? Compare with the results in @jegadeesh1993returns.
2. **Quintile portfolios**: Repeat the analysis using quintile (5-group) portfolios instead of deciles. How do the results change? In the Vietnamese market with fewer stocks, quintiles may produce more stable portfolios.
3. **Exchange-specific momentum**: Separately analyze momentum on HOSE and HNX. Do the two exchanges exhibit different momentum patterns, potentially due to differences in liquidity, firm size, and investor composition?
4. **Industry momentum**: Following @moskowitz1999industries, decompose stock-level momentum into an industry component and a stock-specific component. Assign each stock the equally weighted return of its industry group as the industry momentum signal. Is momentum in Vietnam primarily driven by industry rotation or stock selection?
5. **Momentum and volume**: @lee2000price document an interaction between momentum and trading volume. Construct double-sorted portfolios based on past returns and past trading volume. Do high-volume winners outperform low-volume winners?
6. **Seasonal patterns**: Examine whether momentum profits exhibit seasonal patterns. In the U.S., @jegadeesh1993returns document that January returns of momentum portfolios are particularly unusual. Does a similar pattern exist in the Vietnamese market, potentially around the Lunar New Year (Tết)?
7. **Carhart four-factor model**: Using the WML factor saved to the database in this chapter, estimate the @carhart1997persistence four-factor model for the momentum portfolios. This adds the momentum factor to the Fama-French three-factor model: $r_{i,t} - r_{f,t} = \alpha_i + \beta_{1,i} \text{MKT}_t + \beta_{2,i} \text{SMB}_t + \beta_{3,i} \text{HML}_t + \beta_{4,i} \text{WML}_t + \epsilon_{i,t}$. -->
```