Trong chương này, chúng ta trình bày một cách có hệ thống về lợi nhuận kép. Dù xây dựng danh mục mua và nắm giữ, đánh giá hiệu quả của quỹ, tính chỉ số tài sản tích lũy hay ước lượng các thước đo rủi ro dài hạn, khả năng tính đúng lợi nhuận kép trên các kỳ hạn tùy ý đều là điều không thể thiếu. Chúng ta bắt đầu từ nền tảng toán học: sự khác biệt giữa lợi nhuận đơn giản và lợi nhuận logarit, mối quan hệ giữa trung bình cộng và trung bình nhân, cùng các tính chất của lợi nhuận ghép lãi liên tục. Xuyên suốt chương, chúng ta cũng đề cập những vướng mắc thực tế phát sinh trong dữ liệu cổ phiếu, chẳng hạn như tạm ngừng giao dịch, cơ chế biên độ giá, lợi nhuận của kỳ không trọn vẹn và các sự kiện hủy niêm yết, đồng thời chỉ ra cách xử lý chúng trong bối cảnh Việt Nam.

Tiếp theo, chương này trình bày lợi nhuận kép trượt trên các kỳ hạn chuẩn (3, 6, 9 và 12 tháng), lợi nhuận kép được căn theo thời điểm kết thúc kỳ tài chính, lợi nhuận tích lũy hướng về phía trước dùng cho nghiên cứu sự kiện, và ước lượng độ biến động trượt.

Hiện mã
import pandas as pd
import numpy as np
import sqlite3
import matplotlib.pyplot as plt
from plotnine import *
from mizani.formatters import percent_format, comma_format, date_format
from itertools import product
from datetime import datetime, timedelta

7.1 Lợi nhuận đơn giản và lợi nhuận logarit

Trước khi bàn về việc tính lãi kép, chúng ta cần phân biệt hai quy ước lợi nhuận cơ bản được dùng trong tài chính.

7.1.1 Lợi nhuận đơn giản (số học)

Lợi nhuận gộp đơn giản của một tài sản từ kỳ \(t-1\) đến \(t\) được định nghĩa là

\[ 1 + R_t = \frac{P_t + D_t}{P_{t-1}}, \tag{7.1}\]

trong đó \(P_t\) là giá cuối kỳ \(t\) và \(D_t\) là mọi khoản phân phối bằng tiền mặt (cổ tức, lãi trái phiếu) được chi trả trong kỳ \(t\). Lợi nhuận ròng đơn giản chính là \(R_t\). Khi nói về “lợi nhuận” mà không nêu rõ, thông thường chúng ta hiểu đó là lợi nhuận ròng đơn giản.

Tính chất then chốt của lợi nhuận đơn giản là việc ghép lãi nhiều kỳ có dạng nhân:

\[ 1 + R_t(k) = \prod_{j=0}^{k-1} (1 + R_{t-j}) = (1 + R_t)(1 + R_{t-1}) \cdots (1 + R_{t-k+1}), \tag{7.2}\]

trong đó \(R_t(k)\) là lợi nhuận kép của \(k\) kỳ kết thúc tại thời điểm \(t\). Cấu trúc nhân này là nền tảng của mọi phương pháp tính lãi kép được trình bày trong chương này.

7.1.2 Lợi nhuận ghép lãi liên tục (logarit)

Lợi nhuận ghép lãi liên tục, hay lợi nhuận logarit, được định nghĩa là

\[ r_t = \ln(1 + R_t) = \ln\!\left(\frac{P_t + D_t}{P_{t-1}}\right). \tag{7.3}\]

Ưu điểm chính của lợi nhuận logarit khi tính lãi kép là việc ghép lãi nhiều kỳ trở thành phép cộng:

\[ r_t(k) = \ln(1 + R_t(k)) = \sum_{j=0}^{k-1} r_{t-j} = r_t + r_{t-1} + \cdots + r_{t-k+1}. \tag{7.4}\]

Tính chất cộng này suy ra trực tiếp từ đồng nhất thức logarit \(\ln(ab) = \ln(a) + \ln(b)\). Nó thuận tiện về mặt tính toán vì phép cộng ổn định về số học hơn so với phép nhân lặp, và vì nhiều thủ tục thống kê (trung bình, phương sai, hồi quy) vận hành một cách tự nhiên trên các đại lượng có tính cộng.

Để khôi phục lợi nhuận kép đơn giản từ tổng các lợi nhuận logarit, chúng ta áp dụng hàm mũ:

\[ R_t(k) = \exp\!\left(\sum_{j=0}^{k-1} r_{t-j}\right) - 1. \tag{7.5}\]

7.1.3 Khi nào hai loại lợi nhuận phân kỳ?

Với lợi nhuận nhỏ, xấp xỉ \(r_t \approx R_t\) đúng đến bậc một (qua khai triển Taylor \(\ln(1+x) \approx x\) với \(|x| \ll 1\)). Tuy nhiên, với lợi nhuận lớn, điều thường gặp ở các thị trường mới nổi, cổ phiếu vốn hóa nhỏ hoặc giai đoạn khủng hoảng, hai loại lợi nhuận có thể chênh lệch đáng kể. Xét một cổ phiếu tăng giá gấp đôi (\(R_t = 1.0\)): lợi nhuận logarit là \(r_t = \ln(2) \approx 0.693\), lệch 31%. Ngược lại, với cổ phiếu mất một nửa giá trị (\(R_t = -0.5\)): lợi nhuận logarit là \(r_t = \ln(0.5) \approx -0.693\), lớn hơn 39% về độ lớn.

Sự phân kỳ này đặc biệt đáng chú ý ở Việt Nam, nơi biên độ giá hằng ngày \(\pm 7\%\) trên HOSE, \(\pm 10\%\) trên HNX và \(\pm 15\%\) trên UPCoM có thể tạo ra chuỗi các phiên giá trần hoặc giá sàn liên tiếp. Trong một tuần có các phiên giá trần liên tiếp trên HOSE, lợi nhuận đơn giản là \((1.07)^5 - 1 = 40.3\%\) trong khi lợi nhuận logarit là \(5 \times \ln(1.07) = 33.8\%\), một khoảng chênh lệch đáng kể.

Bảng 7.1 minh họa sự phân kỳ này trên một dải các độ lớn lợi nhuận.

Hiện mã
simple_returns = [-0.50, -0.30, -0.15, -0.10, -0.07, -0.05, -0.01,
                  0.00, 0.01, 0.05, 0.07, 0.10, 0.15, 0.30, 0.50, 1.00]
comparison_df = pd.DataFrame({
    "Simple Return": [f"{r:.2%}" for r in simple_returns],
    "Log Return": [f"{np.log(1+r):.4f}" for r in simple_returns],
    "Difference": [f"{np.log(1+r) - r:.4f}" for r in simple_returns],
    "Relative Error (%)": [
        f"{((np.log(1+r) - r) / abs(r) * 100):.2f}" if r != 0 else "—"
        for r in simple_returns
    ]
})
comparison_df
Bảng 7.1: So sánh lợi nhuận đơn giản và lợi nhuận logarit với các mức thay đổi giá khác nhau. Độ chênh lệch tăng theo độ lớn của lợi nhuận đơn giản, điều đặc biệt quan trọng đối với các cổ phiếu biến động mạnh của thị trường mới nổi.
Simple Return Log Return Difference Relative Error (%)
0 -50.00% -0.6931 -0.1931 -38.63
1 -30.00% -0.3567 -0.0567 -18.89
2 -15.00% -0.1625 -0.0125 -8.35
3 -10.00% -0.1054 -0.0054 -5.36
4 -7.00% -0.0726 -0.0026 -3.67
5 -5.00% -0.0513 -0.0013 -2.59
6 -1.00% -0.0101 -0.0001 -0.50
7 0.00% 0.0000 0.0000 —
8 1.00% 0.0100 -0.0000 -0.50
9 5.00% 0.0488 -0.0012 -2.42
10 7.00% 0.0677 -0.0023 -3.34
11 10.00% 0.0953 -0.0047 -4.69
12 15.00% 0.1398 -0.0102 -6.83
13 30.00% 0.2624 -0.0376 -12.55
14 50.00% 0.4055 -0.0945 -18.91
15 100.00% 0.6931 -0.3069 -30.69

Điểm mấu chốt: lợi nhuận logarit thuận tiện cho việc tính lãi kép (cộng gộp), nhưng lợi nhuận danh mục được tổng hợp theo mặt cắt ngang trong không gian lợi nhuận đơn giản. Trong thực tế, chúng ta thường chuyển sang lợi nhuận logarit để ghép lãi theo thời gian, rồi quy đổi trở lại lợi nhuận đơn giản để báo cáo.

7.2 Nền tảng toán học của việc tính lãi kép

7.2.1 Lợi nhuận trung bình nhân

Lợi nhuận trung bình nhân trên \(T\) kỳ là

\[ \bar{R}_g = \left(\prod_{t=1}^{T} (1 + R_t)\right)^{1/T} - 1, \tag{7.6}\]

đại lượng này biểu diễn mức lợi nhuận không đổi mỗi kỳ sẽ cho cùng giá trị tài sản cuối kỳ như chuỗi lợi nhuận thực tế. Nó luôn nhỏ hơn hoặc bằng trung bình cộng \(\bar{R}_a = \frac{1}{T}\sum_{t=1}^{T} R_t\), và chỉ bằng nhau khi mọi lợi nhuận đều giống nhau. Mối quan hệ xấp xỉ giữa hai đại lượng là:

\[ \bar{R}_g \approx \bar{R}_a - \frac{\sigma^2}{2}, \tag{7.7}\]

