想自己重算,可以從頁首下載 R Markdown 與執行結果。所有實證例都使用隨書提供的固定資料; 檔案來源、變數單位與已知限制請見資料來源與重現說明

《財務時間序列分析》線上附錄

R03:分配、厚尾與實證特徵

本附錄對應第 3–4 章,要回答兩個彼此相關的問題:金融報酬的尾端是否比常態分配厚,以及大幅波動是否會在時間上成群出現?我們先用固定種子模擬常態與標準化 Student-$t$ 分配,確認「變異數相同,尾端仍可很不一樣」;再把同一套描述工具用在兩組真實日簡單報酬上。

第一組資料是 1986–2008 年的 Microsoft(MSFT),每一列代表一個交易日;第二組是由 89 檔股票組成的 2013–2022 年等權教學投資組合,每一列先記錄同一共同交易日的 89 個個股報酬,再取橫斷面平均。MSFT 資料源自 Ruey S. Tsay 教科書網站的 d-msft8608.txt,股票面板源自原課程 S&P 500 價格檔;固定版本與建置方式見 data/DATA_SOURCES.md

兩組報酬都以小數表示,0.01 代表 1%。等權序列是教學用組合,不是官方 S&P 500 指數,也沒有校正成分股生存者偏誤。本頁使用完整歷史樣本做分配與相依性描述,不進行預測,因此不分訓練期、驗證期與測試期;結果也沒有識別任何市場事件對報酬的因果效果。

knitr::opts_chunk$set(
  echo = TRUE, message = FALSE, warning = FALSE,
  fig.width = 8, fig.height = 5,
  dev = "ragg_png", dpi = 144,
  dev.args = list(background = "white")
)
set.seed(20260716)

root_candidates <- c(".", "..")
is_root <- vapply(root_candidates, function(x) {
  file.exists(file.path(x, "main.tex"))
}, logical(1))
stopifnot(any(is_root))
project_root <- root_candidates[which(is_root)[1]]
project_path <- function(...) file.path(project_root, ...)

stopifnot(
  requireNamespace("ragg", quietly = TRUE),
  requireNamespace("systemfonts", quietly = TRUE)
)
cwtex_file <- project_path("assets", "fonts", "cwTeXQKai-Medium.ttf")
stopifnot(file.exists(cwtex_file))
if (!"cwTeX Online" %in% systemfonts::registry_fonts()$family) {
  systemfonts::register_font("cwTeX Online", cwtex_file)
}
plot_family <- "cwTeX Online"

描述統計與 Jarque–Bera 函數#

先寫兩個小函數,目的是讓每一個輸出都能與公式對上。sample_moments() 接受一條報酬序列,輸出有效觀察值數、平均數、標準差、偏態、峰度與尾端分位數;jarque_bera() 再用偏態與超額峰度組成檢定統計量。以下峰度採用「常態分配等於 3」的定義,超額峰度則是峰度減 3。有限樣本偏態與峰度有不同修正版本,因此比較手動版與套件版以前,必須先確認定義。

sample_moments <- function(x) {
  # 描述同一條序列時使用相同的有限值樣本,避免各統計量樣本數不同。
  x <- x[is.finite(x)]
  centered <- x - mean(x)
  m2 <- mean(centered^2)
  m3 <- mean(centered^3)
  m4 <- mean(centered^4)
  c(
    n = length(x),
    mean = mean(x),
    sd = sd(x),
    skewness = m3 / m2^(3 / 2),
    kurtosis = m4 / m2^2,
    excess_kurtosis = m4 / m2^2 - 3,
    q01 = unname(quantile(x, 0.01)),
    q05 = unname(quantile(x, 0.05)),
    median = median(x),
    q95 = unname(quantile(x, 0.95)),
    q99 = unname(quantile(x, 0.99))
  )
}

jarque_bera <- function(x) {
  m <- sample_moments(x)
  # 此處使用教科書中的 iid 漸近式;時間相依性稍後另做診斷。
  statistic <- m["n"] * (
    m["skewness"]^2 / 6 + m["excess_kurtosis"]^2 / 24
  )
  c(
    statistic = unname(statistic),
    p_value_iid_asymptotic = unname(
      pchisq(statistic, 2, lower.tail = FALSE)
    )
  )
}

先用已知分配看懂「厚尾」#

