Cổ phiếu VN30 tăng mạnh tháng trước có tiếp tục thắng tháng sau?
Duy Nguyen · · Nghiên cứu thị trường
> Tóm tắt nhanh: Mỗi tháng mua một phần ba số mã VN30 mạnh nhất trong ba tháng qua thì thắng rổ chia đều 0,50%/tháng trước phí (t=2,06). Trừ phí thì còn 0,37%, và mức này đã không còn chắc về mặt thống kê. Với khung một tháng, phí ăn gần hết phần vượt trội.
Trên diễn đàn có hai phe cãi nhau mãi không xong. Phe thứ nhất mua mã đang chạy, vì "trend is your friend". Phe thứ hai chỉ thích mã vừa bị đạp, vì "giảm nhiều thì phải hồi". Cãi nhau đã lâu nhưng hiếm ai mang số liệu ra.
Mình mang số ra đây. Bài này dùng các mã trong rổ VN30 hiện tại. Cuối mỗi tháng kể từ 2015, mình xếp hạng các mã theo mức tăng giảm trong thời gian qua, chia làm ba nhóm, rồi xem tháng sau nhóm nào lời hơn. Nói trước kết quả cho anh em: phe bắt đáy thua. Phe đu đỉnh thắng, nhưng thắng ít, và trừ phí xong thì không còn bao nhiêu.
> Góc người mới (biết rồi thì lướt qua) > - Quán tính và hồi quy về trung bình: quán tính là quả bóng đang lăn xuống dốc thì lăn tiếp; hồi quy là lò xo bị kéo căng thì bật lại. Hai ý tưởng ngược nhau. > - Danh mục xếp hạng chia 3 nhóm: giống chia lớp thành ba tổ theo điểm kỳ trước, rồi xem kỳ sau tổ giỏi có còn giỏi không. Mỗi tháng làm lại một lần. > - Mốc so sánh: con được 8 điểm thì chưa biết giỏi hay không, phải xem điểm trung bình cả lớp là 6 hay 9. > - t-stat: tín hiệu to hơn tiếng ồn bao nhiêu lần. Từ khoảng 2 trở lên thì khó đổ cho may, dưới 1 thì gần như chắc là nhiễu. > - Thiên lệch sống sót: người lãi thì lên khoe, người cháy tài khoản thì lặng lẽ rời đi, nên đọc diễn đàn cứ tưởng ai cũng lãi. > - Chi phí giao dịch: giống phí ship. Món hàng lời 20 nghìn mà ship mất 25 nghìn thì vẫn lỗ.
Vì sao mã mạnh có thể mạnh tiếp, và vì sao có thể không
Lý do để tin vào quán tính khá đời thường. Tin tốt về một doanh nghiệp thường không ngấm hết vào giá trong một phiên. Người biết trước thì mua trước, quỹ giải ngân từ từ, còn nhà đầu tư cá nhân phải thấy mã xanh mấy tuần liền mới bắt đầu nhảy vào. Vì thế giá đi lên theo từng nấc, giống kẹt xe giờ tan tầm: xe đầu nhích rồi cả dòng mới nhích theo. Ở Mỹ, nghiên cứu của Jegadeesh và Titman năm 1993 cho thấy mã thắng trong 6 đến 12 tháng qua thường thắng tiếp, và sau này hiệu ứng đó được kiểm tra lại rất nhiều lần.
Nhưng lý do để nghi cũng không ít. Cũng ở Mỹ, nếu chỉ nhìn một tháng thì người ta lại hay thấy đảo chiều: mã vừa tăng quá đà tháng này thì tháng sau hụt hơi. Phe bắt đáy dựa vào đúng điều này. Ở các thị trường mới nổi và châu Á, momentum (quán tính giá) thường yếu hơn ở Mỹ. Rổ VN30 lại nhỏ, mỗi nhóm chỉ khoảng 10 mã, nên một hai mã đột biến là đủ kéo lệch trung bình của cả nhóm.
Cách đo
Dữ liệu là giá ngày đã điều chỉnh lấy từ DNSE, gộp thành giá đóng cửa phiên cuối mỗi tháng. Cuối tháng, mình xếp hạng các mã theo lợi suất trong 1, 3, 6 hoặc 12 tháng qua. Với hai khung dài, mình bỏ tháng gần nhất ra để tách khỏi hiệu ứng đảo chiều ngắn hạn, giống cách các nghiên cứu ở Mỹ vẫn làm. Sau đó mình chia các mã thành ba nhóm bằng nhau, mua chia đều từng nhóm ở giá đóng cửa, giữ đúng một tháng rồi làm lại từ đầu. Mã niêm yết muộn thì vào mẫu muộn, trung bình mỗi tháng có 26,4 mã được xếp hạng.
Thị trường cơ sở không bán khống được, nên câu hỏi thực tế nhất là nhóm mạnh nhất có thắng rổ chia đều hay không. Rổ chia đều chính là mốc so sánh. Phí giả định là 0,4% cho một vòng mua rồi bán (phí hai chiều cộng thuế bán 0,1%), và chỉ tính trên phần danh mục phải thay mỗi tháng.
Kết quả: phe nào có lý?
Cả rổ vốn đã tăng sẵn
Điều này phải nói trước, vì nó dễ làm các con số phía sau gây ảo giác. Xếp hạng theo khung một tháng, chỉ cần mua cả rổ chia đều là lợi suất trung bình đã đạt +1,55%/tháng trong 140 tháng. Vì vậy nghe một nhóm nào đó lời vài phần trăm mỗi tháng thì sướng thật, nhưng phải đặt cạnh mốc này mới biết nó hơn được bao nhiêu.
Mã mạnh, mã giữa, mã yếu