trong đó \(\sigma^2\) là phương sai của lợi nhuận. Xấp xỉ này, đôi khi được gọi là “lực cản biến động” (volatility drag), có hàm ý quan trọng: các tài sản có độ biến động cao có khoảng cách lớn hơn giữa trung bình cộng và trung bình nhân, nghĩa là mức tăng trưởng kép thực tế thấp hơn so với những gì một giá trị trung bình giản đơn gợi ra. Ở một thị trường như Việt Nam, nơi độ biến động của từng cổ phiếu thường gấp hai đến ba lần so với cổ phiếu ở các thị trường phát triển, lực cản biến động có thể đáng kể.

7.2.2 Chỉ số tài sản và mức sụt giảm

Với khoản đầu tư ban đầu \(W_0\), giá trị tài sản tại thời điểm \(T\) là

\[ W_T = W_0 \prod_{t=1}^{T} (1 + R_t). \tag{7.8}\]

Lợi nhuận tích lũy (ròng) đơn giản là \(W_T / W_0 - 1\). Mức sụt giảm tối đa (maximum drawdown), một thước đo rủi ro được dùng rộng rãi, được định nghĩa là

\[ \text{MDD} = \max_{0 \le s \le t \le T} \left(\frac{W_s - W_t}{W_s}\right), \tag{7.9}\]

đại lượng này đo mức giảm lớn nhất từ đỉnh xuống đáy của chỉ số tài sản. Chúng ta sẽ tính đại lượng này cùng với lợi nhuận kép ở phần sau. Mức sụt giảm đặc biệt hữu ích ở các thị trường mới nổi chịu những đợt điều chỉnh mạnh, như đã xảy ra trong cuộc khủng hoảng tài chính toàn cầu năm 2008 khi VN-Index giảm khoảng 66% so với đỉnh năm 2007.

7.2.3 Quy đổi theo năm

Với lợi nhuận kép \(k\) kỳ \(R_t(k)\) trong đó mỗi kỳ có độ dài \(\Delta\) (ví dụ \(\Delta = 1/12\) với dữ liệu hằng tháng), lợi nhuận quy đổi theo năm là

\[ R_{\text{ann}} = (1 + R_t(k))^{1/(k\Delta)} - 1. \tag{7.10}\]

Tương tự, với độ biến động ước lượng từ lợi nhuận \(k\) kỳ có độ dài kỳ \(\Delta\):

\[ \sigma_{\text{ann}} = \sigma / \sqrt{\Delta}, \tag{7.11}\]

do đó độ biến động hằng tháng được quy đổi theo năm bằng cách nhân với \(\sqrt{12}\) và độ biến động hằng ngày xấp xỉ nhân với \(\sqrt{252}\) (giả định có 252 ngày giao dịch mỗi năm). Riêng với Việt Nam, HOSE thường có khoảng 245–250 ngày giao dịch mỗi năm sau khi trừ các ngày nghỉ lễ, đủ gần để quy ước \(\sqrt{252}\) vẫn là chuẩn mực.

7.3 Chuẩn bị dữ liệu

Chúng ta bắt đầu bằng việc tải dữ liệu lợi nhuận cổ phiếu hằng tháng từ cơ sở dữ liệu SQLite. Như đã chuẩn bị ở các chương trước, cơ sở dữ liệu này chứa lợi nhuận hằng tháng lấy từ DataCore.vn cho mọi chứng khoán niêm yết trên Sở Giao dịch Chứng khoán TP. Hồ Chí Minh (HOSE), Sở Giao dịch Chứng khoán Hà Nội (HNX) và thị trường giao dịch của các công ty đại chúng chưa niêm yết (UPCoM). Lợi nhuận đã được điều chỉnh theo chia tách cổ phiếu, cổ phiếu thưởng và phát hành quyền mua, đồng thời bao gồm cổ tức tiền mặt tái đầu tư.

Hiện mã
tidy_finance = sqlite3.connect(database="data/tidy_finance_python.sqlite")

prices_monthly = pd.read_sql_query(
    sql="""
        SELECT symbol, date, ret_excess, ret, mktcap, mktcap_lag, risk_free
        FROM prices_monthly
    """,
    con=tidy_finance,
    parse_dates=["date"]
).dropna()

factors_ff3_monthly = pd.read_sql_query(
    sql="SELECT date, mkt_excess FROM factors_ff3_monthly",
    con=tidy_finance,
    parse_dates=["date"]
)

prices_monthly = prices_monthly.merge(
    factors_ff3_monthly,
    on="date",
    how="left"
)

prices_monthly["ret_total"] = prices_monthly["ret"]
prices_monthly["mkt_total"] = (
    prices_monthly["mkt_excess"] + prices_monthly["risk_free"]
)

Chúng ta hãy xem xét mẫu dữ liệu:

Hiện mã
print(f"Sample period: {prices_monthly['date'].min()} to "
      f"{prices_monthly['date'].max()}")
print(f"Number of stocks: {prices_monthly['symbol'].nunique():,}")
print(f"Total observations: {len(prices_monthly):,}")
# print(f"Exchanges: {prices_monthly['exchange'].unique()}")
Sample period: 2010-02-28 00:00:00 to 2023-12-31 00:00:00
Number of stocks: 1,457
Total observations: 165,499

Bảng 7.2 cung cấp thống kê mô tả cho lợi nhuận hằng tháng thô, phân theo sàn giao dịch. Sự khác biệt giữa các sàn phản ánh sự phân tầng về quy mô và thanh khoản: HOSE niêm yết các doanh nghiệp lớn nhất và thanh khoản nhất, HNX bao gồm các công ty vốn hóa trung bình, còn UPCoM là nơi giao dịch các chứng khoán nhỏ hơn và kém thanh khoản hơn.

Bảng 7.2: Thống kê mô tả lợi nhuận cổ phiếu hằng tháng theo sàn giao dịch. Các doanh nghiệp trên HOSE có xu hướng có độ phân tán lợi nhuận thấp hơn và ít quan sát cực đoan hơn so với HNX và UPCoM, phù hợp với vốn hóa thị trường lớn hơn và thanh khoản cao hơn.
Hiện mã
sample_stats = (
    prices_monthly
    .groupby("exchange")["ret_total"]
    .describe(percentiles=[0.05, 0.25, 0.50, 0.75, 0.95])
    .round(4)
)
sample_stats

7.4 Phương pháp 1: Tích lũy kế bằng GroupBy

Cách trực tiếp nhất để tính lợi nhuận kép là dùng tính chất nhân trong Phương trình 7.2. Với mỗi chứng khoán, chúng ta tính tích lũy kế của các lợi nhuận gộp \((1 + R_t)\) trên cửa sổ mong muốn.

Hiện mã
def compute_cumret_cumprod(df, ret_col="ret_total",
                           group_col="symbol"):
    """Compute cumulative returns using cumulative product.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain `group_col`, 'date', and `ret_col`.
    ret_col : str
        Column name for period returns.
    group_col : str
        Column name for grouping (e.g., security identifier).

    Returns
    -------
    pd.DataFrame
        Original DataFrame augmented with 'cumret' and 'wealth_index'.
    """
    df = df.sort_values([group_col, "date"]).copy()
    df["gross_ret"] = 1 + df[ret_col]
    df["wealth_index"] = (
        df.groupby(group_col)["gross_ret"]
        .cumprod()
    )
    df["cumret"] = df["wealth_index"] - 1
    df.drop(columns=["gross_ret"], inplace=True)
    return df

Chúng ta hãy áp dụng cách này cho toàn bộ mẫu và xem xét các chỉ số tài sản thu được của một vài cổ phiếu được chọn:

Hiện mã
stock_cumret = compute_cumret_cumprod(prices_monthly)

# Select stocks with long histories for illustration
stock_counts = (
    stock_cumret.groupby("symbol")["date"]
    .count()
    .reset_index(name="n_obs")
)
long_history_stocks = (
    stock_counts.nlargest(5, "n_obs")["symbol"].tolist()
)

sample_wealth = stock_cumret[
    stock_cumret["symbol"].isin(long_history_stocks)
]

Hình 7.1 vẽ các chỉ số tài sản (giá trị của 1 VND đầu tư) của năm chứng khoán này trong toàn bộ giai đoạn mẫu.

Hiện mã
plot_wealth = (
    ggplot(sample_wealth, aes(x="date", y="wealth_index",
                              color="factor(symbol)")) +
    geom_line(size=0.6) +
    labs(
        x="", y="Wealth index (1 VND invested)",
        color="Stock"
    ) +
    theme_minimal() +
    theme(legend_position="bottom",
          figure_size=(10, 5))
)
plot_wealth.draw()
Biểu đồ đường thể hiện mức tăng trưởng của 1 VND đầu tư vào năm cổ phiếu Việt Nam khác nhau theo thời gian.
Hình 7.1: Chỉ số tài sản (giá trị của 1 VND đầu tư) của một số cổ phiếu Việt Nam có lịch sử dài được chọn. Mỗi đường biểu diễn giá trị tích lũy của khoản đầu tư 1 VND vào một cổ phiếu, với toàn bộ cổ tức được tái đầu tư. Sự phân kỳ về tài sản cuối kỳ cho thấy sức mạnh của lãi kép trong các kỳ hạn dài.

7.4.1 Xử lý lợi nhuận bị thiếu

Cách tính tích lũy kế lan truyền giá trị thiếu: nếu bất kỳ \(R_t\) nào là NaN, toàn bộ tích lũy kế từ thời điểm đó trở đi sẽ thành NaN. Cách này thận trọng vì về thực chất giả định rằng một lợi nhuận bị thiếu khiến chỉ số tài sản sau đó không xác định được. Trong nhiều ứng dụng, đây là hành vi mong muốn vì lợi nhuận bị thiếu có thể cho thấy lỗi dữ liệu hoặc giai đoạn cổ phiếu không được giao dịch.