模擬的好處是母體分配已知,可以先排除資料建置與模型選擇的干擾。自由度 5 的 Student-$t$ 變異數是 $5/(5-2)$;除以理論標準差後,它與標準常態具有相同變異數,但尾端機率仍較高。這一段要觀察的是峰度、尾端分位數與直方圖形狀,不是檢定金融市場。

n_sim <- 10000L
normal_draw <- rnorm(n_sim)
# 標準化後兩組理論變異數相同,尾端差異才不會只是尺度造成。
t5_draw <- rt(n_sim, df = 5) / sqrt(5 / 3)

simulation_summary <- rbind(
  Normal = sample_moments(normal_draw),
  Student_t5 = sample_moments(t5_draw)
)
knitr::kable(round(simulation_summary, 4))
n mean sd skewness kurtosis excess_kurtosis q01 q05 median q95 q99
Normal 10000 0.0101 1.0136 -0.0016 3.0640 0.0640 -2.4048 -1.6583 0.0099 1.6794 2.3828
Student_t5 10000 0.0007 1.0129 -0.1179 8.1864 5.1864 -2.6003 -1.5806 0.0158 1.5817 2.6540

樣本平均數與變異數會因這一次亂數抽樣略有誤差。標準化 Student-$t_5$ 的 1% 與 99% 分位數通常比常態分位數離零更遠,但 5% 與 95% 分位數反而更靠近零,不能籠統地說所有尾端分位數都更遠。直方圖呈現的是三個一起發生的特徵:中心尖峰較高、肩部較薄,而極端尾端較厚;峰度則把這種極端尾端的差異濃縮成一個數字。

old_par <- par(
  mfrow = c(1, 2), mar = c(4, 4, 2, 1),
  family = plot_family
)
hist(
  normal_draw, breaks = 80, probability = TRUE,
  xlim = c(-6, 6), col = "#9FC2D4", border = "white",
  main = "標準常態", xlab = "模擬值"
)
curve(dnorm(x), add = TRUE, lwd = 2, col = "#A34045")
hist(
  t5_draw, breaks = 100, probability = TRUE,
  xlim = c(-6, 6), col = "#D6B0A9", border = "white",
  main = "標準化 Student-t(5)", xlab = "模擬值"
)
curve(dnorm(x), add = TRUE, lwd = 2, col = "#173B57")

相同理論變異數下,標準化 Student-t(5) 的尾端比常態分配厚。

par(old_par)

模擬讓我們看懂「相同變異數不代表相同尾端」,卻沒有提供 MSFT 或投資組合的實證結論。接下來才把這套觀察方式帶到固定的歷史資料。

讀取 MSFT 與股票面板#

讀入兩份資料後,先依日期排序並確認沒有缺值。R 的每一列是一個共同交易日,每一欄是一檔股票;rowMeans(R) 因此是在同一天對 89 檔股票等權平均,而不是跨時間平均。這個方向若弄反,會得到完全不同的研究對象。

msft <- read.csv(project_path(
  "data", "processed", "msft_daily_returns_1986_2008.csv"
))
msft$date <- as.Date(msft$date)
# ACF 依賴時間次序,所以在任何診斷前先由早到晚排序。
msft <- msft[order(msft$date), ]

panel <- read.csv(
  project_path(
    "data", "processed", "sp500_returns_balanced_2013_2022.csv"
  ),
  check.names = FALSE
)
panel$date <- as.Date(panel$date)
R <- as.matrix(panel[, setdiff(names(panel), "date")])
storage.mode(R) <- "double"

stopifnot(
  !anyNA(msft$date), !anyNA(msft$simple_return),
  all(diff(msft$date) > 0),
  !anyNA(panel$date), !anyNA(R),
  all(diff(panel$date) > 0)
)

sp_equal <- rowMeans(R)

data_profile <- data.frame(
  序列 = c("MSFT", "89 檔股票等權教學投資組合"),
  起日 = c(min(msft$date), min(panel$date)),
  迄日 = c(max(msft$date), max(panel$date)),
  觀察值 = c(nrow(msft), nrow(panel)),
  資產數 = c(1L, ncol(R)),
  單位 = "日簡單報酬,小數",
  來源 = c(
    "Tsay 教科書網站 d-msft8608.txt",
    "原課程 S&P 500 價格檔的平衡面板"
  ),
  check.names = FALSE
)
knitr::kable(data_profile)
序列 起日 迄日 觀察值 資產數 單位 來源
MSFT 1986-03-14 2008-12-31 5752 1 日簡單報酬,小數 Tsay 教科書網站 d-msft8608.txt
89 檔股票等權教學投資組合 2013-01-03 2022-06-22 2384 89 日簡單報酬,小數 原課程 S&P 500 價格檔的平衡面板