Hình 1: Mã mạnh, mã giữa, mã yếu: tháng sau ai thắng?
Mỗi cụm ba cột trong hình là một khung xếp hạng. Cam là nhóm yếu nhất, xám là nhóm giữa, xanh là nhóm mạnh nhất, còn chiều cao cột là lợi suất trung bình của tháng kế tiếp. Ở cả bốn khung, cột xanh đều cao hơn cột cam. Phe bắt đáy vì thế không có cửa: xếp hạng theo khung một tháng thì nhóm yếu nhất chỉ được +1,20%/tháng, thấp hơn cả rổ. Hiện tượng đảo chiều ngắn hạn kiểu Mỹ không xuất hiện ở đây.
Nhìn kỹ hơn thì chênh lệch giữa các cột nhỏ hơn nhiều so với chiều cao của chính các cột. Nhóm nào cũng có lời, có nhóm lời nhiều hơn một chút mà thôi.
Lợi suất trung bình tháng kế tiếp theo nhóm xếp hạng (trước phí)
| Xếp hạng theo | Số tháng | 1/3 mạnh nhất | 1/3 yếu nhất | Cả rổ | Mạnh - yếu | t-stat (mạnh - yếu) | |:---|---:|---:|---:|---:|---:|---:| | 1 tháng | 140 | +1,90% | +1,20% | +1,55% | +0,71% | 1,67 | | 3 tháng | 138 | +2,13% | +1,31% | +1,63% | +0,81% | 1,69 | | 6 tháng (bỏ tháng gần nhất) | 135 | +1,68% | +1,29% | +1,55% | +0,39% | 0,75 | | 12 tháng (bỏ tháng gần nhất) | 129 | +2,00% | +1,60% | +1,71% | +0,40% | 0,68 |
Hai khung ngắn cho kết quả rõ nhất. Theo khung một tháng, nhóm mạnh hơn nhóm yếu +0,71%/tháng (t=1,67). Theo khung ba tháng, mức chênh là +0,81%/tháng (t=1,69). Cả hai đều sát ngưỡng nhưng chưa qua, mới dừng ở mức "có mùi".
Còn hai khung dài, vốn là sân nhà của momentum ở Mỹ, thì gần như trống trơn: t chỉ 0,75 và 0,68, vẫn nằm trong vùng nhiễu. Ở khung sáu tháng, nhóm giữa còn gần bằng nhóm mạnh nhất, nên các cột không tăng dần đều từ yếu sang mạnh.
Thắng cả rổ mới là thứ mua được
Muốn ăn trọn phần chênh giữa nhóm mạnh và nhóm yếu thì phải bán khống được nhóm yếu, mà cổ phiếu cơ sở thì không bán khống được. Với người chỉ mua, phép so sánh đúng là so với cả rổ.
Ở khung một tháng, nhóm mạnh nhất vượt rổ +0,35%/tháng (t=1,59) và chỉ thắng rổ 53,6% số tháng, chẳng khác tung đồng xu là mấy.
Khung ba tháng cho kết quả đẹp nhất bài: nhóm mạnh vượt rổ +0,50%/tháng, t=2,06, thắng rổ 57,2% số tháng. Cả nghiên cứu chỉ có con số này vượt được ngưỡng 2.
Trừ phí
Với khung một tháng, mỗi tháng phải thay khoảng 65% danh mục, vì mã nóng tháng này thường không còn nóng tháng sau.
Quy ra tiền với 100 triệu vốn thì phần vượt rổ trước phí khoảng 350.000 đồng/tháng, phí ăn mất khoảng 262.000 đồng, còn lại chừng 88.000 đồng/tháng (t=0,40). Gồng bao nhiêu tháng như vậy chỉ để đổi lấy tiền một bữa sáng thì không đáng.
Khung ba tháng ít phải thay hàng hơn (vòng quay trung bình 34%/tháng) nên giữ lại được nhiều hơn: sau phí còn +0,37%/tháng. Có điều t rơi xuống 1,50, tức là may rủi vẫn giải thích được. Khung mười hai tháng thay hàng ít nhất nhưng vốn đã yếu sẵn, trừ phí xong vẫn nằm trong vùng nhiễu.
Nhìn theo thời gian