Tuy nhiên, trên thị trường Việt Nam, lợi nhuận bị thiếu có thể phát sinh từ các đợt tạm ngừng giao dịch kéo dài. Ủy ban Chứng khoán Nhà nước (UBCKNN) và các sở giao dịch có thể đình chỉ giao dịch một cổ phiếu vì nhiều lý do quản lý, chẳng hạn chậm công bố báo cáo tài chính, thông báo tái cơ cấu doanh nghiệp đang chờ xử lý, hoặc nghi ngờ thao túng thị trường. Các đợt tạm ngừng này có thể kéo dài vài ngày, vài tuần, thậm chí vài tháng. Trong thời gian đó, giá trị của cổ phiếu không thay đổi (mức giá giao dịch cuối cùng vẫn là giá tham chiếu), nên việc coi lợi nhuận bị thiếu bằng không (tức là không có biến động giá) có thể phù hợp hơn việc lan truyền NaN.

Hiện mã
def compute_cumret_skipna(df, ret_col="ret_total",
                          group_col="symbol"):
    """Compute cumulative returns, treating missing returns as zero."""
    df = df.sort_values([group_col, "date"]).copy()
    df["gross_ret"] = 1 + df[ret_col].fillna(0)
    df["wealth_index"] = (
        df.groupby(group_col)["gross_ret"]
        .cumprod()
    )
    df["cumret"] = df["wealth_index"] - 1
    df.drop(columns=["gross_ret"], inplace=True)
    return df
Cảnh báo

Việc coi lợi nhuận bị thiếu bằng không là một giả định có thể phù hợp hoặc không. Nếu lợi nhuận bị thiếu vì cổ phiếu bị tạm ngừng giao dịch thì giá trị bằng không có thể hợp lý. Nếu lợi nhuận bị thiếu do lỗi dữ liệu hoặc vì cổ phiếu thực sự không được giao dịch (ví dụ, đang chờ niêm yết lại sau một sự kiện doanh nghiệp), việc gán giá trị bằng không có thể gây ra thiên lệch. Hãy luôn tìm hiểu nguyên nhân của các giá trị bị thiếu trước khi quyết định cách xử lý.

7.5 Phương pháp 2: Cách tiếp cận log-sum-exp

Phương pháp log-sum-exp khai thác tính chất cộng của lợi nhuận logarit (Phương trình 7.4). Cách tiếp cận này đặc biệt hữu ích khi tính lợi nhuận kép trên các cửa sổ cố định (ví dụ, lợi nhuận hằng năm từ dữ liệu hằng tháng) vì phép cộng vừa hiệu quả về tính toán vừa ổn định về số học.

Hiện mã
def compute_cumret_logsum(df, ret_col="ret_total",
                          group_col="symbol",
                          date_col="date"):
    """Compute cumulative returns using the log-sum-exp approach.

    Steps:
    1. Transform to log returns: r_t = ln(1 + R_t)
    2. Cumulative sum of log returns within each group
    3. Exponentiate to recover simple cumulative return

    Parameters
    ----------
    df : pd.DataFrame
    ret_col : str
    group_col : str
    date_col : str

    Returns
    -------
    pd.DataFrame
    """
    df = df.sort_values([group_col, date_col]).copy()
    df["log_ret"] = np.log(1 + df[ret_col])
    df["cum_log_ret"] = (
        df.groupby(group_col)["log_ret"].cumsum()
    )
    df["wealth_index_log"] = np.exp(df["cum_log_ret"])
    df["cumret_log"] = df["wealth_index_log"] - 1
    df.drop(columns=["log_ret", "cum_log_ret"], inplace=True)
    return df

Chúng ta hãy kiểm tra rằng hai phương pháp cho kết quả giống hệt nhau (đến độ chính xác dấu phẩy động):

Hiện mã
stock_both = compute_cumret_cumprod(prices_monthly)
stock_both = compute_cumret_logsum(stock_both)

# Compare on non-missing observations
mask = (stock_both["cumret"].notna()
        & stock_both["cumret_log"].notna())
max_diff = (stock_both.loc[mask, "cumret"] -
            stock_both.loc[mask, "cumret_log"]).abs().max()
print(f"Maximum absolute difference between methods: {max_diff:.2e}")
Maximum absolute difference between methods: 1.78e-14

Chênh lệch ở mức epsilon máy (\(\approx 10^{-15}\)), xác nhận sự tương đương về số học.

7.5.1 Lợi nhuận kép theo từng kỳ cụ thể

Một công việc phổ biến là tính lợi nhuận kép trong các kỳ lịch (tháng, quý, năm). Phương pháp log-sum-exp phù hợp một cách tự nhiên với phép tổng hợp theo nhóm:

Hiện mã
def compound_return_by_period(df, ret_col="ret_total",
                              group_col="symbol",
                              period="year"):
    """Compute compound returns within calendar periods.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain 'date' and `ret_col`.
    period : str
        One of 'year', 'quarter', 'month'.

    Returns
    -------
    pd.DataFrame with compound returns per group-period.
    """
    df = df.copy()
    df["log_ret"] = np.log(1 + df[ret_col])
    if period == "year":
        df["period"] = df["date"].dt.year
    elif period == "quarter":
        df["period"] = df["date"].dt.to_period("Q")
    elif period == "month":
        df["period"] = df["date"].dt.to_period("M")

    result = (
        df.groupby([group_col, "period"])
        .agg(
            cumret=(
                "log_ret",
                lambda x: np.exp(x.sum()) - 1
            ),
            n_obs=("log_ret", "count"),
            n_miss=(ret_col, lambda x: x.isna().sum()),
            start_date=("date", "min"),
            end_date=("date", "max")
        )
        .reset_index()
    )
    return result

Bảng 7.3 trình bày lợi nhuận kép hằng năm cho một tập con các chứng khoán.

Hiện mã
annual_returns = compound_return_by_period(
    prices_monthly[
        prices_monthly["symbol"].isin(long_history_stocks)
    ],
    period="year"
)

recent_annual = (
    annual_returns
    .sort_values(["symbol", "period"])
    .groupby("symbol")
    .tail(5)
    .round(4)
)
recent_annual.head(20)
/tmp/ipykernel_260635/2619242959.py:13: UserWarning: obj.round has no effect with datetime, timedelta, or period dtypes. Use obj.dt.round(...) instead.
Bảng 7.3: Lợi nhuận kép hằng năm của một số chứng khoán Việt Nam được chọn. Số quan sát hằng tháng không bị thiếu (n_obs) và số quan sát bị thiếu (n_miss) được báo cáo để đánh dấu các năm có thể không đầy đủ. Một cặp cổ phiếu-năm có n_obs thấp hơn đáng kể so với 12 cho thấy cổ phiếu chỉ được niêm yết trong một phần của năm hoặc bị tạm ngừng giao dịch kéo dài.
symbol period cumret n_obs n_miss start_date end_date
9 AAM 2019 -0.2810 12 0 2019-01-31 2019-12-31
10 AAM 2020 -0.1622 12 0 2020-01-31 2020-12-31
11 AAM 2021 0.1250 12 0 2021-01-31 2021-12-31
12 AAM 2022 -0.0913 12 0 2022-01-31 2022-12-31
13 AAM 2023 -0.2337 12 0 2023-01-31 2023-12-31
23 ABI 2019 0.1946 12 0 2019-01-31 2019-12-31
24 ABI 2020 0.2418 12 0 2020-01-31 2020-12-31
25 ABI 2021 0.2896 12 0 2021-01-31 2021-12-31
26 ABI 2022 -0.5085 12 0 2022-01-31 2022-12-31
27 ABI 2023 -0.5042 12 0 2023-01-31 2023-12-31
37 ABT 2019 -0.1893 12 0 2019-01-31 2019-12-31
38 ABT 2020 -0.1400 12 0 2020-01-31 2020-12-31
39 ABT 2021 0.0842 12 0 2021-01-31 2021-12-31
40 ABT 2022 -0.0789 12 0 2022-01-31 2022-12-31
41 ABT 2023 -0.0768 12 0 2023-01-31 2023-12-31
51 ACC 2019 -0.1875 12 0 2019-01-31 2019-12-31
52 ACC 2020 -0.4923 12 0 2020-01-31 2020-12-31
53 ACC 2021 1.2339 12 0 2021-01-31 2021-12-31
54 ACC 2022 -0.8538 12 0 2022-01-31 2022-12-31
55 ACC 2023 0.1027 12 0 2023-01-31 2023-12-31
Quan trọng

Khi số quan sát không bị thiếu (n_obs) nhỏ hơn 12 đối với lợi nhuận hằng năm, lợi nhuận kép chỉ phản ánh một phần của năm. Điều này thường xảy ra vào năm đầu và năm cuối của một chứng khoán niêm yết trên HOSE, HNX hoặc UPCoM, hoặc khi một cổ phiếu chuyển sàn (ví dụ, từ UPCoM lên HOSE khi đáp ứng điều kiện niêm yết). Người dùng cần quyết định giữ lại hay loại bỏ các quan sát không trọn năm này tùy theo thiết kế nghiên cứu của mình.

7.6 Phương pháp 3: Tính lãi kép lặp với logic giữ lại

Trong một số ứng dụng, chúng ta cần kiểm soát chi tiết cách các giá trị bị thiếu, sự kiện hủy niêm yết hay các điều kiện đặc biệt khác ảnh hưởng đến quá trình tính lãi kép. Cách tiếp cận lặp xử lý lần lượt từng quan sát, chuyển tiếp lợi nhuận tích lũy và áp dụng logic có điều kiện ở mỗi bước.