data_profile 應顯示 MSFT 有 5,752 個交易日,等權組合有 2,384 個共同交易日。兩列的日期範圍不同,因此後面的統計量是在各自樣本內描述,不宜把數值差異全部歸因於資產本身;市場年代、樣本長度與成分股篩選也可能影響結果。

真實報酬的偏態、峰度與尾端#

現在用同一組公式整理兩條真實報酬序列。分位數告訴我們左右尾的實際尺度,偏態描述兩側是否對稱,峰度則對極端觀察值特別敏感;三者要放在一起讀,不能只看單一 $p$ 值。

empirical_summary <- rbind(
  MSFT = sample_moments(msft$simple_return),
  SP_equal_weight = sample_moments(sp_equal)
)
knitr::kable(round(empirical_summary, 5))
n mean sd skewness kurtosis excess_kurtosis q01 q05 median q95 q99
MSFT 5752 0.00123 0.02359 -0.13209 12.92329 9.92329 -0.06108 -0.03306 0.00000 0.03846 0.06344
SP_equal_weight 2384 0.00076 0.01075 -0.67951 22.94458 19.94458 -0.03040 -0.01555 0.00106 0.01469 0.02507
jb_table <- rbind(
  MSFT = jarque_bera(msft$simple_return),
  SP_equal_weight = jarque_bera(sp_equal)
)
knitr::kable(jb_table, digits = 6)
statistic p_value_iid_asymptotic
MSFT 23617.11 0
SP_equal_weight 39696.89 0

MSFT 的樣本峰度約為 12.92,等權教學組合約為 22.94,都遠高於常態分配的 3;兩者的 Jarque–Bera 統計量也都很大。就這兩段固定樣本而言,常態分配無法妥善描述偏態與尾端。這項結果只指出常態模型的不足,還沒有在 Student-$t$、偏態 $t$ 或其他厚尾分配之間選出唯一答案。

Jarque–Bera 的卡方近似依賴獨立同分配與有限高階動差。金融報酬常有條件異質變異,因此極小的漸近 $p$ 值適合當作常態模型的警訊,後面仍要檢查報酬與平方報酬的時間相依性。

套件作法:用 basicStats()normalTest() 整理分配特徵#

原課程的實作程式在 slides/L02_Return_properties/W1L2_R_scripts_Descriptive_stat_returns.RfBasics::basicStats() 整理描述統計,並以 normalTest(..., method = "jb") 執行 Jarque–Bera 檢定。下列程式沿用這個工作流程,但改讀本書的固定資料,不在執行時連線下載。套件會代為計算統計量並整理 fHTEST 輸出;資料期間、報酬尺度、峰度定義與時間相依性是否可忽略,仍要由使用者判斷。

stopifnot(requireNamespace("fBasics", quietly = TRUE))

empirical_series <- list(
  MSFT = msft$simple_return,
  `S&P 等權教學組合` = sp_equal
)

basic_row <- function(out, row_name) {
  out <- as.matrix(out)
  location <- match(tolower(row_name), tolower(row.names(out)))
  stopifnot(!is.na(location))
  as.numeric(out[location, 1])
}

fbasics_summary <- lapply(empirical_series, fBasics::basicStats)
manual_summary <- lapply(empirical_series, sample_moments)

# 按統計量名稱取值,避免套件輸出列次序改變時配錯數字。
moment_comparison <- do.call(rbind, lapply(names(empirical_series), function(nm) {
  package_result <- as.matrix(fbasics_summary[[nm]])
  manual_result <- manual_summary[[nm]]
  manual_values <- c(
    manual_result["mean"],
    manual_result["sd"],
    manual_result["skewness"],
    manual_result["excess_kurtosis"]
  )
  package_values <- c(
    basic_row(package_result, "Mean"),
    basic_row(package_result, "Stdev"),
    basic_row(package_result, "Skewness"),
    basic_row(package_result, "Kurtosis")
  )
  data.frame(
    序列 = nm,
    統計量 = c("平均數", "標準差", "偏態", "超額峰度"),
    手動版 = unname(manual_values),
    fBasics = package_values,
    套件減手動 = package_values - unname(manual_values),
    check.names = FALSE
  )
}))
row.names(moment_comparison) <- NULL
knitr::kable(moment_comparison, digits = 7)
序列 統計量 手動版 fBasics 套件減手動
MSFT 平均數 0.0012318 0.001232 0.0000002
MSFT 標準差 0.0235944 0.023594 -0.0000004
MSFT 偏態 -0.1320928 -0.132058 0.0000348
MSFT 超額峰度 9.9232911 9.918798 -0.0044931
S&P 等權教學組合 平均數 0.0007612 0.000761 -0.0000002
S&P 等權教學組合 標準差 0.0107466 0.010747 0.0000004
S&P 等權教學組合 偏態 -0.6795124 -0.679085 0.0004274
S&P 等權教學組合 超額峰度 19.9445772 19.925332 -0.0192452
# 對同一批固定觀察值執行套件版 JB,才能與手動公式比較。
fbasics_jb <- lapply(empirical_series, function(x) {
  fBasics::normalTest(x, method = "jb")
})

