Quantitative Strategy Research & Backtesting
1 Overview
1.1 Research Objectives
The project addresses the following questions. Each one is answered by a dedicated section that is generated for every strategy and every asset in the configuration, so results can be read side by side.
| Research question | Where it is answered |
|---|---|
| Do simple momentum or mean-reversion signals exhibit persistent predictive behavior? | Section 5 |
| How much does strategy performance deteriorate after transaction costs? | Section 6 |
| Are observed returns statistically distinguishable from zero? | Section 7 |
| How stable are strategy parameters across time? | Section 8.3 |
| Does in-sample performance survive unseen market data? | Sections 8.2 and 8.3 |
| Do signals generalize across different asset classes? | Section 8.5 |
| How strongly does strategy performance depend on market regime? | Section 8.4 |
1.2 Tech Stack
- Language: R, R Markdown (
rmdformats::readthedown) - Data: Yahoo Finance daily prices via
yfR - Wrangling / plotting:
tidyverse(dplyr,tidyr,purrr,ggplot2) - Time-series tools:
zoo(rolling windows),tseries(Jarque-Bera)
1.3 How the Report Is Organised
| Part | Content | Changes when you add a new stock / strategy? |
|---|---|---|
| Configuration | Global settings, asset universe, strategy registry | Yes — the only place you edit |
| Framework | Data loader, backtest engine, metrics, tests | No |
| Analysis sections | Comparison, costs, statistics, robustness | No (tables and plots are regenerated) |
| Appendix | Theory and methodology | No |
2 Configuration
This is the only section that needs editing to add an asset, add a strategy, or change an assumption.
2.1 Global Settings
CFG <- list(
# --- Dữ liệu ---------------------------------------------------------
data_start = as.Date("2010-01-01"), # tải sớm hơn eval_start để có dữ liệu "khởi động" cho signal
data_end = Sys.Date(),
price_field = "price_adjusted", # hoặc "price_close" (bản nháp cũ dùng price_close)
# --- Cửa sổ đánh giá -------------------------------------------------
eval_start = as.Date("2010-01-01"), # mọi chiến lược được đánh giá trên CÙNG cửa sổ này
warmup_obs = 260, # >= (lookback/window lớn nhất trong mọi grid) + 1
# --- Giả định giao dịch ----------------------------------------------
trading_days = 252,
risk_free = 0,
cost_bps = 10, # chi phí trên mỗi 1 đơn vị turnover (10 bps = 0.10%)
cost_grid_bps = c(0, 5, 10, 20, 30, 50),# dùng cho phân tích độ nhạy chi phí
# --- Kiểm định ngoài mẫu ---------------------------------------------
train_end = as.Date("2024-12-31"), # hold-out: train <= train_end < test_start <= test
test_start = as.Date("2025-01-01"),
wf_years = 2011:2025, # các năm test của walk-forward
min_train_obs = 200, # số quan sát tối thiểu để chọn tham số
# --- Regime ----------------------------------------------------------
regime_ma = 200 # Bull nếu giá > MA(200) của ngày hôm trước
)
CFG$cost <- CFG$cost_bps / 1e42.2 Asset Universe
Thêm cổ phiếu / tài sản mới = thêm một dòng vào bảng dưới (nhớ dấu phẩy cuối dòng trước). Cột sector và asset_class được dùng để so sánh mức độ khái quát hóa giữa các nhóm (Section 8.5). Mã có lịch sử ngắn hơn vẫn được xử lý riêng, không làm cắt ngắn dữ liệu của các mã khác.
UNIVERSE <- tribble(
~ticker, ~yahoo, ~name, ~sector, ~asset_class,
"HPG", "HPG.VN", "Hoa Phat Group", "Steel", "Equity",
"FPT", "FPT.VN", "FPT Corp", "Technology", "Equity",
# Ví dụ thêm mã (bỏ comment, thêm dấu "," sau dòng NKG, kiểm tra symbol trên Yahoo Finance):
# "VNM", "VNM.VN", "Vinamilk", "Consumer Staples", "Equity",
# "FPT", "FPT.VN", "FPT Corp", "Technology", "Equity",
# "GOLD", "GC=F", "Gold futures", "Commodity", "Commodity"
)
kable(UNIVERSE, caption = "Asset universe")| ticker | yahoo | name | sector | asset_class |
|---|---|---|---|---|
| HPG | HPG.VN | Hoa Phat Group | Steel | Equity |
| FPT | FPT.VN | FPT Corp | Technology | Equity |
2.3 Strategy Registry
Mỗi chiến lược gồm hai phần:
- Hàm tín hiệu
sig_*(price, ...): nhận vector giá đóng cửa, trả về vector vị thế mục tiêu tại thời điểm đóng cửa (1= long,0= cash,-1= short). Không cần tự dịch (shift) vị thế, engine sẽ làm việc này. - Khai báo
strategy(...): tên hiển thị, tham số mặc định (params) và lưới tham số (grid) dùng cho các phân tích độ bền (robustness).
Các hàm hỗ trợ đăng ký (không cần chỉnh sửa):
# ---- Rolling helpers (an toàn khi chuỗi ngắn hơn cửa sổ) --------------------
roll_mean <- function(x, k) {
if (length(x) < k) return(rep(NA_real_, length(x)))
zoo::rollmeanr(x, k, fill = NA)
}
roll_sd <- function(x, k) {
if (length(x) < k) return(rep(NA_real_, length(x)))
zoo::rollapplyr(x, k, sd, fill = NA)
}
# ---- Khai báo chiến lược ----------------------------------------------------
strategy <- function(id, label, fn, params = list(), grid = NULL, family = "signal") {
if (is.null(grid)) grid <- params # không có grid => grid chỉ gồm tham số mặc định
stopifnot(is.function(fn), is.list(params), is.list(grid))
list(id = id, label = label, fn = fn, params = params, grid = grid, family = family)
}
register_strategies <- function(...) {
s <- list(...)
setNames(s, purrr::map_chr(s, "id"))
}
param_text <- function(p) paste(names(p), unlist(p), sep = "=", collapse = ", ")
param_label <- function(id, p) {
if (length(p) == 0) return(id)
paste0(id, "(", param_text(p), ")")
}
# Bảng "spec" = mỗi dòng là một (chiến lược + bộ tham số)
# use_grid = FALSE: chỉ tham số mặc định | TRUE: toàn bộ lưới tham số
make_specs <- function(strategies, use_grid = FALSE) {
purrr::map_dfr(strategies, function(s) {
param_sets <- if (use_grid && length(s$grid) > 0) {
purrr::pmap(do.call(tidyr::expand_grid, s$grid), list)
} else {
list(s$params)
}
tibble(
strategy = s$id,
label = s$label,
family = s$family,
spec = purrr::map_chr(param_sets, \(p) param_label(s$id, p)),
params = param_sets
)
})
}Hàm tín hiệu và khai báo chiến lược:
# ---- Hàm tín hiệu ---------------------------------------------------------
# Benchmark: luôn nắm giữ
sig_buy_hold <- function(price) rep(1, length(price))
# Momentum: long nếu lợi suất L phiên gần nhất > 0, ngược lại cash
sig_momentum <- function(price, lookback = 60) {
mom <- price / dplyr::lag(price, lookback) - 1
as.numeric(mom > 0)
}
# Mean reversion: z-score của giá so với trung bình/độ lệch chuẩn cuộn
# long khi z < -threshold, short khi z > +threshold, thoát khi z cắt qua 0
sig_mean_reversion <- function(price, window = 20, threshold = 2) {
z <- (price - roll_mean(price, window)) / roll_sd(price, window)
position <- numeric(length(z))
state <- 0
for (i in seq_along(z)) {
if (is.na(z[i])) {
position[i] <- 0
next
}
if (state == 0) {
if (z[i] < -threshold) {
state <- 1
} else if (z[i] > threshold) {
state <- -1
}
} else if (state == 1 && z[i] >= 0) {
state <- 0
} else if (state == -1 && z[i] <= 0) {
state <- 0
}
position[i] <- state
}
position
}
# ---- Đăng ký chiến lược ----------------------------------------------------
STRATEGIES <- register_strategies(
strategy(
id = "buy_hold", label = "Buy & Hold", family = "benchmark",
fn = sig_buy_hold
),
strategy(
id = "momentum", label = "Momentum", family = "trend",
fn = sig_momentum,
params = list(lookback = 60),
grid = list(lookback = c(20, 40, 60, 90, 120, 180, 252))
),
strategy(
id = "mean_reversion", label = "Mean Reversion", family = "mean-reversion",
fn = sig_mean_reversion,
params = list(window = 20, threshold = 2),
grid = list(window = c(10, 20, 40, 60), threshold = c(1.5, 2, 2.5))
)
# ---- MẪU: thêm chiến lược mới -------------------------------------------
# 1) Viết hàm tín hiệu (nhớ dùng roll_mean / roll_sd nếu cần cửa sổ cuộn):
# sig_ma_cross <- function(price, fast = 20, slow = 100) {
# as.numeric(roll_mean(price, fast) > roll_mean(price, slow))
# }
# 2) Thêm một khai báo vào danh sách (nhớ dấu phẩy):
# , strategy(
# id = "ma_cross", label = "MA Crossover", family = "trend",
# fn = sig_ma_cross,
# params = list(fast = 20, slow = 100),
# grid = list(fast = c(10, 20, 50), slow = c(100, 150, 200))
# )
# 3) Đảm bảo CFG$warmup_obs >= tham số lớn nhất + 1. Xong: mọi bảng/biểu đồ tự cập nhật.
)
# Bảng màu cố định theo chiến lược (Buy & Hold luôn màu xám)
STRAT_PAL <- setNames(
grDevices::hcl.colors(length(STRATEGIES), "Dark 3"),
purrr::map_chr(STRATEGIES, "label")
)
STRAT_PAL[purrr::map_chr(STRATEGIES, "family") == "benchmark"] <- "grey40"
strategy_table <- purrr::map_dfr(STRATEGIES, function(s) {
tibble(
Strategy = s$label, Family = s$family,
`Default parameters` = if (length(s$params)) param_text(s$params) else "-",
`Grid size` = nrow(make_specs(list(s), use_grid = TRUE))
)
})
kable(strategy_table, caption = "Strategy universe")| Strategy | Family | Default parameters | Grid size |
|---|---|---|---|
| Buy & Hold | benchmark | - | 1 |
| Momentum | trend | lookback=60 | 7 |
| Mean Reversion | mean-reversion | window=20, threshold=2 | 12 |
3 Framework
Phần hạ tầng dùng lại cho mọi chiến lược và mọi mã (data loader, backtest engine, chỉ số hiệu suất, kiểm định, công cụ ngoài mẫu). Không cần chỉnh sửa khi thêm chiến lược hoặc cổ phiếu.
3.1 Data Loader
# Đổi nguồn dữ liệu (CSV, API khác...) chỉ cần sửa hàm này, miễn là trả về: ticker, ref_date, price
load_prices <- function(universe, cfg = CFG) {
dir.create("data/yfr_cache", recursive = TRUE, showWarnings = FALSE)
raw <- yfR::yf_get(
tickers = universe$yahoo,
first_date = cfg$data_start,
last_date = cfg$data_end,
freq_data = "daily",
do_cache = TRUE,
cache_folder = "data/yfr_cache",
be_quiet = TRUE
)
prices <- raw |>
inner_join(transmute(universe, symbol = ticker, ticker = yahoo), by = "ticker") |>
transmute(ticker = symbol, ref_date, price = .data[[cfg$price_field]]) |>
drop_na(price) |>
arrange(ticker, ref_date)
missing <- setdiff(universe$ticker, unique(prices$ticker))
if (length(missing) > 0) {
warning("No data returned for: ", paste(missing, collapse = ", "))
}
prices
}3.2 Backtest Engine
Mọi chiến lược đi qua cùng một engine: tín hiệu tại đóng cửa phiên \(t\) chỉ được dùng để nắm giữ từ phiên \(t+1\), và chi phí tính trên mức thay đổi vị thế. Kết quả là một bảng dạng dài (ticker × strategy × ref_date) nên thêm mã hay chiến lược chỉ là thêm dòng.
backtest_one <- function(df, strat, params = strat$params, cost = CFG$cost, cfg = CFG) {
# df: một mã (ref_date, price), đã sắp xếp theo thời gian
signal <- do.call(strat$fn, c(list(price = df$price), params))
position <- dplyr::lag(signal) # dịch 1 phiên: không dùng thông tin ngày t để ăn lợi suất ngày t
position[is.na(position)] <- 0 # chưa đủ dữ liệu => cash
tibble(
ref_date = df$ref_date,
obs = seq_len(nrow(df)),
position = position,
ret_mkt = df$price / dplyr::lag(df$price) - 1
) |>
# cửa sổ đánh giá chung cho mọi chiến lược
filter(ref_date >= cfg$eval_start, obs > cfg$warmup_obs) |>
mutate(
turnover = abs(position - dplyr::lag(position, default = 0)), # bắt đầu từ cash
ret_gross = position * ret_mkt,
ret_net = ret_gross - turnover * cost
)
}
run_backtests <- function(prices, specs, strategies = STRATEGIES, cost = CFG$cost) {
purrr::imap_dfr(split(prices, prices$ticker), function(px, tk) {
purrr::map_dfr(seq_len(nrow(specs)), function(i) {
backtest_one(px, strategies[[specs$strategy[i]]], specs$params[[i]], cost) |>
mutate(
ticker = tk, strategy = specs$strategy[i], label = specs$label[i],
family = specs$family[i], spec = specs$spec[i], .before = 1
)
})
}) |>
mutate(label = factor(label, levels = names(STRAT_PAL)))
}3.3 Performance Metrics
PERF_KEYS <- c("ticker", "strategy", "label", "family")
perf_metrics <- function(r, turnover = NULL, position = NULL,
tdays = CFG$trading_days, rf = CFG$risk_free) {
ok <- is.finite(r)
r <- r[ok]
n <- length(r)
out <- list(n_obs = n, cum_return = NA_real_, ann_return = NA_real_, ann_vol = NA_real_,
sharpe = NA_real_, sortino = NA_real_, max_dd = NA_real_)
if (n >= 2) {
wealth <- cumprod(1 + r)
cum <- wealth[n] - 1
ann_ret <- (1 + cum)^(tdays / n) - 1
ann_vol <- sd(r) * sqrt(tdays)
down_dev <- sqrt(mean(pmin(r, 0)^2)) * sqrt(tdays)
out$cum_return <- cum
out$ann_return <- ann_ret
out$ann_vol <- ann_vol
out$sharpe <- if (ann_vol > 0) (ann_ret - rf) / ann_vol else NA_real_
out$sortino <- if (down_dev > 0) (ann_ret - rf) / down_dev else NA_real_
out$max_dd <- min(wealth / pmax(1, cummax(wealth)) - 1)
}
if (!is.null(turnover)) out$annual_turnover <- mean(turnover[ok]) * tdays
if (!is.null(position)) out$exposure <- mean(abs(position[ok]))
as_tibble(out)
}
# Tổng hợp chỉ số theo nhóm bất kỳ (mặc định: mã x chiến lược)
summarise_perf <- function(bt, ret_col = "ret_net", by = PERF_KEYS) {
bt |>
group_by(across(all_of(by))) |>
reframe(perf_metrics(.data[[ret_col]], turnover, position))
}
# Đường vốn & drawdown
add_wealth <- function(bt, ret_col = "ret_net", by = c("ticker", "strategy")) {
bt |>
arrange(ref_date) |>
group_by(across(all_of(by))) |>
mutate(
wealth = cumprod(1 + .data[[ret_col]]),
drawdown = wealth / pmax(1, cummax(wealth)) - 1
) |>
ungroup()
}
# Ma trận so sánh: hàng = chiến lược, cột = mã cổ phiếu
metric_matrix <- function(perf, metric, fmt = \(x) sprintf("%.2f", x)) {
perf |>
select(label, ticker, value = all_of(metric)) |>
mutate(value = fmt(value)) |>
pivot_wider(names_from = ticker, values_from = value) |>
arrange(label)
}
fmt_pct <- \(x) sprintf("%.1f%%", 100 * x)
fmt_perf <- function(df) {
df |>
mutate(
across(any_of(c("cum_return", "ann_return", "ann_vol", "max_dd", "exposure",
"return_drag", "test_return")), fmt_pct),
across(any_of(c("sharpe", "sortino", "annual_turnover", "sharpe_drag",
"train_sharpe", "test_sharpe", "sharpe_retention")), \(x) sprintf("%.2f", x))
)
}3.4 Statistical Tests
signif_stars <- function(p) {
as.character(cut(p, c(-Inf, 0.01, 0.05, 0.1, Inf), labels = c("***", "**", "*", "")))
}
# H0: lợi suất trung bình mỗi ngày = 0
t_test_stats <- function(r) {
r <- r[is.finite(r)]
tt <- tryCatch(t.test(r, mu = 0), error = function(e) NULL)
tibble(
mean_daily = mean(r),
t_stat = if (is.null(tt)) NA_real_ else unname(tt$statistic),
p_value = if (is.null(tt)) NA_real_ else tt$p.value
)
}
# Hồi quy OLS: r_strategy = alpha + beta * r_asset + e
alpha_beta <- function(r, m, tdays = CFG$trading_days) {
ok <- is.finite(r) & is.finite(m)
empty <- tibble(alpha_ann = NA_real_, alpha_p = NA_real_, beta = NA_real_, r_squared = NA_real_)
if (sum(ok) < 30 || sd(r[ok]) == 0) return(empty)
fit <- lm(y ~ x, data = data.frame(y = r[ok], x = m[ok]))
cf <- summary(fit)$coefficients
tibble(
alpha_ann = unname(coef(fit)[1]) * tdays,
alpha_p = cf[1, 4],
beta = unname(coef(fit)[2]),
r_squared = summary(fit)$r.squared
)
}
# Thống kê phân phối (không dùng package moments để giảm phụ thuộc)
skewness <- function(x) { x <- x[is.finite(x)]; mean((x - mean(x))^3) / mean((x - mean(x))^2)^1.5 }
excess_kurtosis <- function(x) { x <- x[is.finite(x)]; mean((x - mean(x))^4) / mean((x - mean(x))^2)^2 - 3 }
asset_diagnostics <- function(r, lag = 10) {
jb <- tseries::jarque.bera.test(r)
lb <- Box.test(r, lag = lag, type = "Ljung-Box")
tibble(
jb_stat = unname(jb$statistic), jb_p = jb$p.value,
lb_stat = unname(lb$statistic), lb_p = lb$p.value
)
}3.5 Out-of-Sample Machinery
Vì tín hiệu chỉ dùng thông tin quá khứ, backtest trên toàn bộ lịch sử rồi cắt theo ngày cho kết quả giống hệt chạy lại trên từng đoạn. Nhờ vậy lưới tham số chỉ cần chạy một lần, và hold-out / walk-forward chỉ là các phép lọc và chọn tham số trên bảng kết quả đó.
# Chọn bộ tham số có Sharpe (net) cao nhất trên dữ liệu <= end_date, theo từng (mã, chiến lược)
select_best <- function(grid_bt, end_date, min_obs = CFG$min_train_obs) {
grid_bt |>
filter(ref_date <= end_date) |>
group_by(ticker, strategy, spec) |>
filter(n() >= min_obs) |>
summarise(train_sharpe = perf_metrics(ret_net)$sharpe, .groups = "drop_last") |>
filter(is.finite(train_sharpe)) |>
slice_max(train_sharpe, n = 1, with_ties = FALSE) |>
ungroup()
}
# Walk-forward mở rộng: mỗi năm test dùng tham số chọn trên toàn bộ dữ liệu trước đó
walk_forward <- function(grid_bt, years) {
selection <- purrr::map_dfr(years, function(y) {
select_best(grid_bt, as.Date(paste0(y - 1, "-12-31"))) |> mutate(test_year = y)
})
returns <- grid_bt |>
mutate(test_year = lubridate::year(ref_date)) |>
inner_join(selection, by = c("ticker", "strategy", "spec", "test_year"))
list(selection = selection, returns = returns)
}
# Tổng hợp theo nhóm (sector / asset class): chiến lược có khái quát hóa không?
cross_section_summary <- function(perf, universe = UNIVERSE, group_col = "sector") {
bh <- perf |> filter(family == "benchmark") |> select(ticker, bh_sharpe = sharpe)
perf |>
filter(family != "benchmark") |>
left_join(bh, by = "ticker") |>
left_join(select(universe, ticker, all_of(group_col)), by = "ticker") |>
group_by(across(all_of(group_col)), label) |>
summarise(
n_assets = n(),
mean_sharpe = mean(sharpe, na.rm = TRUE),
median_sharpe = median(sharpe, na.rm = TRUE),
pct_positive = mean(sharpe > 0, na.rm = TRUE),
pct_beat_bh = mean(sharpe > bh_sharpe, na.rm = TRUE),
.groups = "drop"
)
}3.6 Plot Helpers
plot_lines <- function(df, y, title, ylab, log_y = FALSE, hline = NULL) {
p <- ggplot(df, aes(ref_date, .data[[y]], colour = label, linewidth = family == "benchmark")) +
geom_line() +
facet_wrap(~ticker, ncol = 2, scales = "free_y") +
scale_colour_manual(values = STRAT_PAL, name = NULL) +
scale_linewidth_manual(values = c("TRUE" = 0.9, "FALSE" = 0.5), guide = "none") +
labs(title = title, x = NULL, y = ylab)
if (!is.null(hline)) p <- p + geom_hline(yintercept = hline, colour = "grey60", linewidth = 0.3)
if (log_y) p <- p + scale_y_log10()
p
}4 Data
prices <- load_prices(UNIVERSE, CFG)
prices |>
group_by(ticker) |>
summarise(first_date = min(ref_date), last_date = max(ref_date), n_obs = n(), .groups = "drop") |>
left_join(select(UNIVERSE, ticker, name, sector, asset_class), by = "ticker") |>
kable(caption = "Data coverage")| ticker | first_date | last_date | n_obs | name | sector | asset_class |
|---|---|---|---|---|---|---|
| FPT | 2010-02-22 | 2026-09-21 | 4303 | FPT Corp | Technology | Equity |
| HPG | 2010-02-22 | 2026-09-21 | 4303 | Hoa Phat Group | Steel | Equity |
asset_ret <- prices |>
arrange(ticker, ref_date) |>
group_by(ticker) |>
mutate(ret = price / dplyr::lag(price) - 1) |>
ungroup() |>
filter(ref_date >= CFG$eval_start, is.finite(ret))4.1 Price History
Giá được chuẩn hóa về 100 tại ngày đầu tiên của mỗi mã trong cửa sổ đánh giá.
prices |>
filter(ref_date >= CFG$eval_start) |>
group_by(ticker) |>
mutate(index = price / first(price) * 100) |>
ungroup() |>
ggplot(aes(ref_date, index, colour = ticker)) +
geom_line() +
scale_y_log10() +
labs(title = "Normalized prices (base = 100, log scale)", x = NULL, y = "Index", colour = NULL)4.2 Return Distribution
asset_stats <- asset_ret |>
group_by(ticker) |>
reframe(
select(perf_metrics(ret), n_obs, ann_return, ann_vol, sharpe, max_dd),
skewness = skewness(ret),
excess_kurtosis = excess_kurtosis(ret),
min_daily = min(ret),
max_daily = max(ret)
)
asset_stats |>
mutate(across(c(ann_return, ann_vol, max_dd, min_daily, max_daily), fmt_pct),
across(c(sharpe, skewness, excess_kurtosis), \(x) sprintf("%.2f", x))) |>
kable(caption = "Buy & Hold descriptive statistics (Sharpe with zero risk-free rate)")| ticker | n_obs | ann_return | ann_vol | sharpe | max_dd | skewness | excess_kurtosis | min_daily | max_daily |
|---|---|---|---|---|---|---|---|---|---|
| FPT | 4302 | 14.6% | 26.7% | 0.55 | -52.5% | -0.60 | 14.35 | -25.1% | 9.3% |
| HPG | 4302 | 14.6% | 33.3% | 0.44 | -76.4% | -0.54 | 11.39 | -30.0% | 12.1% |
ggplot(asset_ret, aes(ret)) +
geom_histogram(aes(y = after_stat(density)), bins = 80, fill = "lightblue", colour = NA) +
facet_wrap(~ticker, ncol = 2, scales = "free") +
labs(title = "Daily return distribution", x = "Simple return", y = "Density")4.3 Normality and Autocorrelation
Jarque-Bera kiểm tra tính chuẩn; Ljung-Box (10 lag) kiểm tra tự tương quan của lợi suất. Tự tương quan có ý nghĩa là điều kiện cần để momentum / mean reversion có cơ sở thống kê.
asset_ret |>
group_by(ticker) |>
reframe(asset_diagnostics(ret)) |>
mutate(across(c(jb_stat, lb_stat), \(x) round(x, 2)),
across(c(jb_p, lb_p), \(x) signif(x, 3))) |>
kable(caption = "Jarque-Bera and Ljung-Box tests on daily returns")| ticker | jb_stat | jb_p | lb_stat | lb_p |
|---|---|---|---|---|
| FPT | 37155.82 | 0 | 9.68 | 0.469 |
| HPG | 23455.85 | 0 | 16.13 | 0.096 |
acf_tbl <- asset_ret |>
group_by(ticker) |>
reframe(
lag = 1:10,
acf = as.numeric(acf(ret, lag.max = 10, plot = FALSE)$acf)[-1],
ci = 1.96 / sqrt(n())
)
ggplot(acf_tbl, aes(lag, acf)) +
geom_col(fill = "steelblue", width = 0.6) +
geom_line(aes(y = ci), linetype = "dashed", colour = "firebrick") +
geom_line(aes(y = -ci), linetype = "dashed", colour = "firebrick") +
facet_wrap(~ticker, ncol = 2) +
scale_x_continuous(breaks = 1:10) +
labs(title = "Autocorrelation of daily returns (95% bounds)", x = "Lag", y = "ACF")5 Strategy Comparison
Tất cả chiến lược đăng ký ở phần Configuration được chạy với tham số mặc định, trên cùng cửa sổ đánh giá, cùng giả định chi phí.
specs_default <- make_specs(STRATEGIES, use_grid = FALSE)
bt <- run_backtests(prices, specs_default)
scoreboard <- bind_rows(
gross = summarise_perf(bt, "ret_gross"),
net = summarise_perf(bt, "ret_net"),
.id = "basis"
)
scoreboard_net <- filter(scoreboard, basis == "net")5.1 Scoreboard
scoreboard_net |>
arrange(ticker, label) |>
select(ticker, label, n_obs, cum_return, ann_return, ann_vol, sharpe, sortino,
max_dd, annual_turnover, exposure) |>
fmt_perf() |>
kable(caption = sprintf("Net performance at %d bps per unit of turnover", CFG$cost_bps))| ticker | label | n_obs | cum_return | ann_return | ann_vol | sharpe | sortino | max_dd | annual_turnover | exposure |
|---|---|---|---|---|---|---|---|---|---|---|
| FPT | Buy & Hold | 4043 | 1378.0% | 18.3% | 25.8% | 0.71 | 1.07 | -52.1% | 0.06 | 100.0% |
| FPT | Momentum | 4043 | 586.4% | 12.8% | 19.7% | 0.65 | 1.00 | -35.9% | 12.78 | 64.8% |
| FPT | Mean Reversion | 4043 | -53.1% | -4.6% | 20.7% | -0.22 | -0.31 | -67.0% | 17.58 | 56.9% |
| HPG | Buy & Hold | 4043 | 1624.9% | 19.4% | 32.8% | 0.59 | 0.89 | -72.3% | 0.06 | 100.0% |
| HPG | Momentum | 4043 | 1894.5% | 20.5% | 24.1% | 0.85 | 1.32 | -45.0% | 13.84 | 58.6% |
| HPG | Mean Reversion | 4043 | -95.2% | -17.3% | 26.4% | -0.65 | -0.89 | -97.1% | 16.21 | 58.2% |
5.2 Side-by-Side Matrices
Hàng là chiến lược, cột là cổ phiếu: đọc ngang để so sánh cổ phiếu, đọc dọc để so sánh chiến lược.
| label | FPT | HPG |
|---|---|---|
| Buy & Hold | 0.71 | 0.59 |
| Momentum | 0.65 | 0.85 |
| Mean Reversion | -0.22 | -0.65 |
| label | FPT | HPG |
|---|---|---|
| Buy & Hold | 18.3% | 19.4% |
| Momentum | 12.8% | 20.5% |
| Mean Reversion | -4.6% | -17.3% |
| label | FPT | HPG |
|---|---|---|
| Buy & Hold | -52.1% | -72.3% |
| Momentum | -35.9% | -45.0% |
| Mean Reversion | -67.0% | -97.1% |
kable(metric_matrix(scoreboard_net, "annual_turnover"), caption = "Annual turnover (units of position)")| label | FPT | HPG |
|---|---|---|
| Buy & Hold | 0.06 | 0.06 |
| Momentum | 12.78 | 13.84 |
| Mean Reversion | 17.58 | 16.21 |
ggplot(scoreboard_net, aes(label, ticker, fill = sharpe)) +
geom_tile(colour = "white") +
geom_text(aes(label = sprintf("%.2f", sharpe))) +
scale_fill_gradient2(low = "firebrick", mid = "white", high = "steelblue", midpoint = 0) +
labs(title = "Net Sharpe ratio heatmap", x = NULL, y = NULL, fill = "Sharpe")scoreboard_net |>
pivot_longer(c(ann_return, ann_vol, sharpe, max_dd), names_to = "metric") |>
mutate(metric = factor(metric,
levels = c("ann_return", "ann_vol", "sharpe", "max_dd"),
labels = c("Ann. return", "Ann. volatility", "Sharpe", "Max drawdown"))) |>
ggplot(aes(label, value, fill = label)) +
geom_col() +
facet_grid(metric ~ ticker, scales = "free_y") +
scale_fill_manual(values = STRAT_PAL, name = NULL) +
labs(title = "Net metrics by asset and strategy", x = NULL, y = NULL) +
theme(axis.text.x = element_blank())6 Transaction Costs
6.1 Gross vs Net
cost_drag <- scoreboard |>
select(basis, ticker, label, family, ann_return, sharpe) |>
pivot_wider(names_from = basis, values_from = c(ann_return, sharpe)) |>
mutate(return_drag = ann_return_gross - ann_return_net,
sharpe_drag = sharpe_gross - sharpe_net) |>
left_join(select(scoreboard_net, ticker, label, annual_turnover), by = c("ticker", "label")) |>
arrange(ticker, label) |>
select(ticker, label, annual_turnover, ann_return_gross, ann_return_net, return_drag,
sharpe_gross, sharpe_net, sharpe_drag)
cost_drag |>
mutate(across(c(ann_return_gross, ann_return_net, return_drag), fmt_pct),
across(c(annual_turnover, sharpe_gross, sharpe_net, sharpe_drag), \(x) sprintf("%.2f", x))) |>
kable(caption = "Cost drag: gross minus net")| ticker | label | annual_turnover | ann_return_gross | ann_return_net | return_drag | sharpe_gross | sharpe_net | sharpe_drag |
|---|---|---|---|---|---|---|---|---|
| FPT | Buy & Hold | 0.06 | 18.3% | 18.3% | 0.0% | 0.71 | 0.71 | 0.00 |
| FPT | Momentum | 12.78 | 14.2% | 12.8% | 1.5% | 0.72 | 0.65 | 0.07 |
| FPT | Mean Reversion | 17.58 | -2.9% | -4.6% | 1.7% | -0.14 | -0.22 | 0.08 |
| HPG | Buy & Hold | 0.06 | 19.4% | 19.4% | 0.0% | 0.59 | 0.59 | 0.00 |
| HPG | Momentum | 13.84 | 22.2% | 20.5% | 1.7% | 0.92 | 0.85 | 0.07 |
| HPG | Mean Reversion | 16.21 | -15.9% | -17.3% | 1.4% | -0.60 | -0.65 | 0.05 |
6.2 Cost Sensitivity
Vị thế và turnover không phụ thuộc vào chi phí, nên có thể tính lại lợi suất net cho nhiều mức chi phí mà không cần chạy lại tín hiệu.
cost_sens <- purrr::map_dfr(CFG$cost_grid_bps, function(b) {
bt |>
mutate(ret_net = ret_gross - turnover * b / 1e4) |>
summarise_perf("ret_net") |>
mutate(cost_bps = b)
})
ggplot(cost_sens, aes(cost_bps, sharpe, colour = label, linetype = family == "benchmark")) +
geom_line() +
geom_point() +
geom_hline(yintercept = 0, colour = "grey60", linewidth = 0.3) +
facet_wrap(~ticker, ncol = 2) +
scale_colour_manual(values = STRAT_PAL, name = NULL) +
scale_linetype_manual(values = c("TRUE" = "dashed", "FALSE" = "solid"), guide = "none") +
labs(title = "Sharpe ratio vs transaction cost", x = "Cost per unit of turnover (bps)", y = "Net Sharpe")# Mức chi phí mà tại đó Sharpe (net) của mỗi chiến lược vẫn lớn hơn Buy & Hold cùng mức chi phí
cost_sens |>
select(ticker, label, family, cost_bps, sharpe) |>
group_by(ticker, cost_bps) |>
mutate(bh_sharpe = sharpe[family == "benchmark"][1]) |>
ungroup() |>
filter(family != "benchmark") |>
mutate(beats_bh = sharpe > bh_sharpe) |>
group_by(ticker, label) |>
summarise(
`Highest cost still beating Buy & Hold (bps)` =
if (any(beats_bh, na.rm = TRUE)) max(cost_bps[beats_bh], na.rm = TRUE) else NA_real_,
.groups = "drop"
) |>
kable(caption = "Cost tolerance (NA = never beats Buy & Hold in the tested cost range)")| ticker | label | Highest cost still beating Buy & Hold (bps) |
|---|---|---|
| FPT | Momentum | 0 |
| FPT | Mean Reversion | NA |
| HPG | Momentum | 30 |
| HPG | Mean Reversion | NA |
7 Statistical Significance
7.1 Are Mean Returns Different from Zero?
Kiểm định t một mẫu trên lợi suất net hằng ngày (bao gồm cả những ngày đứng ngoài thị trường).
ttest_tbl <- bt |>
group_by(across(all_of(PERF_KEYS))) |>
reframe(t_test_stats(ret_net)) |>
mutate(signif = signif_stars(p_value)) |>
arrange(ticker, label)
ttest_tbl |>
transmute(ticker, label,
`Mean daily return` = sprintf("%.4f%%", 100 * mean_daily),
`t-stat` = round(t_stat, 2),
`p-value` = signif(p_value, 3),
signif) |>
kable(caption = "One-sample t-test, H0: mean daily net return = 0 (*** p<0.01, ** p<0.05, * p<0.1)")| ticker | label | Mean daily return | t-stat | p-value | signif |
|---|---|---|---|---|---|
| FPT | Buy & Hold | 0.0798% | 3.12 | 0.001820 | *** |
| FPT | Momentum | 0.0554% | 2.83 | 0.004680 | *** |
| FPT | Mean Reversion | -0.0102% | -0.50 | 0.618000 | |
| HPG | Buy & Hold | 0.0918% | 2.82 | 0.004800 | *** |
| HPG | Momentum | 0.0855% | 3.58 | 0.000345 | *** |
| HPG | Mean Reversion | -0.0614% | -2.34 | 0.019100 | ** |
7.2 Alpha and Beta
Hồi quy lợi suất net của chiến lược lên lợi suất của chính tài sản đó. Beta phản ánh mức độ phơi nhiễm thị trường (exposure), alpha là phần lợi suất còn lại sau khi loại bỏ phơi nhiễm.
alpha_tbl <- bt |>
filter(family != "benchmark") |>
group_by(across(all_of(PERF_KEYS))) |>
reframe(alpha_beta(ret_net, ret_mkt)) |>
mutate(signif = signif_stars(alpha_p)) |>
arrange(ticker, label)
alpha_tbl |>
transmute(ticker, label,
`Alpha (ann.)` = fmt_pct(alpha_ann),
`Alpha p-value` = signif(alpha_p, 3),
signif,
Beta = round(beta, 3),
`R-squared` = round(r_squared, 3)) |>
kable(caption = "OLS: strategy return = alpha + beta x asset return")| ticker | label | Alpha (ann.) | Alpha p-value | signif | Beta | R-squared |
|---|---|---|---|---|---|---|
| FPT | Momentum | 2.2% | 0.4900 | 0.584 | 0.583 | |
| FPT | Mean Reversion | -2.7% | 0.6040 | 0.006 | 0.000 | |
| HPG | Momentum | 9.1% | 0.0264 | ** | 0.539 | 0.538 |
| HPG | Mean Reversion | -16.2% | 0.0144 | ** | 0.030 | 0.001 |
8 Robustness
Từ đây các chiến lược được chạy trên toàn bộ lưới tham số để kiểm tra mức độ ổn định và nguy cơ overfitting.
grid_specs <- make_specs(STRATEGIES, use_grid = TRUE)
grid_bt <- run_backtests(prices, grid_specs)
grid_specs |> count(label, name = "n_parameter_sets") |> kable()| label | n_parameter_sets |
|---|---|
| Buy & Hold | 1 |
| Mean Reversion | 12 |
| Momentum | 7 |
8.1 Parameter Sensitivity
Sharpe (net) của từng bộ tham số trên giai đoạn train (<= train_end). Vùng ổn định (nhiều tham số lân cận cho kết quả tương tự) đáng tin hơn một đỉnh nhọn đơn lẻ.
sensitivity <- grid_bt |>
filter(ref_date <= CFG$train_end) |>
summarise_perf("ret_net", by = c(PERF_KEYS, "spec")) |>
left_join(select(grid_specs, strategy, spec, params), by = c("strategy", "spec"))
plot_sensitivity <- function(id) {
s <- STRATEGIES[[id]]
pn <- names(s$grid)[lengths(s$grid) > 1] # các tham số thực sự thay đổi
d <- sensitivity |> filter(strategy == id) |> tidyr::unnest_wider(params)
if (length(pn) == 1) {
ggplot(d, aes(.data[[pn]], sharpe)) +
geom_hline(yintercept = 0, linetype = "dashed") +
geom_line() + geom_point() +
facet_wrap(~ticker, ncol = 2) +
labs(title = paste(s$label, "- parameter sensitivity (train period)"), x = pn, y = "Net Sharpe")
} else if (length(pn) == 2) {
ggplot(d, aes(factor(.data[[pn[1]]]), factor(.data[[pn[2]]]), fill = sharpe)) +
geom_tile(colour = "white") +
geom_text(aes(label = sprintf("%.2f", sharpe)), size = 3) +
scale_fill_gradient2(low = "firebrick", mid = "white", high = "steelblue", midpoint = 0) +
facet_wrap(~ticker, ncol = 2) +
labs(title = paste(s$label, "- parameter sensitivity (train period)"),
x = pn[1], y = pn[2], fill = "Sharpe")
} else {
NULL # >2 tham số: xem bảng `sensitivity`
}
}
tunable <- names(Filter(\(s) any(lengths(s$grid) > 1), STRATEGIES))8.2 Hold-out Validation
Tham số được chọn chỉ dựa trên giai đoạn train rồi áp dụng nguyên trạng lên giai đoạn test chưa từng thấy.
best_train <- select_best(grid_bt, CFG$train_end)
holdout_bt <- grid_bt |>
inner_join(select(best_train, ticker, strategy, spec), by = c("ticker", "strategy", "spec"))
perf_train <- holdout_bt |>
filter(ref_date <= CFG$train_end) |>
summarise_perf("ret_net", by = c(PERF_KEYS, "spec")) |>
select(all_of(PERF_KEYS), spec, train_sharpe = sharpe)
perf_test <- holdout_bt |>
filter(ref_date >= CFG$test_start) |>
summarise_perf("ret_net", by = c(PERF_KEYS, "spec")) |>
select(all_of(PERF_KEYS), spec, test_return = ann_return, test_sharpe = sharpe, max_dd)
holdout_tbl <- perf_train |>
inner_join(perf_test, by = c(PERF_KEYS, "spec")) |>
mutate(sharpe_retention = if_else(train_sharpe > 0, test_sharpe / train_sharpe, NA_real_)) |>
arrange(ticker, label)
holdout_tbl |>
select(ticker, label, spec, train_sharpe, test_sharpe, sharpe_retention, test_return, max_dd) |>
fmt_perf() |>
kable(caption = "Train vs test (parameters selected on train only; retention = test Sharpe / train Sharpe)")| ticker | label | spec | train_sharpe | test_sharpe | sharpe_retention | test_return | max_dd |
|---|---|---|---|---|---|---|---|
| FPT | Buy & Hold | buy_hold | 1.03 | -0.89 | -0.86 | -27.9% | -52.1% |
| FPT | Momentum | momentum(lookback=40) | 1.04 | -1.23 | -1.18 | -18.2% | -31.2% |
| FPT | Mean Reversion | mean_reversion(window=10, threshold=2.5) | 0.07 | 0.10 | 1.48 | 0.8% | -10.0% |
| HPG | Buy & Hold | buy_hold | 0.65 | 0.13 | 0.19 | 3.6% | -25.1% |
| HPG | Momentum | momentum(lookback=20) | 1.16 | -0.41 | -0.36 | -7.6% | -27.5% |
| HPG | Mean Reversion | mean_reversion(window=10, threshold=2.5) | -0.08 | -0.43 | NA | -6.6% | -20.7% |
holdout_bt |>
filter(ref_date >= CFG$test_start) |>
add_wealth("ret_net") |>
plot_lines("wealth", "Out-of-sample growth of 1 unit (hold-out period)", "Wealth", hline = 1)8.3 Walk-Forward Validation
Mỗi năm test dùng tham số được chọn trên toàn bộ dữ liệu trước năm đó (cửa sổ mở rộng). Chuỗi lợi suất ngoài mẫu được nối lại thành một đường vốn duy nhất.
wf <- walk_forward(grid_bt, CFG$wf_years)
wf_perf <- wf$returns |> summarise_perf("ret_net")
wf_perf |>
arrange(ticker, label) |>
select(ticker, label, n_obs, cum_return, ann_return, ann_vol, sharpe, sortino, max_dd, annual_turnover) |>
fmt_perf() |>
kable(caption = sprintf("Walk-forward out-of-sample performance (%d-%d)",
min(CFG$wf_years), max(CFG$wf_years)))| ticker | label | n_obs | cum_return | ann_return | ann_vol | sharpe | sortino | max_dd | annual_turnover |
|---|---|---|---|---|---|---|---|---|---|
| FPT | Buy & Hold | 3630 | 2175.6% | 24.2% | 25.2% | 0.96 | 1.46 | -36.9% | 0.00 |
| FPT | Momentum | 3630 | 1023.5% | 18.3% | 19.3% | 0.95 | 1.50 | -38.4% | 12.57 |
| FPT | Mean Reversion | 3630 | -62.7% | -6.6% | 13.4% | -0.50 | -0.66 | -63.9% | 6.18 |
| HPG | Buy & Hold | 3630 | 3900.7% | 29.2% | 32.8% | 0.89 | 1.35 | -72.3% | 0.00 |
| HPG | Momentum | 3630 | 3097.0% | 27.2% | 24.8% | 1.10 | 1.73 | -36.7% | 16.45 |
| HPG | Mean Reversion | 3630 | -54.5% | -5.3% | 18.9% | -0.28 | -0.40 | -80.6% | 7.78 |
kable(metric_matrix(wf_perf, "sharpe"), caption = "Walk-forward Sharpe ratio (compare with the full-sample matrix above)")| label | FPT | HPG |
|---|---|---|
| Buy & Hold | 0.96 | 0.89 |
| Momentum | 0.95 | 1.10 |
| Mean Reversion | -0.50 | -0.28 |
wf$returns |>
add_wealth("ret_net") |>
plot_lines("wealth", "Walk-forward out-of-sample growth of 1 unit", "Wealth", hline = 1)8.3.1 Annual Out-of-Sample Returns
wf_year <- wf$returns |>
summarise_perf("ret_net", by = c(PERF_KEYS, "test_year"))
ggplot(wf_year, aes(factor(test_year), cum_return, fill = label)) +
geom_col(position = "dodge") +
geom_hline(yintercept = 0, linetype = "dashed") +
facet_wrap(~ticker, ncol = 2) +
scale_fill_manual(values = STRAT_PAL, name = NULL) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Walk-forward: annual out-of-sample return", x = "Test year", y = "Return")8.3.2 Parameter Stability
Tham số được chọn thay đổi liên tục giữa các năm là dấu hiệu tín hiệu không ổn định (hoặc đang khớp nhiễu).
param_txt <- \(spec) if_else(grepl("\\(", spec), sub("^[^(]*\\((.*)\\)$", "\\1", spec), "-")
wf$selection |>
left_join(distinct(grid_specs, strategy, label, family), by = "strategy") |>
filter(family != "benchmark") |>
mutate(selected = param_txt(spec)) |>
select(ticker, label, test_year, selected) |>
pivot_wider(names_from = test_year, values_from = selected) |>
arrange(ticker, label) |>
kable(caption = "Parameters selected each year")| ticker | label | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| FPT | Mean Reversion | window=60, threshold=2.5 | window=10, threshold=2.5 | window=60, threshold=2.5 | window=60, threshold=2.5 | window=60, threshold=2 | window=60, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 |
| FPT | Momentum | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 | lookback=40 |
| HPG | Mean Reversion | window=60, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=20, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 | window=10, threshold=2.5 |
| HPG | Momentum | lookback=60 | lookback=60 | lookback=252 | lookback=60 | lookback=60 | lookback=60 | lookback=60 | lookback=60 | lookback=60 | lookback=20 | lookback=20 | lookback=20 | lookback=20 | lookback=20 |
wf$selection |>
left_join(distinct(grid_specs, strategy, label, family), by = "strategy") |>
filter(family != "benchmark") |>
mutate(selected = param_txt(spec)) |>
count(ticker, label, selected, name = "years_selected") |>
arrange(ticker, label, desc(years_selected)) |>
kable(caption = "Selection frequency")| ticker | label | selected | years_selected |
|---|---|---|---|
| FPT | Mean Reversion | window=10, threshold=2.5 | 9 |
| FPT | Mean Reversion | window=60, threshold=2.5 | 4 |
| FPT | Mean Reversion | window=60, threshold=2 | 1 |
| FPT | Momentum | lookback=40 | 14 |
| HPG | Mean Reversion | window=20, threshold=2.5 | 9 |
| HPG | Mean Reversion | window=10, threshold=2.5 | 4 |
| HPG | Mean Reversion | window=60, threshold=2.5 | 1 |
| HPG | Momentum | lookback=60 | 8 |
| HPG | Momentum | lookback=20 | 5 |
| HPG | Momentum | lookback=252 | 1 |
8.4 Market Regime
Regime được xác định bằng vị trí của giá so với MA(200) của phiên trước để không dùng thông tin cùng ngày.
regimes <- prices |>
arrange(ticker, ref_date) |>
group_by(ticker) |>
mutate(ma = roll_mean(price, CFG$regime_ma),
regime = if_else(dplyr::lag(price) > dplyr::lag(ma), "Bull", "Bear")) |>
ungroup() |>
select(ticker, ref_date, regime)
regime_perf <- bt |>
inner_join(regimes, by = c("ticker", "ref_date")) |>
filter(!is.na(regime)) |>
group_by(across(all_of(c(PERF_KEYS, "regime")))) |>
reframe(
perf_metrics(ret_net, turnover, position),
mean_daily = mean(ret_net),
hit_rate = mean(ret_net > 0)
)
regime_perf |>
arrange(ticker, label, desc(regime)) |>
transmute(ticker, label, regime, days = n_obs,
`Mean daily` = sprintf("%.3f%%", 100 * mean_daily),
`Cumulative` = fmt_pct(cum_return),
`Hit rate` = fmt_pct(hit_rate),
Exposure = fmt_pct(exposure)) |>
kable(caption = "Net performance by regime")| ticker | label | regime | days | Mean daily | Cumulative | Hit rate | Exposure |
|---|---|---|---|---|---|---|---|
| FPT | Buy & Hold | Bull | 2691 | 0.094% | 808.4% | 44.4% | 100.0% |
| FPT | Buy & Hold | Bear | 1352 | 0.053% | 62.7% | 41.3% | 100.0% |
| FPT | Momentum | Bull | 2691 | 0.079% | 545.0% | 36.5% | 83.4% |
| FPT | Momentum | Bear | 1352 | 0.008% | 6.4% | 11.5% | 27.7% |
| FPT | Mean Reversion | Bull | 2691 | -0.020% | -52.2% | 26.1% | 59.3% |
| FPT | Mean Reversion | Bear | 1352 | 0.010% | -1.9% | 21.7% | 52.1% |
| HPG | Buy & Hold | Bull | 2488 | 0.117% | 1047.4% | 43.5% | 100.0% |
| HPG | Buy & Hold | Bear | 1555 | 0.052% | 50.3% | 41.4% | 100.0% |
| HPG | Momentum | Bull | 2488 | 0.122% | 1299.4% | 36.6% | 82.5% |
| HPG | Momentum | Bear | 1555 | 0.027% | 42.5% | 8.8% | 20.5% |
| HPG | Mean Reversion | Bull | 2488 | -0.079% | -89.5% | 25.0% | 60.2% |
| HPG | Mean Reversion | Bear | 1555 | -0.034% | -54.9% | 23.5% | 55.1% |
ggplot(regime_perf, aes(regime, mean_daily * CFG$trading_days, fill = label)) +
geom_col(position = "dodge") +
geom_hline(yintercept = 0, linetype = "dashed") +
facet_wrap(~ticker, ncol = 2) +
scale_fill_manual(values = STRAT_PAL, name = NULL) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Annualized mean return by regime", x = NULL, y = "Mean daily return x 252")8.5 Generalization Across Assets
Với nhiều mã / nhóm tài sản, bảng dưới cho biết chiến lược có hiệu quả nhất quán hay chỉ tốt ở vài mã. Chỉ nên tin kết luận khi mỗi nhóm có đủ số mã (n_assets).
gen_full <- cross_section_summary(scoreboard_net, group_col = "sector")
gen_wf <- cross_section_summary(wf_perf, group_col = "sector")
fmt_gen <- \(d) d |>
mutate(across(c(pct_positive, pct_beat_bh), fmt_pct),
across(c(mean_sharpe, median_sharpe), \(x) sprintf("%.2f", x)))
kable(fmt_gen(gen_full), caption = "By sector, full sample (net Sharpe)")| sector | label | n_assets | mean_sharpe | median_sharpe | pct_positive | pct_beat_bh |
|---|---|---|---|---|---|---|
| Steel | Momentum | 1 | 0.85 | 0.85 | 100.0% | 100.0% |
| Steel | Mean Reversion | 1 | -0.65 | -0.65 | 0.0% | 0.0% |
| Technology | Momentum | 1 | 0.65 | 0.65 | 100.0% | 0.0% |
| Technology | Mean Reversion | 1 | -0.22 | -0.22 | 0.0% | 0.0% |
| sector | label | n_assets | mean_sharpe | median_sharpe | pct_positive | pct_beat_bh |
|---|---|---|---|---|---|---|
| Steel | Momentum | 1 | 1.10 | 1.10 | 100.0% | 100.0% |
| Steel | Mean Reversion | 1 | -0.28 | -0.28 | 0.0% | 0.0% |
| Technology | Momentum | 1 | 0.95 | 0.95 | 100.0% | 0.0% |
| Technology | Mean Reversion | 1 | -0.50 | -0.50 | 0.0% | 0.0% |
kable(fmt_gen(cross_section_summary(wf_perf, group_col = "asset_class")),
caption = "By asset class, walk-forward out-of-sample (net Sharpe)")| asset_class | label | n_assets | mean_sharpe | median_sharpe | pct_positive | pct_beat_bh |
|---|---|---|---|---|---|---|
| Equity | Momentum | 2 | 1.02 | 1.02 | 100.0% | 50.0% |
| Equity | Mean Reversion | 2 | -0.39 | -0.39 | 0.0% | 0.0% |
9 Summary
best_full <- scoreboard_net |>
group_by(ticker) |>
slice_max(sharpe, n = 1, with_ties = FALSE) |>
ungroup() |>
select(ticker, best_full_sample = label, full_sharpe = sharpe)
best_wf <- wf_perf |>
group_by(ticker) |>
slice_max(sharpe, n = 1, with_ties = FALSE) |>
ungroup() |>
select(ticker, best_walk_forward = label, wf_sharpe = sharpe)
left_join(best_full, best_wf, by = "ticker") |>
mutate(across(c(full_sharpe, wf_sharpe), \(x) sprintf("%.2f", x))) |>
kable(caption = "Best strategy per asset by net Sharpe (Buy & Hold included as a candidate)")| ticker | best_full_sample | full_sharpe | best_walk_forward | wf_sharpe |
|---|---|---|---|---|
| FPT | Buy & Hold | 0.71 | Buy & Hold | 0.96 |
| HPG | Momentum | 0.85 | Momentum | 1.10 |
Key findings (điền sau khi chạy trên dữ liệu thực; mỗi ý tương ứng một câu hỏi nghiên cứu)
- Persistence of signals: …
- Impact of transaction costs: …
- Statistical significance: …
- Parameter stability: …
- Out-of-sample survival: …
- Cross-asset generalization: …
- Regime dependence: …
Negative results (for example a weak mean-reversion specification after costs) are retained rather than removed from the analysis.
10 Theory and Methodology
10.1 Strategies
10.1.1 Buy & Hold (benchmark)
The benchmark holds a constant position of \(+1\). It is treated as just another registered strategy, so every metric, table and test is computed on it in exactly the same way as on the signal strategies.
10.1.2 Momentum
Momentum is measured over a trailing look-back period of \(L\) trading days:
\[\text{MOM}_t(L) = \frac{P_t}{P_{t-L}} - 1\]
The strategy is long-only:
- Positive momentum \(\to\) long (\(w = 1\))
- Non-positive momentum \(\to\) cash (\(w = 0\))
The economic rationale is trend persistence: gradual information diffusion, under-reaction and herding can make recent winners continue to outperform over short and medium horizons.
10.1.3 Mean Reversion
Mean reversion is investigated using a rolling price z-score over a window of \(W\) days:
\[Z_t = \frac{P_t - \mu_t^{(W)}}{\sigma_t^{(W)}}\]
where \(\mu_t^{(W)}\) and \(\sigma_t^{(W)}\) are the rolling mean and standard deviation of the price. The position follows a small state machine with hysteresis:
- Enter long when \(Z_t < -k\), enter short when \(Z_t > +k\) (default \(k = 2\))
- Exit when the z-score crosses back through zero
The rationale is that temporary price dislocations (liquidity shocks, overreaction) are corrected over time. The main risk is a trending market, where prices keep moving away from their mean and the strategy repeatedly trades against the trend.
Weak or negative results for a specification are reported rather than discarded, in order to avoid selection bias.
10.2 Backtesting Framework
Let \(s_t\) be the target position computed with information up to the close of day \(t\). The position actually held during day \(t\) is the lagged signal:
\[w_t = s_{t-1}\]
so that information from today’s close can never be used to earn today’s return. This prevents look-ahead bias. With simple returns \(r_t = P_t / P_{t-1} - 1\):
\[r^{\text{gross}}_t = w_t \times r_t\]
All strategies are evaluated on the same window: signals are computed on the full history (including a warm-up period), but performance is measured only from eval_start, after warmup_obs observations. Every strategy starts from cash at the beginning of that window, so the initial entry cost is paid by all strategies including Buy & Hold.
Because signals only use past data, a backtest on the full history and a backtest restricted to a sub-period give identical returns on that sub-period. This is why hold-out and walk-forward validation can be computed by slicing a single results table.
10.2.1 Transaction Costs
Turnover measures the change in position, and the cost is proportional to it:
\[\text{Turnover}_t = |w_t - w_{t-1}|, \qquad r^{\text{net}}_t = w_t \, r_t - \text{Turnover}_t \times c\]
where \(c\) is the cost per unit of turnover (cost_bps, default 10 basis points). A reversal from short to long counts as turnover of 2. Because \(r^{\text{net}}\) is linear in \(c\), cost sensitivity can be computed without re-running the signals.
Realistic costs for Vietnamese equities include brokerage fees on both sides and a selling tax, so total friction per unit of turnover may be higher than the default; the cost-sensitivity analysis shows how conclusions change.
10.2.2 Performance Metrics
With \(n\) daily net returns \(r_1, \dots, r_n\) and \(T = 252\) trading days per year:
\[\text{Cumulative return} = \prod_{t=1}^{n}(1 + r_t) - 1\]
\[\text{Annualized return} = \left(\prod_{t=1}^{n}(1 + r_t)\right)^{T/n} - 1\]
\[\text{Annualized volatility} = \sigma_r \sqrt{T}\]
\[\text{Sharpe} = \frac{\text{Annualized return} - r_f}{\text{Annualized volatility}}\]
\[\text{Sortino} = \frac{\text{Annualized return} - r_f}{\text{DD}}, \qquad \text{DD} = \sqrt{T \cdot \frac{1}{n}\sum_{t=1}^{n}\min(r_t, 0)^2}\]
\[\text{Max drawdown} = \min_t \left(\frac{W_t}{\max_{s \le t} W_s} - 1\right), \qquad W_t = \prod_{u \le t}(1 + r_u)\]
\[\text{Annual turnover} = T \times \overline{\text{Turnover}_t}, \qquad \text{Exposure} = \overline{|w_t|}\]
The Sharpe ratio here uses the geometric annualized return in the numerator, so it is slightly lower than the arithmetic version for volatile assets.
10.3 Statistical Tests
One-sample t-test. Tests \(H_0: E[r_t] = 0\) using
\[t = \frac{\bar r}{s / \sqrt{n}}\]
Daily returns are fat-tailed and can be autocorrelated, so p-values are approximate. When many parameter sets are searched, the best p-value is biased low (data snooping), which is why out-of-sample evidence carries more weight than in-sample significance.
Jarque-Bera normality test. Based on skewness \(S\) and kurtosis \(K\):
\[JB = \frac{n}{6}\left(S^2 + \frac{(K - 3)^2}{4}\right) \sim \chi^2_2 \text{ under normality}\]
Ljung-Box test. Tests the joint null of no autocorrelation up to lag \(m\):
\[Q = n(n+2)\sum_{k=1}^{m}\frac{\hat\rho_k^2}{n-k} \sim \chi^2_m\]
Alpha / beta regression. OLS regression of strategy returns on the return of the underlying asset:
\[r^{\text{strategy}}_t = \alpha + \beta \, r^{\text{asset}}_t + \varepsilon_t\]
\(\beta\) measures the average market exposure of the strategy and \(\alpha\) the average return not explained by that exposure (annualized by multiplying the daily intercept by \(T\)).
10.4 Robustness and Out-of-Sample Validation
Parameter sensitivity. Performance is computed for each point of the parameter grid. A robust signal shows a plateau of similar performance, not an isolated peak.
Hold-out validation. Data are split at train_end. Parameters are selected by maximizing net Sharpe on the training period only, then frozen and evaluated on the test period. The Sharpe retention ratio
\[\text{Retention} = \frac{\text{Sharpe}_{\text{test}}}{\text{Sharpe}_{\text{train}}}\]
summarizes how much in-sample performance survives (reported only when the training Sharpe is positive).
Walk-forward validation. For each test year \(y\), parameters are re-selected using all data up to the end of year \(y-1\) (expanding window) and applied to year \(y\). The concatenated out-of-sample returns approximate what a researcher following the same selection procedure in real time would have earned. The sequence of selected parameters reveals how stable the signal is.
Regime analysis. A simple trend regime labels a day Bull if the previous close is above its 200-day moving average and Bear otherwise. Using the previous day’s values avoids look-ahead in the regime label. Performance is then measured separately within each regime.
Cross-asset generalization. The same strategy and procedure are applied to every asset, and results are aggregated by sector or asset class (mean and median Sharpe, share of assets with positive Sharpe, and share of assets where the strategy beats Buy & Hold). A signal that only works for one asset is more likely to be chance.
10.5 Limitations
- Survivorship and selection bias in the choice of assets.
- No modelling of slippage, market impact, liquidity limits or price limits; costs are a single proportional rate.
- Short positions (used by mean reversion) are generally not available to retail investors in the Vietnamese equity market, so short-side results may not be implementable.
- Settlement-cycle rules restrict how quickly positions can be reversed in practice.
- Daily close-to-close returns assume execution at the next close after the signal.
- Walk-forward returns do not charge the one-off cost of switching between parameter sets at year boundaries.
- Results depend on the price series used (
price_adjustedvsprice_close) and on data quality from the free data source.