Hiện mã
def compute_cumret_iterative(df, ret_col="ret_total",
                              group_col="symbol",
                              handle_missing="carry"):
    """Compute cumulative returns iteratively with flexible
    missing value handling.

    Parameters
    ----------
    df : pd.DataFrame
    ret_col : str
    group_col : str
    handle_missing : str
        'carry' : treat missing as zero return (carry forward)
        'propagate' : propagate NaN (conservative)
        'reset' : reset wealth index to 1 after missing spell

    Returns
    -------
    pd.DataFrame
    """
    df = df.sort_values([group_col, "date"]).copy()
    results = []

    for name, group in df.groupby(group_col):
        cumret = 1.0
        cumrets = []
        for _, row in group.iterrows():
            ret = row[ret_col]
            if pd.notna(ret):
                cumret = cumret * (1 + ret)
            else:
                if handle_missing == "propagate":
                    cumret = np.nan
                elif handle_missing == "reset":
                    cumret = 1.0
                # 'carry' does nothing (cumret unchanged)
            cumrets.append(cumret)
        group = group.copy()
        group["wealth_iter"] = cumrets
        group["cumret_iter"] = group["wealth_iter"] - 1
        results.append(group)

    return pd.concat(results, ignore_index=True)
Ghi chú

Phương pháp lặp là chậm nhất trong bốn cách tiếp cận vì không tận dụng được các phép toán vector hóa của NumPy. Với bộ dữ liệu lớn, hãy ưu tiên Phương pháp 1 hoặc 2, trừ khi logic có điều kiện của Phương pháp 3 là thiết yếu. Trên một bộ dữ liệu có 1 triệu quan sát, Phương pháp 1 chạy trong khoảng 0.1 giây so với hơn 10 giây của Phương pháp 3.

7.6.1 So sánh các cách xử lý giá trị bị thiếu

Để minh họa sự khác biệt giữa ba chiến lược xử lý giá trị bị thiếu, hãy xét một cổ phiếu giả định có một lợi nhuận bị thiếu ở giữa lịch sử của nó:

Hiện mã
example = pd.DataFrame({
    "symbol": [1]*6,
    "date": pd.date_range("2024-01-31", periods=6, freq="ME"),
    "ret_total": [0.05, 0.03, np.nan, 0.04, -0.02, 0.06]
})

carry = compute_cumret_iterative(example, handle_missing="carry")
propagate = compute_cumret_iterative(
    example, handle_missing="propagate"
)
reset = compute_cumret_iterative(example, handle_missing="reset")

comparison = pd.DataFrame({
    "Date": example["date"].dt.strftime("%Y-%m"),
    "Return": example["ret_total"],
    "Carry": carry["cumret_iter"].round(6),
    "Propagate": propagate["cumret_iter"].round(6),
    "Reset": reset["cumret_iter"].round(6)
})
comparison
Bảng 7.4: Effect of different missing value treatments on cumulative returns. The ‘carry’ strategy assumes zero return for missing periods (appropriate for trading halts); ‘propagate’ makes all subsequent values undefined (conservative); ‘reset’ restarts the cumulative product after the missing spell.
Date Return Carry Propagate Reset
0 2024-01 0.05 0.050000 0.0500 0.050000
1 2024-02 0.03 0.081500 0.0815 0.081500
2 2024-03 NaN 0.081500 NaN 0.000000
3 2024-04 0.04 0.124760 NaN 0.040000
4 2024-05 -0.02 0.102265 NaN 0.019200
5 2024-06 0.06 0.168401 NaN 0.080352

7.7 Phương pháp 4: Lợi nhuận kép trượt

Với nhiều ứng dụng thực nghiệm, gồm chiến lược động lượng, đánh giá hiệu quả đầu tư và ước lượng rủi ro, chúng ta cần lợi nhuận kép trên các cửa sổ trượt có độ dài cố định. Mục này triển khai việc tính lãi kép trượt hiệu quả bằng pandas.

7.7.1 Cửa sổ trượt qua lợi nhuận logarit

Cách hiệu quả nhất là kết hợp phương pháp log-sum-exp với tổng trượt:

Hiện mã
def rolling_compound_return(df, ret_col="ret_total",
                             group_col="symbol",
                             windows=[3, 6, 9, 12]):
    """Compute rolling compound returns over specified windows.

    Parameters
    ----------
    df : pd.DataFrame
        Must be sorted by [group_col, 'date'] with no gaps.
    ret_col : str
    group_col : str
    windows : list of int
        Rolling window lengths (in periods).

    Returns
    -------
    pd.DataFrame with new columns ret_{k} for each window k.
    """
    df = df.sort_values([group_col, "date"]).copy()
    df["log_ret"] = np.log(1 + df[ret_col])

    for k in windows:
        rolling_logsum = (
            df.groupby(group_col)["log_ret"]
            .transform(
                lambda x: x.rolling(
                    window=k, min_periods=k
                ).sum()
            )
        )
        df[f"ret_{k}"] = np.exp(rolling_logsum) - 1

    df.drop(columns=["log_ret"], inplace=True)
    return df

Chúng ta áp dụng cách này cho toàn bộ mẫu để tính lợi nhuận kép 3, 6, 9 và 12 tháng gần nhất:

Hiện mã
stock_rolling = rolling_compound_return(
    prices_monthly,
    windows=[3, 6, 9, 12]
)

Chúng ta cũng tính các lợi nhuận trượt tương tự cho chỉ số thị trường, đóng vai trò chuẩn so sánh khi tính lợi nhuận vượt mức:

Hiện mã
# Compute market rolling returns
market_monthly = (
    prices_monthly[["date", "mkt_total"]]
    .drop_duplicates()
    .sort_values("date")
    .copy()
)
market_monthly["log_mkt"] = np.log(1 + market_monthly["mkt_total"])

for k in [3, 6, 9, 12]:
    market_monthly[f"mkt_{k}"] = (
        np.exp(
            market_monthly["log_mkt"]
            .rolling(window=k, min_periods=k)
            .sum()
        ) - 1
    )

market_monthly.drop(columns=["log_mkt"], inplace=True)

# Merge market rolling returns back
stock_rolling = stock_rolling.merge(
    market_monthly[
        ["date"] + [f"mkt_{k}" for k in [3, 6, 9, 12]]
    ],
    on="date",
    how="left"
)

Hình 35.4 hiển thị phân phối của lợi nhuận kép trượt 12 tháng theo thời gian.

Hiện mã
rolling_stats = (
    stock_rolling
    .dropna(subset=["ret_12"])
    .groupby("date")["ret_12"]
    .agg(["median", lambda x: x.quantile(0.25),
           lambda x: x.quantile(0.75)])
    .reset_index()
)
rolling_stats.columns = ["date", "median", "p25", "p75"]

plot_rolling = (
    ggplot(rolling_stats, aes(x="date")) +
    geom_ribbon(aes(ymin="p25", ymax="p75"),
                alpha=0.3, fill="#2166ac") +
    geom_line(aes(y="median"), color="#2166ac", size=0.7) +
    geom_hline(yintercept=0, linetype="dashed") +
    labs(x="", y="12-month compound return") +
    scale_y_continuous(labels=percent_format()) +
    theme_minimal() +
    theme(figure_size=(10, 5))
)
plot_rolling.draw()
Biểu đồ chuỗi thời gian thể hiện phân phối lợi nhuận cổ phiếu trượt 12 tháng ở Việt Nam.
Hình 7.2: Phân phối theo mặt cắt ngang của lợi nhuận kép trượt 12 tháng của các cổ phiếu Việt Nam theo thời gian. Dải tô bóng biểu diễn khoảng tứ phân vị (phân vị thứ 25 đến 75), còn đường liền thể hiện trung vị. Các sự kiện toàn thị trường mạnh, như khủng hoảng tài chính toàn cầu 2008 và cú sốc COVID-19 năm 2020, hiện rõ là những giai đoạn mà ngay cả lợi nhuận trung vị cũng chuyển sang âm mạnh.

7.7.2 Kiểm tra lợi nhuận trượt

Việc kiểm tra lợi nhuận kép trượt bằng phép tính trực tiếp là điều nên làm. Chúng ta chọn một cổ phiếu và tính lại lợi nhuận 12 tháng của nó theo cách thủ công:

Hiện mã
test_stock = long_history_stocks[0]
test_data = (
    stock_rolling[stock_rolling["symbol"] == test_stock]
    .sort_values("date")
    .tail(15)
    .copy()
)

# Direct computation
test_data["direct_ret_12"] = (
    test_data["ret_total"]
    .transform(
        lambda x: x.add(1).rolling(
            12, min_periods=12
        ).apply(np.prod, raw=True) - 1
    )
)

verify = (
    test_data[["date", "ret_12", "direct_ret_12"]]
    .dropna()
    .tail(5)
    .copy()
)
verify["difference"] = (
    verify["ret_12"] - verify["direct_ret_12"]
).abs()
verify.round(8)
/tmp/ipykernel_260635/922053246.py:28: UserWarning: obj.round has no effect with datetime, timedelta, or period dtypes. Use obj.dt.round(...) instead.
Bảng 7.5: Verification of rolling compound return calculation. The ‘Direct’ column computes the product of the preceding 12 monthly gross returns minus one; ‘Rolling’ uses our log-sum-exp function. Differences are at machine precision.
date ret_12 direct_ret_12 difference
386 2023-09-30 -0.152407 -0.152407 0.0
387 2023-10-31 -0.208836 -0.208836 0.0
388 2023-11-30 -0.199018 -0.199018 0.0
389 2023-12-31 -0.233698 -0.233698 0.0

7.8 Lợi nhuận hủy niêm yết và thiên kiến sống sót

Một vấn đề thực tiễn quan trọng khi tính lợi nhuận kép là cách xử lý các chứng khoán bị loại khỏi sàn giao dịch. Việc hủy niêm yết xảy ra vì nhiều lý do: sáp nhập và mua lại, phá sản, không đáp ứng điều kiện niêm yết, tự nguyện rút niêm yết, hoặc chuyển sang sàn khác. Nếu không đưa lợi nhuận hủy niêm yết vào, lợi nhuận kép thu được chịu thiên kiến sống sót: chúng phóng đại hiệu quả vì các kết quả tệ nhất (phá sản, bị buộc hủy niêm yết) bị loại ra (Shumway 1997).