# 先看完整 fHTEST,認識統計量、自由度與 p 值在物件中的位置。
for (nm in names(fbasics_jb)) {
  cat("\n", nm, "\n", sep = "")
  print(fbasics_jb[[nm]])
}
## 
## MSFT
## 
## Title:
##  Jarque-Bera Normality Test
## 
## Test Results:
##   STATISTIC:
##     X-squared: 23617.1128
##   P VALUE:
##     Asymptotic p Value: < 2.2e-16 
## 
## 
## S&P 等權教學組合
## 
## Title:
##  Jarque-Bera Normality Test
## 
## Test Results:
##   STATISTIC:
##     X-squared: 39696.8887
##   P VALUE:
##     Asymptotic p Value: < 2.2e-16
extract_fhtest <- function(x) {
  test_result <- methods::slot(x, "test")
  c(
    statistic = unname(test_result$statistic),
    p_value = unname(test_result$p.value)
  )
}

jb_comparison <- do.call(rbind, lapply(names(empirical_series), function(nm) {
  manual_result <- jarque_bera(empirical_series[[nm]])
  package_result <- extract_fhtest(fbasics_jb[[nm]])
  data.frame(
    序列 = nm,
    手動JB = manual_result["statistic"],
    fBasics_JB = package_result["statistic"],
    手動p值 = manual_result["p_value_iid_asymptotic"],
    fBasics_p值 = package_result["p_value"],
    check.names = FALSE
  )
}))
row.names(jb_comparison) <- NULL
knitr::kable(jb_comparison, digits = 7)
序列 手動JB fBasics_JB 手動p值 fBasics_p值
MSFT 23617.11 23617.11 0 0
S&P 等權教學組合 39696.89 39696.89 0 0

平均數與標準差先幫我們確認資料與尺度是否一致;偏態、峰度與 Jarque–Bera 數值則可能因有限樣本修正與動差定義不同而略有差異。特別是 basicStats()Kurtosis 採超額峰度,應與上文「常態等於 0」的欄位比較,不能直接對照「常態等於 3」的峰度。表中若出現小幅數值差,先查定義與修正方式;兩種作法在這份固定資料上的方向應一致,都指出常態模型對尾端的描述不足。

series_list <- list(
  MSFT = list(date = msft$date, return = msft$simple_return),
  `S&P 等權教學組合` = list(date = panel$date, return = sp_equal)
)

old_par <- par(
  mfrow = c(2, 3), mar = c(4.5, 3.7, 3, 1),
  family = plot_family
)
for (nm in names(series_list)) {
  z <- series_list[[nm]]
  plot(
    z$date, 100 * z$return, type = "l", col = "#173B57",
    xlab = "日期", ylab = "日報酬(%)", main = nm
  )
  hist(
    z$return, breaks = 70, probability = TRUE,
    col = "#9FC2D4", border = "white",
    xlab = "日簡單報酬", main = paste(nm, "分配")
  )
  curve(
    dnorm(x, mean(z$return), sd(z$return)),
    add = TRUE, lwd = 2, col = "#A34045"
  )
  qqnorm(
    z$return, pch = 16, cex = 0.4,
    col = "#173B57", main = paste(nm, "常態 Q–Q")
  )
  qqline(z$return, col = "#A34045", lwd = 2)
}

MSFT 與等權教學投資組合的時間圖、經驗分配與常態 Q–Q 圖。

par(old_par)

時間圖先告訴我們極端波動落在哪些日期,直方圖比較整體形狀,Q–Q 圖則把左右尾偏離常態的方向分開呈現。若尾端點系統性離開參考線,應考慮厚尾創新或更合適的風險衡量方式;圖形本身還不能決定唯一的替代分配。

