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 / 1e4

2.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 sectorasset_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")
Table 2.1: 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:

  1. 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.
  2. 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")
Table 2.2: 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")
Table 4.1: 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)")
Table 4.2: 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")
Table 4.3: 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))
Table 5.1: Net performance at 10 bps per unit of turnover
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.

kable(metric_matrix(scoreboard_net, "sharpe"), caption = "Net Sharpe ratio")
Table 5.2: Net Sharpe ratio
label FPT HPG
Buy & Hold 0.71 0.59
Momentum 0.65 0.85
Mean Reversion -0.22 -0.65
kable(metric_matrix(scoreboard_net, "ann_return", fmt_pct), caption = "Net annualized return")
Table 5.2: Net annualized return
label FPT HPG
Buy & Hold 18.3% 19.4%
Momentum 12.8% 20.5%
Mean Reversion -4.6% -17.3%
kable(metric_matrix(scoreboard_net, "max_dd", fmt_pct), caption = "Maximum drawdown")
Table 5.2: Maximum drawdown
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)")
Table 5.2: 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())

5.3 Equity Curves

bt_wealth <- add_wealth(bt, "ret_net")

plot_lines(bt_wealth, "wealth", "Growth of 1 unit (net of costs, log scale)", "Wealth",
           log_y = TRUE, hline = 1)

5.4 Drawdowns

plot_lines(bt_wealth, "drawdown", "Drawdown (net of costs)", "Drawdown") +
  scale_y_continuous(labels = scales::percent)

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")
Table 6.1: 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)")
Table 6.2: 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)")
Table 7.1: 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")
Table 7.2: 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))
for (id in tunable) print(plot_sensitivity(id))

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)")
Table 8.1: 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)))
Table 8.2: Walk-forward out-of-sample performance (2011-2025)
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)")
Table 8.3: 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")
Table 8.4: 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")
Table 8.5: 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")
Table 8.6: 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)")
Table 8.7: 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%
kable(fmt_gen(gen_wf), caption = "By sector, walk-forward out-of-sample (net Sharpe)")
Table 8.7: By sector, walk-forward out-of-sample (net Sharpe)
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)")
Table 8.7: 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)")
Table 9.1: 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_adjusted vs price_close) and on data quality from the free data source.