7.8.1 Bối cảnh Việt Nam

Ở Việt Nam, chứng khoán có thể bị loại khỏi niêm yết trên sàn vì nhiều lý do theo quy định của UBCKNN và các sở giao dịch:

  • Hủy niêm yết bắt buộc: khi một doanh nghiệp có lỗ lũy kế vượt vốn điều lệ, không đáp ứng nghĩa vụ báo cáo tài chính trong ba năm liên tiếp, hoặc bị thu hồi giấy phép kinh doanh.
  • Hủy niêm yết tự nguyện: khi cổ đông của doanh nghiệp biểu quyết rút khỏi sàn giao dịch.
  • Chuyển sàn: khi một doanh nghiệp chuyển từ UPCoM lên HOSE/HNX (nâng cấp) hoặc từ HOSE/HNX xuống UPCoM (hạ cấp). Về mặt kinh tế, những lần chuyển sàn này không phải là hủy niêm yết thực sự nhưng đòi hỏi xử lý cẩn thận khi tính lợi nhuận.

Khác với các thị trường phát triển hơn, nơi dữ liệu chi tiết về lợi nhuận hủy niêm yết được tổng hợp một cách có hệ thống, dữ liệu thị trường Việt Nam có thể không phải lúc nào cũng cung cấp lợi nhuận hủy niêm yết một cách tường minh. Khi một cổ phiếu bị hủy niêm yết do vi phạm hoặc hoàn cảnh bất lợi (ví dụ, phá sản), mức giá giao dịch cuối cùng có thể phóng đại đáng kể giá trị thu hồi của chứng khoán. Các nhà nghiên cứu cần nhận thức hạn chế này và cân nhắc gán lợi nhuận hủy niêm yết dựa trên lý do hủy niêm yết, theo phương pháp của Shumway (1997).

7.8.2 Đưa lợi nhuận hủy niêm yết vào tính toán

Khi một chứng khoán bị hủy niêm yết, một “lợi nhuận hủy niêm yết” cuối cùng phản ánh sự thay đổi giá trị giữa ngày giao dịch thông thường cuối cùng và giá trị thực hiện được sau khi hủy niêm yết. Lợi nhuận này phải được kết hợp với lợi nhuận thông thường trong tháng hủy niêm yết:

\[ R_t^{\text{adj}} = (1 + R_t)(1 + R_t^{\text{delist}}) - 1, \tag{7.12}\]

trong đó \(R_t\) là lợi nhuận thông thường và \(R_t^{\text{delist}}\) là lợi nhuận hủy niêm yết. Nếu lợi nhuận thông thường bị thiếu (cổ phiếu ngừng giao dịch trước cuối tháng), chúng ta chỉ dùng lợi nhuận hủy niêm yết.

Hiện mã
def adjust_for_delisting(df, ret_col="ret_total",
                          dlret_col="dlret"):
    """Adjust returns for delisting events.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain `ret_col` and `dlret_col`.

    Returns
    -------
    pd.DataFrame with adjusted return column 'ret_adj'.
    """
    df = df.copy()
    df["ret_adj"] = df[ret_col]

    # Case 1: Both regular and delisting returns available
    mask_both = df[ret_col].notna() & df[dlret_col].notna()
    df.loc[mask_both, "ret_adj"] = (
        (1 + df.loc[mask_both, ret_col]) *
        (1 + df.loc[mask_both, dlret_col]) - 1
    )

    # Case 2: Only delisting return available
    mask_dlret_only = (
        df[ret_col].isna() & df[dlret_col].notna()
    )
    df.loc[mask_dlret_only, "ret_adj"] = (
        df.loc[mask_dlret_only, dlret_col]
    )

    return df

7.8.3 Tác động của điều chỉnh hủy niêm yết

Độ lớn của thiên lệch do hủy niêm yết phụ thuộc vào tần suất và mức độ nghiêm trọng của các sự kiện hủy niêm yết. Shumway (1997) cho thấy rằng, ở các thị trường phát triển, việc bỏ qua lợi nhuận hủy niêm yết gây ra thiên lệch hướng lên khoảng 1% mỗi năm trong lợi nhuận danh mục trọng số bằng nhau. Thiên lệch này lớn hơn đối với cổ phiếu vốn hóa nhỏ và cổ phiếu giá trị, vốn dễ gặp khó khăn tài chính hơn. Ở Việt Nam, nơi các doanh nghiệp nhỏ hơn trên HNX và UPCoM chịu ràng buộc thanh khoản chặt hơn và rủi ro vỡ nợ cao hơn, thiên lệch có thể còn rõ rệt hơn. Trong các trường hợp hủy niêm yết ở thị trường mới nổi, hủy niêm yết bắt buộc thường liên quan đến các doanh nghiệp khó khăn tài chính nghiêm trọng, nơi giá trị vốn chủ sở hữu còn lại gần bằng không, hàm ý lợi nhuận hủy niêm yết gần \(-100\%\) trong những trường hợp xấu nhất.

7.9 Ước lượng độ biến động trượt

Độ biến động của lợi nhuận cổ phiếu là đầu vào quan trọng cho quản trị rủi ro, định giá quyền chọn và nhiều mô hình định giá tài sản thực nghiệm. Một cách tiếp cận phổ biến là ước lượng độ lệch chuẩn trượt của lợi nhuận trên một cửa sổ các kỳ gần nhất.

7.9.1 Độ biến động trượt 24 tháng

Theo Ben-David, Franzoni, và Moussawi (2012), chúng ta tính tổng độ biến động lợi nhuận cổ phiếu là độ lệch chuẩn trượt của lợi nhuận hằng tháng trên cửa sổ 24 tháng:

\[ \hat{\sigma}_{i,t}^{24} = \sqrt{\frac{1}{23}\sum_{j=0}^{23}(R_{i,t-j} - \bar{R}_{i,t}^{24})^2}, \tag{7.13}\]

trong đó \(\bar{R}_{i,t}^{24} = \frac{1}{24}\sum_{j=0}^{23} R_{i,t-j}\) là lợi nhuận trung bình 24 tháng gần nhất.

Hiện mã
def rolling_volatility(df, ret_col="ret_total",
                        group_col="symbol",
                        window=24):
    """Compute rolling return volatility.

    Parameters
    ----------
    df : pd.DataFrame
    ret_col : str
    group_col : str
    window : int
        Rolling window length in periods.

    Returns
    -------
    pd.DataFrame with 'vol_{window}' column (annualized).
    """
    df = df.sort_values([group_col, "date"]).copy()
    df[f"vol_{window}"] = (
        df.groupby(group_col)[ret_col]
        .transform(
            lambda x: x.rolling(
                window=window, min_periods=window
            ).std()
        )
    )
    # Annualize (monthly to annual)
    df[f"vol_{window}_ann"] = df[f"vol_{window}"] * np.sqrt(12)
    return df
Hiện mã
stock_vol = rolling_volatility(stock_rolling)

Hình 7.3 cho thấy phân phối theo mặt cắt ngang của độ biến động 24 tháng quy đổi theo năm qua thời gian.

Hiện mã
vol_stats = (
    stock_vol
    .dropna(subset=["vol_24_ann"])
    .groupby("date")["vol_24_ann"]
    .agg(["median", lambda x: x.quantile(0.25),
           lambda x: x.quantile(0.75)])
    .reset_index()
)
vol_stats.columns = ["date", "median", "p25", "p75"]

plot_vol = (
    ggplot(vol_stats, aes(x="date")) +
    geom_ribbon(aes(ymin="p25", ymax="p75"),
                alpha=0.3, fill="#b2182b") +
    geom_line(aes(y="median"), color="#b2182b", size=0.7) +
    labs(x="", y="Annualized 24-month volatility") +
    scale_y_continuous(labels=percent_format()) +
    theme_minimal() +
    theme(figure_size=(10, 5))
)
plot_vol.draw()
Chuỗi thời gian của phân phối theo mặt cắt ngang về độ biến động lợi nhuận cổ phiếu ở Việt Nam.
Hình 7.3: Phân phối theo mặt cắt ngang của độ biến động lợi nhuận cổ phiếu trượt 24 tháng, quy đổi theo năm, của các cổ phiếu Việt Nam. Độ biến động trung vị (đường liền) và khoảng tứ phân vị (dải tô bóng) phản ánh cả các xu hướng dài hạn lẫn các giai đoạn khủng hoảng. Cổ phiếu Việt Nam có độ biến động cao hơn hẳn về mặt cấu trúc so với các thị trường phát triển, với độ biến động trung vị quy đổi theo năm thường nằm trong khoảng 30% đến 50%.

7.9.2 Biến động và lợi nhuận kép: Sự hao hụt do phương sai

Như đã nêu trong Phương trình 7.7, lợi nhuận trung bình nhân thấp hơn trung bình cộng khoảng \(\sigma^2/2\). “Sự hao hụt do phương sai” hay “lực cản biến động” này có nghĩa là hai danh mục có cùng lợi nhuận trung bình cộng nhưng độ biến động khác nhau sẽ có lợi nhuận kép khác nhau: danh mục có độ biến động thấp hơn sẽ tăng trưởng kép đến mức tài sản cuối kỳ lớn hơn.

Hiệu ứng này có tầm quan trọng định lượng ở Việt Nam. Một cổ phiếu có lợi nhuận trung bình cộng hằng tháng là 1.5% và độ lệch chuẩn hằng tháng là 10% chịu lực cản biến động khoảng \(0.10^2/2 = 0.5\%\) mỗi tháng, tức xấp xỉ 6% mỗi năm. Điều này phù hợp với quan sát rằng nhà đầu tư Việt Nam chịu sự bào mòn đáng kể đối với mức tăng trưởng kép của tài sản do độ biến động đặc thù cao của từng cổ phiếu. Chúng ta có thể kiểm chứng thực nghiệm điều này bằng cách phân cổ phiếu thành các nhóm ngũ phân vị theo độ biến động và so sánh lợi nhuận kép:

Hiện mã
annual_data = compound_return_by_period(
    prices_monthly, period="year"
)
annual_data = annual_data[annual_data["n_obs"] >= 10].copy()

vol_annual = (
    prices_monthly
    .groupby(["symbol", prices_monthly["date"].dt.year])[
        "ret_total"
    ]
    .agg(["std", "mean", "count"])
    .reset_index()
)
vol_annual.columns = ["symbol", "period", "monthly_std",
                       "monthly_mean", "n_months"]
vol_annual = vol_annual[vol_annual["n_months"] >= 10].copy()
vol_annual["ann_vol"] = vol_annual["monthly_std"] * np.sqrt(12)
vol_annual["arith_mean_ann"] = vol_annual["monthly_mean"] * 12

vol_analysis = annual_data.merge(
    vol_annual, on=["symbol", "period"]
)

vol_analysis["vol_quintile"] = (
    vol_analysis.groupby("period")["ann_vol"]
    .transform(
        lambda x: pd.qcut(
            x, 5, labels=[1, 2, 3, 4, 5], duplicates="drop"
        )
    )
)

vol_summary = (
    vol_analysis
    .groupby("vol_quintile")
    .agg(
        arithmetic_mean=("arith_mean_ann", "mean"),
        geometric_mean=("cumret", "mean"),
        avg_volatility=("ann_vol", "mean"),
        n_stockyears=("cumret", "count")
    )
    .round(4)
    .reset_index()
)
vol_summary
Bảng 7.6: Trung bình cộng, trung bình nhân và độ biến động theo ngũ phân vị độ biến động của các cổ phiếu Việt Nam. Chênh lệch giữa trung bình cộng và trung bình nhân tăng theo độ biến động, xác nhận hiệu ứng hao hụt do phương sai. Lực cản biến động lớn rõ rệt ở nhóm có độ biến động cao nhất, điển hình của các cổ phiếu nhỏ và kém thanh khoản trên HNX và UPCoM.
vol_quintile arithmetic_mean geometric_mean avg_volatility n_stockyears
0 1 -0.0908 -0.0763 0.1887 2708
1 2 -0.0754 -0.0610 0.3312 2700
2 3 -0.0388 -0.0169 0.4493 2701
3 4 0.0404 0.0494 0.6005 2700
4 5 0.4411 0.3389 1.0288 2705

7.10 Lợi nhuận kép quanh thời điểm kết thúc năm tài chính

Một cách tiếp cận được dùng rộng rãi trong nghiên cứu kế toán và tài chính là căn lợi nhuận kép theo ngày kết thúc kỳ tài chính riêng của từng doanh nghiệp. Điều này thiết yếu để tính lợi nhuận bất thường mua và nắm giữ (BHAR) cho nghiên cứu sự kiện, trôi dạt lợi nhuận sau công bố kết quả kinh doanh (PEAD) và các nghiên cứu khác mà ngày xảy ra sự kiện khác nhau giữa các doanh nghiệp.

Ở Việt Nam, phần lớn doanh nghiệp niêm yết áp dụng năm tài chính theo năm dương lịch (tháng 1 đến tháng 12), theo yêu cầu của Luật Kế toán trừ khi Bộ Tài chính chấp thuận ngoại lệ. Tuy nhiên, các doanh nghiệp trong một số ngành (ví dụ nông nghiệp, du lịch) có thể dùng năm tài chính không chuẩn kết thúc vào tháng 3, tháng 6 hoặc tháng 9.

7.10.1 Căn lợi nhuận theo kỳ tài chính

Thách thức chính là ngày kết thúc năm tài chính khác nhau giữa các doanh nghiệp. Chúng ta cần tính lợi nhuận kép trên các cửa sổ neo vào những ngày riêng của từng doanh nghiệp này.

Hiện mã
def compound_returns_around_event(
    returns_df, events_df,
    id_col="symbol", date_col="date",
    event_date_col="datadate", ret_col="ret_total",
    pre_windows=[3, 6, 9, 12],
    post_windows=[3, 6]
):
    """Compute compound returns in windows around firm-specific
    event dates.

    Parameters
    ----------
    returns_df : pd.DataFrame
        Monthly returns with [id_col, date_col, ret_col].
    events_df : pd.DataFrame
        Event dates with [id_col, event_date_col].
    pre_windows : list of int
        Trailing window lengths (months before event).
    post_windows : list of int
        Forward window lengths (months after event).

    Returns
    -------
    pd.DataFrame with compound returns for each window.
    """
    returns_df = returns_df.sort_values(
        [id_col, date_col]
    ).copy()
    events_df = events_df.copy()

    # Align event dates to month ends
    events_df["event_month"] = (
        pd.to_datetime(events_df[event_date_col])
        + pd.offsets.MonthEnd(0)
    )

    results = []

    for _, event in events_df.iterrows():
        sid = event[id_col]
        edate = event["event_month"]

        sec_rets = returns_df[
            returns_df[id_col] == sid
        ].copy()
        sec_rets = sec_rets.set_index(date_col)[ret_col]

        row = {id_col: sid,
               event_date_col: event[event_date_col]}

        # Pre-event compound returns
        for k in pre_windows:
            start = edate - pd.DateOffset(months=k-1)
            start = (start - pd.offsets.MonthEnd(0)
                     + pd.offsets.MonthEnd(0))
            window_rets = sec_rets[
                (sec_rets.index >= start)
                & (sec_rets.index <= edate)
            ]
            if len(window_rets) >= k * 0.8:
                cumret = (
                    np.exp(np.log(1 + window_rets).sum()) - 1
                )
            else:
                cumret = np.nan
            row[f"ret_pre_{k}"] = cumret

        # Post-event compound returns
        for k in post_windows:
            start = edate + pd.DateOffset(months=1)
            end = (edate + pd.DateOffset(months=k)
                   + pd.offsets.MonthEnd(0))
            window_rets = sec_rets[
                (sec_rets.index >= start)
                & (sec_rets.index <= end)
            ]
            if len(window_rets) >= k * 0.8:
                cumret = (
                    np.exp(np.log(1 + window_rets).sum()) - 1
                )
            else:
                cumret = np.nan
            row[f"ret_post_{k}"] = cumret

        results.append(row)

    return pd.DataFrame(results)

7.10.2 Lợi nhuận bất thường mua và nắm giữ so với lợi nhuận bất thường tích lũy

Với nghiên cứu sự kiện và đánh giá hiệu quả đầu tư, chúng ta thường muốn có lợi nhuận kép vượt mức, tức là lợi nhuận kép của cổ phiếu trừ đi lợi nhuận kép của chuẩn so sánh trên cùng cửa sổ. Lợi nhuận bất thường mua và nắm giữ (BHAR) được định nghĩa là

\[ \text{BHAR}_{i,t}(k) = \prod_{j=1}^{k}(1 + R_{i,t+j}) - \prod_{j=1}^{k}(1 + R_{b,t+j}), \tag{7.14}\]

trong đó \(R_{b,t}\) là lợi nhuận của chuẩn so sánh (chỉ số thị trường, danh mục khớp theo quy mô, v.v.). Điều này khác với lợi nhuận bất thường tích lũy (CAR), vốn cộng các lợi nhuận bất thường đơn giản:

\[ \text{CAR}_{i,t}(k) = \sum_{j=1}^{k}(R_{i,t+j} - R_{b,t+j}). \tag{7.15}\]

BHAR phản ánh tốt hơn trải nghiệm thực tế của nhà đầu tư vì nó thể hiện việc ghép lãi của lợi nhuận, trong khi CAR ngầm giả định tái cân bằng hằng ngày để duy trì các vị thế giá trị bằng nhau trong cổ phiếu và chuẩn so sánh (Barber và Lyon 1997). Sự phân biệt này đặc biệt quan trọng ở Việt Nam, nơi lợi nhuận của từng cổ phiếu có thể biến động mạnh nên hiệu ứng ghép lãi bị khuếch đại. Lyon, Barber, và Tsai (1999) phân tích sâu hơn các tính chất thống kê của BHAR và khuyến nghị dùng giá trị tới hạn bootstrap cho suy luận thống kê.

Hiện mã
def compute_bhar(stock_returns, benchmark_returns):
    """Compute buy-and-hold abnormal return.

    Parameters
    ----------
    stock_returns : array-like
        Sequence of stock returns.
    benchmark_returns : array-like
        Sequence of benchmark returns (same length).

    Returns
    -------
    float : BHAR
    """
    stock_cumret = (
        np.prod(1 + np.array(stock_returns)) - 1
    )
    bench_cumret = (
        np.prod(1 + np.array(benchmark_returns)) - 1
    )
    return stock_cumret - bench_cumret

7.11 Giá trị sổ sách của vốn chủ sở hữu

Nhiều ứng dụng thực nghiệm dùng lợi nhuận kép cũng cần các biến kế toán cấp doanh nghiệp. Một biến thường dùng là giá trị sổ sách của vốn chủ sở hữu, được tính theo Daniel và Titman (1997):

\[ \text{BE} = \text{SE} + \text{DT} + \text{ITC} - \text{PS}, \tag{7.16}\]

trong đó SE là vốn chủ sở hữu, DT là thuế hoãn lại, ITC là tín dụng thuế đầu tư và PS là giá trị cổ phiếu ưu đãi. Với cổ phiếu ưu đãi, thứ tự ưu tiên là: giá trị mua lại nếu có, sau đó là giá trị thanh lý, rồi đến giá trị ghi sổ.

In Vietnam, the accounting standards (Vietnamese Accounting Standards, VAS, and increasingly IFRS adoption) provide a somewhat different chart of accounts. Stockholders’ equity is reported on the balance sheet as Vốn chủ sở hữu, which includes contributed capital (Vốn góp của chủ sở hữu), share premium (Thặng dư vốn cổ phần), treasury stock adjustments, retained earnings (Lợi nhuận sau thuế chưa phân phối), and other reserves. Deferred tax assets and liabilities are reported separately. Preferred stock is rare among Vietnamese listed firms (most issue only common shares), but when present, its book value should be subtracted from total equity.