報酬與平方報酬的時間相依#

厚尾描述的是無條件分配,波動群聚則問「大波動之後是否較常接著大波動」。因此同時畫原報酬與平方報酬的自相關函數(ACF):前者主要看條件平均的線性相依,後者把正負號拿掉,用來觀察波動大小是否具有持續性。

old_par <- par(
  mfrow = c(2, 2), mar = c(4, 3.5, 4, 1),
  family = plot_family, cex.main = 0.85
)
for (nm in names(series_list)) {
  z <- series_list[[nm]]$return
  acf(z, lag.max = 30, main = paste(nm, "報酬 ACF"))
  acf(z^2, lag.max = 30, main = paste(nm, "平方報酬 ACF"))
}

MSFT 與等權教學投資組合的報酬 ACF 與平方報酬 ACF。

par(old_par)
lb_row <- function(x, series_name) {
  q_return <- Box.test(x, lag = 20, type = "Ljung-Box")
  q_square <- Box.test(x^2, lag = 20, type = "Ljung-Box")
  data.frame(
    序列 = series_name,
    Q20_報酬 = unname(q_return$statistic),
    p_報酬 = q_return$p.value,
    Q20_平方報酬 = unname(q_square$statistic),
    p_平方報酬 = q_square$p.value,
    check.names = FALSE
  )
}

diagnostics <- rbind(
  lb_row(msft$simple_return, "MSFT"),
  lb_row(sp_equal, "S&P 等權教學組合")
)
knitr::kable(diagnostics, digits = 6)
序列 Q20_報酬 p_報酬 Q20_平方報酬 p_平方報酬
MSFT 41.3004 0.003408 1156.800 0
S&P 等權教學組合 422.1970 0.000000 4037.729 0

在這份固定資料中,MSFT 報酬的 $Q(20)$ 約為 41.30,等權組合約為 422.20;平方報酬的統計量更分別達約 1,156.80 與 4,037.73。前 20 階同時為零的限制在兩條序列上都遭拒絕,平方報酬的相依尤其明顯。下一步可分開處理兩個問題:條件平均模型檢查殘差是否仍有線性相依,ARCH/GARCH 類模型則處理波動持續性。這些統計量沒有直接證明報酬可交易,也沒有提供造成波動群聚的因果機制。

定位極端日期#

由於峰度對少數極端值很敏感,最後把絕對報酬最大的日期列出來。這一步是為了回查資料來源與理解哪些觀察值主導尾端,而不是根據結果大小自動刪除資料。

largest_moves <- function(date, x, label, number = 5L) {
  keep <- order(abs(x), decreasing = TRUE)[seq_len(number)]
  data.frame(
    序列 = label,
    日期 = date[keep],
    日簡單報酬 = x[keep],
    check.names = FALSE
  )
}

extreme_table <- rbind(
  largest_moves(msft$date, msft$simple_return, "MSFT"),
  largest_moves(panel$date, sp_equal, "S&P 等權教學組合")
)
knitr::kable(extreme_table, digits = 6)
序列 日期 日簡單報酬
MSFT 1987-10-19 -0.301158
MSFT 2000-10-19 0.195652
MSFT 1987-10-26 -0.186529
MSFT 2008-10-13 0.186047
MSFT 1987-10-21 0.179688
S&P 等權教學組合 2020-03-16 -0.121581
S&P 等權教學組合 2020-03-24 0.105691
S&P 等權教學組合 2020-03-12 -0.096315
S&P 等權教學組合 2020-03-13 0.087688
S&P 等權教學組合 2020-03-09 -0.080871

若回查後確認是建檔或價格調整錯誤,應修正資料並重新計算全部統計量;若極端值與原始來源一致,就應保留,因為它們正是厚尾與風險分析要解釋的現象。

這兩段歷史資料給我們的訊息#

MSFT 與等權教學組合都呈現高峰度、常態 Q–Q 圖尾端偏離,以及顯著的平方報酬相依。這些證據支持在後續模型中正面處理厚尾與條件異質變異,卻還不能判定哪一個厚尾分配最好,也不能把歷史尾端頻率當成不變的未來機率。實務上的下一步,是在明確的訓練期內比較候選分配與波動模型,再用保留的測試期評估預測分配,而不是只依一次常態性檢定作決定。

© 陳釗而 《財務時間序列分析》