Hình 2: Tài sản cộng dồn của ba danh mục, xếp hạng theo 1 tháng
Hình này theo dõi một đồng vốn bỏ vào từ 02/2015 lớn lên thế nào (trước phí, lời bao nhiêu tái đầu tư bấy nhiêu, xếp hạng theo khung một tháng). Đến cuối kỳ, nhóm mạnh nhất thành x10,13, cả rổ thành x6,44, nhóm yếu nhất thành x3,75. Nhìn thì rất mê, nhưng điều cần xem là khoảng cách giữa đường xanh và đường xám nới ra đều đặn hay chỉ bật ra trong vài giai đoạn.
Chia theo giai đoạn thì thấy ngay. Giai đoạn 2019-2022, chênh lệch mạnh trừ yếu lên tới +1,32%/tháng, còn giai đoạn trước và giai đoạn sau chỉ có +0,35% và +0,43%. Hiệu ứng dồn cục vào một quãng chứ không chảy đều.
Ngay cả khi tính cả kỳ, cũng có lúc nhóm mạnh thua rổ tới -13,8% tính từ đỉnh. Khung mười hai tháng còn bấp bênh hơn: giai đoạn 2015-2018 chênh lệch bị âm (-0,84%/tháng), và mức thua rổ tệ nhất từ đỉnh là -25,7%.
Mình có thể sai ở đâu
Lỗi nặng nhất là thiên lệch sống sót. Mình dùng danh sách VN30 hiện tại, tức là toàn những mã đã "sống sót" tới hôm nay. Mã nào từng tăng mạnh, lọt vào nhóm mạnh nhất rồi sập và bị loại khỏi rổ thì không có trong mẫu, nên nhóm mạnh nhất trong quá khứ trông đẹp hơn thực tế. Nếu dùng đúng danh sách VN30 ở từng thời điểm, mình đoán phần vượt trội sẽ nhỏ đi, nhưng chưa kiểm tra thì chưa dám nói nhỏ đi bao nhiêu.
Thứ hai là mẫu nhỏ, theo cả hai nghĩa. Mỗi nhóm chỉ có khoảng chục mã, nên một hai mã đột biến có thể quyết định kết quả cả tháng. Dữ liệu cũng chỉ cho hơn một trăm tháng độc lập, trong khi các nghiên cứu momentum ở Mỹ dùng dữ liệu hàng chục năm.
Cuối cùng, mình thử bốn khung xếp hạng cùng lúc. Thử càng nhiều cách thì càng dễ gặp một kết quả đẹp do tình cờ, nên t=2,06 của khung ba tháng, khi ba khung còn lại đều dưới 2, nên coi là manh mối hơn là kết luận. Ngoài ra, phí 0,4% chỉ là giả định, và mình chưa tính trượt giá khi khớp ở giá đóng cửa.
Code để anh em tự chạy
Toàn bộ phần tính toán và vẽ hình nằm dưới đây, dữ liệu tải miễn phí từ DNSE.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
# panel: dict {mã: DataFrame giá ngày đã điều chỉnh, index = ngày, có cột close}
# Ví dụ tải từ DNSE (miễn phí, không cần key):
# url = "https://services.entrade.com.vn/chart-api/v2/ohlcs/stock?from=1420070400&to=1790000000&symbol=FPT&resolution=1D"
# d = requests.get(url).json(); px = pd.DataFrame({"close": d["c"]}, index=pd.to_datetime(d["t"], unit="s"))
BLUE, ORANGE, NEUTRAL, AXIS = "#2a78d6", "#eb6834", "#b4b2a9", "#c3c2b7"
LOOKBACKS = {"1 tháng": (1, 0), "3 tháng": (3, 0), "6 tháng (bỏ tháng gần nhất)": (6, 1),
"12 tháng (bỏ tháng gần nhất)": (12, 1)}
pct_axis = lambda ax, decimals=1: ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v*100:.{decimals}f}%"))
zero_line = lambda ax: ax.axhline(0, color=AXIS, linewidth=1)
def summarize(x) -> dict:
"""Số mẫu, trung bình, trung vị, tỷ lệ dương và t-stat của một chuỗi lợi suất."""
x = pd.Series(x, dtype=float).dropna()
n = len(x)
std = x.std(ddof=1) if n > 1 else np.nan
se = std / np.sqrt(n) if n > 1 else np.nan
return {
"n": n,
"mean": x.mean(),
"median": x.median(),
"std": std,
"win_rate": (x > 0).mean() if n else np.nan,
"t_stat": x.mean() / se if n > 1 and se > 0 else np.nan,
"ci95": 1.96 * se,
}
def month_end_prices(panel: dict[str, pd.DataFrame], min_days: int = 10) -> pd.DataFrame:
"""Giá đóng cửa phiên cuối mỗi tháng của từng mã. Bỏ tháng mà mã có ít hơn `min_days` phiên
(mới niêm yết, tạm ngừng) và bỏ tháng cuối chưa kết thúc."""
px = pd.DataFrame({t: d["close"] for t, d in panel.items()})
m = px.resample("ME").last().where(px.resample("ME").count() >= min_days)
return m[m.index <= px.index.max()]
def momentum_portfolios(m: pd.DataFrame, lookback: int, skip: int = 0, min_names: int = 5) -> pd.DataFrame:
"""Cuối mỗi tháng: xếp hạng các mã theo lợi suất `lookback` tháng qua (bỏ `skip` tháng gần nhất),
chia 3 nhóm bằng nhau, rồi đo lợi suất tháng KẾ TIẾP của từng nhóm (chia đều trong nhóm).
Mua ở giá đóng cửa cuối tháng, giữ đến cuối tháng sau nên không vướng T+2."""
signal = m.shift(skip) / m.shift(lookback) - 1
nxt = m.shift(-1) / m - 1
rows, prev_top = [], set()
for t in m.index[:-1]:
ok = signal.loc[t].notna() & nxt.loc[t].notna()
if ok.sum() < min_names:
continue
s, r = signal.loc[t, ok], nxt.loc[t, ok]
q = s.rank(pct=True)
top = set(s.index[q > 2 / 3])
rows.append({"date": t, "top": r[q > 2 / 3].mean(), "mid": r[(q > 1 / 3) & (q <= 2 / 3)].mean(),
"bottom": r[q <= 1 / 3].mean(), "all": r.mean(), "ic": s.rank().corr(r.rank()),
"turnover": 1 - len(top & prev_top) / len(top) if prev_top else 1.0, "n": int(ok.sum())})
prev_top = top
out = pd.DataFrame(rows).set_index("date")
out["spread"] = out["top"] - out["bottom"]
out["excess"] = out["top"] - out["all"]
return out
def plot_terciles(res: dict[str, pd.DataFrame]):
fig, ax = plt.subplots(figsize=(9.0, 4.6))
x = np.arange(len(res))
w = 0.26
for i, (col, color, name) in enumerate([("bottom", ORANGE, "1/3 yếu nhất"), ("mid", NEUTRAL, "1/3 giữa"),
("top", BLUE, "1/3 mạnh nhất")]):
ax.bar(x + (i - 1) * w, [d[col].mean() for d in res.values()], width=w - 0.03, color=color, label=name, zorder=2)
ax.set_xticks(x, [k.replace(" (", "\n(") for k in res])
ax.set_xlabel("Giai đoạn dùng để xếp hạng")
lo, hi = ax.get_ylim()
ax.set_ylim(lo, hi + (hi - lo) * 0.15) # chừa chỗ cho chú thích
zero_line(ax)
pct_axis(ax, decimals=2)
ax.set_title("Lợi suất trung bình tháng kế tiếp, theo nhóm xếp hạng")
ax.legend(loc="upper left", ncols=3)
return fig
m = month_end_prices(panel)
res = {k: momentum_portfolios(m, lb, skip) for k, (lb, skip) in LOOKBACKS.items()}
for k, d in res.items():
print(k, d[["top", "bottom", "all"]].mean().round(4).to_dict(), "t(top - bottom) =", round(summarize(d["spread"])["t_stat"], 2))
plot_terciles(res).savefig("terciles.png", dpi=150)
Hàm month_end_prices lấy giá đóng cửa phiên cuối mỗi tháng của từng mã, bỏ những tháng có quá ít phiên (mới niêm yết hoặc bị tạm ngừng) và bỏ tháng chưa kết thúc. Tiếp theo, momentum_portfolios tính lợi suất trong khung xếp hạng (có tùy chọn bỏ tháng gần nhất) và lợi suất của tháng kế tiếp. Với từng tháng, hàm này xếp hạng các mã theo phần trăm, chia ba nhóm, ghi lại lợi suất trung bình của từng nhóm và của cả rổ, cùng với tỷ lệ mã trong nhóm mạnh phải thay so với tháng trước (vòng quay). Hàm summarize lấy chuỗi chênh lệch đó và tính t-stat bằng cách chia trung bình cho độ dao động đã điều chỉnh theo số tháng. Vì mua ở giá đóng cửa cuối tháng và giữ tới cuối tháng sau, cách làm này không vướng T+2 (cổ phiếu mua phải sau hai ngày làm việc mới về tài khoản để bán).
Dùng được gì khi giao dịch
Chưa có gì đủ chắc để làm thành hệ thống. Có điều, nếu anh em đã định mua mã trong VN30, thì mã mạnh nhất trong ba tháng qua là lựa chọn thuận gió hơn mã vừa bị đạp. Còn "giảm nhiều rồi" thì không phải lý do để mua, vì nhóm yếu nhất tháng trước còn thua cả rổ.
Đổi danh mục hằng tháng theo khung một tháng thì gần như chỉ để nuôi phí. Phí, thuế và trượt giá đủ sức ăn hết một lợi thế cỡ vài phần mười phần trăm mỗi tháng.
Hỏi đáp
Vậy cứ mua cả rổ VN30 chia đều rồi ngồi im là được à? Trong mẫu này, cả rổ chia đều đã tăng +1,55%/tháng, còn phần lời thêm của nhóm mạnh sau phí thì nằm trong vùng nhiễu. Nhưng chính con số của cả rổ cũng được thiên lệch sống sót làm đẹp lên. Đừng lấy nó làm kỳ vọng cho tương lai.
Ở Mỹ momentum 6-12 tháng mạnh nhất, sao ở đây lại yếu nhất? Mình chỉ có số để mô tả, chưa có số để giải thích nguyên nhân. Trong mẫu này, khung mười hai tháng thậm chí bị âm ở giai đoạn 2015-2018. Kết quả đó khớp với nhận xét chung là momentum ở thị trường mới nổi và châu Á yếu hơn ở Mỹ.
Khung ba tháng có t=2,06 trước phí rồi, thế đủ tin chưa? Chưa. Đó là kết quả đẹp nhất trong bốn khung thử cùng lúc, sau phí tụt về 1,50, lại còn được thiên lệch sống sót đỡ thêm. Ai muốn thử tiếp thì có một biến thể đáng làm: vẫn xếp hạng theo ba tháng nhưng ba tháng mới đổi danh mục một lần, xem tiết kiệm được phí có bù lại phần tín hiệu bị loãng đi không.
Kết
Trên rổ VN30, bắt đáy mã vừa giảm không có cơ sở, còn theo mã mạnh trong ba tháng qua thì có lợi thế nhỏ trước phí (+0,50%/tháng so với rổ). Trừ phí xong còn +0,37%, và mức này chưa đủ chắc để tách khỏi may rủi. Muốn biết hiệu ứng có thật hay không thì phải chờ thêm dữ liệu và chạy lại trên danh sách VN30 không bị thiên lệch sống sót.
Bài viết chỉ nhằm mục đích nghiên cứu và chia sẻ kiến thức, không phải khuyến nghị đầu tư.
---
Muốn tự kiểm chứng một ý tưởng như thế này? Bạn có thể dựng chiến lược, backtest và chạy paper trading ngay trên nền tảng QuantVN. Xem thêm các nghiên cứu khác trong bài tổng hợp Chứng khoán phái sinh VN30F1M: dữ liệu nói gì.