Hiện mã
def compute_book_equity(df):
    """Compute book value of equity for Vietnamese firms.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain at minimum: equity (stockholders' equity),
        deferred_tax (deferred tax liabilities, net),
        pref_stock (preferred stock, if applicable).

    Returns
    -------
    pd.DataFrame with 'be' column.
    """
    df = df.copy()
    df["pref"] = df.get(
        "pref_stock", pd.Series(0, index=df.index)
    )
    df["dt"] = df.get(
        "deferred_tax", pd.Series(0, index=df.index)
    )
    df["be"] = (
        df["equity"].fillna(0)
        + df["dt"].fillna(0)
        - df["pref"].fillna(0)
    )
    # Set non-positive book equity to NaN
    df.loc[df["be"] <= 0, "be"] = np.nan
    return df

7.12 Mức sụt giảm tối đa

Mức sụt giảm tối đa là một thước đo rủi ro quan trọng bổ sung cho độ biến động. Trong khi độ biến động đo độ phân tán của lợi nhuận một cách đối xứng, mức sụt giảm tối đa nắm bắt khoản lỗ tích lũy tồi tệ nhất mà nhà đầu tư có thể phải chịu: một thước đo phù hợp hơn với cách nhà đầu tư cảm nhận rủi ro về mặt tâm lý (Kahneman và Tversky 2013).

Hiện mã
def compute_max_drawdown(df, ret_col="ret_total",
                          group_col="symbol"):
    """Compute maximum drawdown for each security.

    Parameters
    ----------
    df : pd.DataFrame
    ret_col : str
    group_col : str

    Returns
    -------
    pd.DataFrame with 'max_drawdown' and running drawdown.
    """
    df = df.sort_values([group_col, "date"]).copy()
    df["gross_ret"] = 1 + df[ret_col]
    df["wealth"] = (
        df.groupby(group_col)["gross_ret"].cumprod()
    )
    df["peak"] = df.groupby(group_col)["wealth"].cummax()
    df["drawdown"] = (
        (df["wealth"] - df["peak"]) / df["peak"]
    )

    max_dd = (
        df.groupby(group_col)["drawdown"]
        .min()
        .reset_index(name="max_drawdown")
    )
    df = df.merge(max_dd, on=group_col)
    df.drop(columns=["gross_ret"], inplace=True)
    return df

Hình 35.7 minh họa diễn biến sụt giảm của một cổ phiếu được chọn.

Hiện mã
dd_data = compute_max_drawdown(
    prices_monthly[
        prices_monthly["symbol"] == long_history_stocks[0]
    ]
)
mdd = dd_data["max_drawdown"].iloc[0]

plot_dd = (
    ggplot(dd_data, aes(x="date", y="drawdown")) +
    geom_area(fill="#b2182b", alpha=0.4) +
    geom_line(color="#b2182b", size=0.5) +
    geom_hline(yintercept=mdd, linetype="dashed") +
    labs(x="", y="Drawdown from peak") +
    scale_y_continuous(labels=percent_format()) +
    theme_minimal() +
    theme(figure_size=(10, 4))
)
plot_dd.draw()
Biểu đồ chuỗi thời gian về mức sụt giảm của một cổ phiếu Việt Nam.
Hình 7.4: Hồ sơ sụt giảm của một cổ phiếu Việt Nam được chọn, thể hiện mức giảm phần trăm so với đỉnh cao nhất tính đến từng thời điểm. Mức sụt giảm tối đa (đường nét đứt nằm ngang) biểu diễn khoản lỗ từ đỉnh xuống đáy tồi tệ nhất trong toàn bộ mẫu. Cổ phiếu Việt Nam thường xuyên có mức sụt giảm vượt 50%, phản ánh độ biến động cao của thị trường và tính dễ bị tổn thương trước các đợt điều chỉnh do tâm lý.

7.13 Tổng hợp lại: Một quy trình xử lý toàn diện

Bây giờ chúng ta kết hợp tất cả các phương pháp thành một quy trình duy nhất tạo ra bộ dữ liệu sẵn sàng cho nghiên cứu, với lợi nhuận kép trượt, lợi nhuận thị trường, độ biến động và các thước đo sụt giảm.

Hiện mã
def build_compound_return_dataset(
    stock_df, windows=[3, 6, 9, 12], vol_window=24
):
    """Build comprehensive compound return dataset.

    Parameters
    ----------
    stock_df : pd.DataFrame
        Monthly stock return data with columns:
        symbol, date, ret_total, mkt_total.
    windows : list of int
        Rolling compound return windows.
    vol_window : int
        Rolling volatility window.

    Returns
    -------
    pd.DataFrame
    """
    df = stock_df.sort_values(["symbol", "date"]).copy()

    # Step 1: Log returns
    df["log_ret"] = np.log(1 + df["ret_total"])
    df["log_mkt"] = np.log(1 + df["mkt_total"])

    # Step 2: Rolling compound returns (stock and market)
    for k in windows:
        df[f"ret_{k}"] = np.exp(
            df.groupby("symbol")["log_ret"]
            .transform(
                lambda x: x.rolling(k, min_periods=k).sum()
            )
        ) - 1

        df[f"mkt_{k}"] = np.exp(
            df["log_mkt"]
            .rolling(k, min_periods=k)
            .sum()
        ) - 1

        # Excess compound return (BHAR vs market)
        df[f"exret_{k}"] = df[f"ret_{k}"] - df[f"mkt_{k}"]

    # Step 3: Cumulative return (full history)
    df["wealth"] = (
        df.groupby("symbol")["log_ret"]
        .cumsum()
        .apply(np.exp)
    )
    df["cumret"] = df["wealth"] - 1

    # Step 4: Rolling volatility
    df[f"vol_{vol_window}"] = (
        df.groupby("symbol")["ret_total"]
        .transform(
            lambda x: x.rolling(
                vol_window, min_periods=vol_window
            ).std()
        )
    ) * np.sqrt(12)  # annualize

    # Step 5: Drawdown
    df["peak"] = df.groupby("symbol")["wealth"].cummax()
    df["drawdown"] = (df["wealth"] - df["peak"]) / df["peak"]

    # Clean up
    df.drop(
        columns=["log_ret", "log_mkt", "peak"], inplace=True
    )

    return df
Hiện mã
# Build the full dataset
compound_dataset = build_compound_return_dataset(prices_monthly)

Bảng 7.7 cung cấp thống kê mô tả cho các biến chính trong bộ dữ liệu lợi nhuận kép của chúng ta.

Hiện mã
summary_cols = ["ret_total", "ret_3", "ret_6", "ret_12",
                "exret_3", "exret_12", "vol_24", "drawdown"]
available_cols = [c for c in summary_cols
                  if c in compound_dataset.columns]

summary = (
    compound_dataset[available_cols]
    .describe(percentiles=[0.05, 0.25, 0.50, 0.75, 0.95])
    .T
    .round(4)
)
summary
Bảng 7.7: Thống kê mô tả các biến lợi nhuận kép trên toàn bộ các quan sát cổ phiếu-tháng của Việt Nam. Lợi nhuận ở dạng thập phân (0.10 = 10%). Độ phân tán rộng của lợi nhuận kép 12 tháng và độ biến động trung vị cao phản ánh đặc điểm thị trường mới nổi của thị trường cổ phiếu Việt Nam.
count mean std min 5% 25% 50% 75% 95% max
ret_total 165499.0 0.0042 0.1862 -0.9900 -0.2381 -0.0703 0.0000 0.0553 0.2773 12.7500
ret_3 162586.0 0.0094 0.3393 -0.9999 -0.3889 -0.1436 -0.0126 0.0987 0.5000 27.2911
ret_6 158227.0 0.0171 0.5053 -0.9999 -0.5095 -0.2196 -0.0400 0.1404 0.7320 35.7136
ret_12 149520.0 0.0375 0.8136 -0.9999 -0.6522 -0.3191 -0.0877 0.1807 1.0767 47.9515
exret_3 153637.0 0.0385 0.3343 -1.1691 -0.3420 -0.1163 0.0067 0.1378 0.4992 27.3041
exret_12 140571.0 0.1401 0.8031 -1.5858 -0.5388 -0.2003 0.0281 0.2880 1.1119 48.0488
vol_24 132233.0 0.5493 0.3488 0.0000 0.2070 0.3445 0.4827 0.6737 1.0739 9.1792
drawdown 165499.0 -0.5927 0.2975 -1.0000 -0.9631 -0.8501 -0.6616 -0.3725 0.0000 0.0000

7.14 Phân phối theo mặt cắt ngang của lợi nhuận kép

Để hiểu lợi nhuận kép khác nhau thế nào giữa các chứng khoán, chúng ta xem xét phân phối theo mặt cắt ngang ở các kỳ hạn khác nhau.

Hiện mã
horizon_data = pd.DataFrame()
for k in [3, 6, 12]:
    col = f"ret_{k}"
    temp = compound_dataset[[col]].dropna().copy()
    temp.columns = ["compound_return"]
    temp["horizon"] = f"{k} months"
    lo, hi = temp["compound_return"].quantile([0.01, 0.99])
    temp = temp[
        (temp["compound_return"] >= lo)
        & (temp["compound_return"] <= hi)
    ]
    horizon_data = pd.concat([horizon_data, temp])

plot_horizons = (
    ggplot(horizon_data,
           aes(x="compound_return", fill="horizon")) +
    geom_density(alpha=0.4) +
    geom_vline(xintercept=0, linetype="dashed") +
    labs(x="Compound return", y="Density", fill="Horizon") +
    scale_x_continuous(labels=percent_format()) +
    theme_minimal() +
    theme(legend_position="bottom",
          figure_size=(10, 5))
)
plot_horizons.draw()
Các biểu đồ mật độ chồng lên nhau của lợi nhuận kép ở các kỳ hạn 3, 6 và 12 tháng của các cổ phiếu Việt Nam.
Hình 7.5: Phân phối theo mặt cắt ngang của lợi nhuận kép ở các kỳ hạn khác nhau (3, 6 và 12 tháng) của các cổ phiếu Việt Nam. Kỳ hạn dài hơn có độ phân tán lớn hơn và độ lệch phải rõ rệt hơn, phản ánh việc ghép lãi của rủi ro đặc thù. Các đuôi dày cực đoan hơn so với thường thấy ở các thị trường phát triển, phù hợp với môi trường biến động cao hơn.

7.15 Những lưu ý riêng của Việt Nam

7.15.1 Biên độ giá và tác động lên việc tính lãi kép

Các sở giao dịch chứng khoán Việt Nam áp đặt biên độ giá hằng ngày, giới hạn mức thay đổi giá tối đa so với giá tham chiếu. Theo các quy định mới nhất:

  • HOSE: \(\pm 7\%\)
  • HNX: \(\pm 10\%\)
  • UPCoM: \(\pm 15\%\)

Các giới hạn này cắt cụt phân phối lợi nhuận hằng ngày và có thể tạo ra chuỗi các phiên chạm biên độ khi có sự kiện thông tin lớn. Với việc tính lợi nhuận kép, điều này có nghĩa là sự điều chỉnh theo thông tin mới có thể trải ra nhiều ngày thay vì diễn ra tức thời. Khi tính lợi nhuận kép hằng tháng từ dữ liệu hằng ngày, điều này được xử lý đúng vì lợi nhuận kép tích lũy toàn bộ mức điều chỉnh bất kể mất bao nhiêu ngày.

Tuy nhiên, biên độ giá có thể gây ra thiên lệch trong việc tính lợi nhuận ở kỳ hạn ngắn. Nếu một sự kiện tích cực lớn xảy ra và cổ phiếu chạm giá trần trong nhiều ngày liên tiếp, lợi nhuận kép 1 ngày hoặc 1 tuần sẽ đánh giá thấp nội dung thông tin thực sự của sự kiện (Kim, Liu, và Yang 2013). Với các ứng dụng nghiên cứu sự kiện, nhà nghiên cứu cần kiểm tra rằng cửa sổ sự kiện đủ dài để bao hàm độ trễ điều chỉnh giá do biên độ giá gây ra.

7.15.2 Giới hạn sở hữu nước ngoài

Việt Nam áp đặt giới hạn sở hữu nước ngoài (FOL) đối với các công ty niêm yết, thường tối đa 49% với hầu hết các ngành và thấp hơn (30% hoặc ít hơn) với một số lĩnh vực hạn chế như ngân hàng và viễn thông. Khi một cổ phiếu đã đạt FOL, nhà đầu tư nước ngoài chỉ có thể mua cổ phiếu từ những người bán là nhà đầu tư nước ngoài khác, tạo ra một thị trường song song có phần bù giá đối với cổ phiếu của khối ngoại. Điều này không trực tiếp ảnh hưởng đến việc tính lợi nhuận kép (vốn dùng giá giao dịch chính thức), nhưng các nhà nghiên cứu về lợi nhuận danh mục xuyên biên giới nên lưu ý rằng mức giá thực tế nhà đầu tư nước ngoài phải trả có thể khác với giá trên bảng giá (Vo 2017).

7.15.3 VN-Index và các chuẩn so sánh thị trường

Đối với lợi nhuận kép của chuẩn so sánh, các chỉ số chính của Việt Nam là:

  • VN-Index: Chỉ số trọng số theo vốn hóa của tất cả cổ phiếu niêm yết trên HOSE.
  • VN30: 30 cổ phiếu lớn nhất và thanh khoản nhất trên HOSE, được xem xét định kỳ sáu tháng một lần.
  • HNX-Index: Chỉ số trọng số theo vốn hóa của các cổ phiếu niêm yết trên HNX.

VN-Index là chuẩn so sánh được dùng rộng rãi nhất và là lợi nhuận thị trường mặc định trong bộ dữ liệu của chúng ta.

7.16 Các cân nhắc về hiệu năng

Khi làm việc với bộ dữ liệu lớn, hiệu quả tính toán rất quan trọng. Bảng 7.8 so sánh thời gian thực thi của bốn phương pháp tính lãi kép trên một bộ dữ liệu chuẩn hóa.

Hiện mã
import time

np.random.seed(42)
n_stocks = 100
n_months = 100
test_df = pd.DataFrame({
    "symbol": np.repeat(range(n_stocks), n_months),
    "date": np.tile(
        pd.date_range("2015-01-31", periods=n_months,
                       freq="ME"),
        n_stocks
    ),
    "ret_total": np.random.normal(
        0.01, 0.08, n_stocks * n_months
    )
})

methods = {}

t0 = time.time()
_ = compute_cumret_cumprod(test_df)
methods["Cumulative Product"] = time.time() - t0

t0 = time.time()
_ = compute_cumret_logsum(test_df)
methods["Log-Sum-Exp"] = time.time() - t0

t0 = time.time()
_ = compute_cumret_iterative(test_df)
methods["Iterative (carry)"] = time.time() - t0

t0 = time.time()
_ = rolling_compound_return(test_df, windows=[12])
methods["Rolling (12-month)"] = time.time() - t0

perf_df = pd.DataFrame({
    "Method": methods.keys(),
    "Time (seconds)": [f"{v:.4f}" for v in methods.values()],
    "Relative Speed": [
        f"{v/min(methods.values()):.1f}x"
        for v in methods.values()
    ]
})
perf_df
Bảng 7.8: So sánh thời gian thực thi của các phương pháp tính lãi kép khác nhau trên bộ dữ liệu 10,000 quan sát cổ phiếu-tháng. Các phương pháp tích lũy kế và log-sum-exp nhanh hơn nhiều bậc so với cách tiếp cận lặp nhờ vector hóa của NumPy.
Method Time (seconds) Relative Speed
0 Cumulative Product 0.0068 1.1x
1 Log-Sum-Exp 0.0064 1.0x
2 Iterative (carry) 0.4896 76.2x
3 Rolling (12-month) 0.0267 4.1x

7.17 Các lỗi thường gặp và thông lệ tốt nhất

Một số vấn đề tinh tế có thể dẫn đến tính sai lợi nhuận kép. Chúng ta tóm tắt những vấn đề quan trọng nhất:

Khoảng trống trong chuỗi thời gian. Nếu một chứng khoán có những tháng không có quan sát (thậm chí không có cả cờ đánh dấu lợi nhuận bị thiếu), các phép tính cửa sổ trượt dựa trên chỉ mục theo vị trí sẽ cho kết quả sai. Cửa sổ trượt sẽ bao phủ sai kỳ lịch. Hãy luôn bảo đảm chuỗi thời gian đầy đủ, điền các khoảng trống bằng giá trị thiếu tường minh trước khi tính các thống kê trượt. Điều này đặc biệt đáng lưu ý ở Việt Nam, nơi các đợt đình chỉ giao dịch có thể tạo ra khoảng trống.

Thiên kiến sống sót. Như đã thảo luận trong mục về lợi nhuận hủy niêm yết, việc loại bỏ các chứng khoán ngừng giao dịch làm lợi nhuận kép bị thiên lệch hướng lên. Hãy luôn đưa lợi nhuận hủy niêm yết vào khi có sẵn. Khi không có lợi nhuận hủy niêm yết (như đôi khi xảy ra với dữ liệu Việt Nam), hãy cân nhắc dùng các giá trị gán dựa trên lý do hủy niêm yết.

Thiên lệch nhìn trước. Khi căn lợi nhuận kép theo ngày kết thúc năm tài chính cho phân tích theo mặt cắt ngang, cần cẩn thận không dùng lợi nhuận trước ngày kết thúc năm tài chính để dự báo lợi nhuận sau công bố. Các doanh nghiệp Việt Nam phải công bố báo cáo tài chính năm đã kiểm toán trong vòng 90 ngày kể từ ngày kết thúc năm tài chính, do đó nên có khoảng đệm ít nhất 3 tháng khi xây dựng lợi nhuận kép hướng về phía trước.

Tràn số trên và tràn số dưới. Với các kỳ hạn ghép lãi rất dài hoặc lợi nhuận cực đoan, tích lũy kế có thể bị tràn số trên (inf) hoặc tràn số dưới (0). Phương pháp log-sum-exp vững hơn trước các vấn đề số học như vậy vì nó vận hành trong không gian logarit, nơi miền giá trị được nén lại.

Quy đổi theo năm của các kỳ không trọn vẹn. Khi tính lợi nhuận quy đổi theo năm từ dữ liệu của kỳ không trọn vẹn (ví dụ, 7 tháng dữ liệu quy đổi lên 12 tháng), công thức quy đổi \((1+R)^{12/k} - 1\) giả định rằng tốc độ lợi nhuận quan sát được sẽ tiếp tục duy trì. Giả định này càng mạnh với các kỳ không trọn vẹn ngắn và có thể cho kết quả gây hiểu lầm. Hãy báo cáo lợi nhuận kép thực tế và số kỳ cùng với mọi con số quy đổi theo năm.

Chuyển sàn. Ở Việt Nam, cổ phiếu đôi khi chuyển giữa UPCoM, HNX và HOSE. Những lần chuyển sàn này có thể kèm theo tạm ngừng giao dịch và có thể gây ra các khoảng trống biểu kiến trong chuỗi lợi nhuận. Khi tính lợi nhuận kép trải qua một lần chuyển sàn, hãy bảo đảm chuỗi lợi nhuận liên tục qua ngày chuyển sàn.