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

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

R02:價格、日期與報酬率

本附錄對應第 2 章,從一個每天都會遇到的實證問題開始:手上只有一列列調整收盤價時,日簡單報酬與日對數報酬究竟應如何計算,日期間隔又該怎麼處理?我們實際讀取 Apple Inc.(AAPL)的固定資料,從調整收盤價重算兩種報酬,再比較複利、交易日間隔與極端日。

每一列代表一個交易日,樣本期間為 2019-01-02 至 2022-06-22,共 875 個交易日。adjusted 的尺度是「美元/股的調整後價格」;報酬欄位以小數表示,例如 0.01 代表 1%。資料來自原課程 S&P 500 價格檔所保留的 AAPL 序列,建置細節見 data/DATA_SOURCES.md。第 $t$ 日報酬要同時用到第 $t$ 日與前一交易日的價格,因此通常要等第 $t$ 日收盤價可得後才能計算;若拿它預測同一天稍早的結果,就會使用到尚未出現的資訊。

這一頁的工作是整理並描述固定歷史資料,沒有訓練期、驗證期與測試期之分,也沒有識別市場事件對 AAPL 報酬的因果效果。

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

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"

讀取、排序與入口檢查#

計算落後價格以前,先把日期順序確認清楚。若檔案不是由早到晚排列,lag() 或相鄰列相除便會把錯誤的兩天接在一起。以下先檢查欄位,將日期轉成 Date 後排序,再確認沒有重複日期,而且調整價格都是正的有限值。

aapl_path <- project_path(
  "data", "processed", "aapl_adjusted_daily_2019_2022.csv"
)
stopifnot(file.exists(aapl_path))

aapl <- read.csv(aapl_path, stringsAsFactors = FALSE)
needed <- c(
  "date", "symbol", "company", "sector", "adjusted",
  "simple_return", "log_return"
)
stopifnot(all(needed %in% names(aapl)))

aapl$date <- as.Date(aapl$date)
# 報酬是依相鄰交易日計算,排序必須發生在任何 lag 或 diff 之前。
aapl <- aapl[order(aapl$date), ]
row.names(aapl) <- NULL

stopifnot(
  !anyNA(aapl$date),
  !anyDuplicated(aapl$date),
  all(diff(aapl$date) > 0),
  all(aapl$symbol == "AAPL"),
  all(is.finite(aapl$adjusted)),
  all(aapl$adjusted > 0)
)

data_profile <- data.frame(
  資產 = unique(aapl$symbol),
  起日 = min(aapl$date),
  迄日 = max(aapl$date),
  交易日數 = nrow(aapl),
  價格單位 = "美元/股(調整後尺度)",
  報酬單位 = "小數;0.01 代表 1%",
  資料來源 = "原課程 S&P 500 價格檔的 AAPL 固定版本",
  check.names = FALSE
)
knitr::kable(data_profile)
資產 起日 迄日 交易日數 價格單位 報酬單位 資料來源
AAPL 2019-01-02 2022-06-22 875 美元/股(調整後尺度) 小數;0.01 代表 1% 原課程 S&P 500 價格檔的 AAPL 固定版本

資料摘要應顯示單一資產 AAPL、875 個交易日,以及前述價格與報酬尺度。這張表把後續每個數字的分母與時間單位先固定下來;若換成週報酬或未調整收盤價,解讀也必須跟著改變。

第一筆價格沒有本檔案中的前一期價格可供比較,所以兩種報酬都應是缺值。這個缺值是報酬定義自然產生的,不需要填成 0;填入 0 反而會憑空增加一個「沒有漲跌」的交易日。

colSums(is.na(aapl[c("adjusted", "simple_return", "log_return")]))
##      adjusted simple_return    log_return 
##             0             1             1
stopifnot(
  is.na(aapl$simple_return[1]),
  is.na(aapl$log_return[1]),
  !anyNA(aapl$simple_return[-1]),
  !anyNA(aapl$log_return[-1])
)

用相鄰交易日的調整價格計算報酬#

若 $P_t^*$ 是同一調整尺度下的價格,則

\[ R_t=\frac{P_t^*}{P_{t-1}^*}-1, \qquad r_t=\log P_t^*-\log P_{t-1}^*=\log(1+R_t). \]

第一個公式衡量價格的相對變動,第二個公式把相鄰價格取對數後相減。兩者都要求 \(P_t^*\) 與 \(P_{t-1}^*\) 位於同一調整尺度。資料供應者已把其採用的公司行動調整反映在 adjusted 中;若研究需要逐筆辨認拆股或現金股利,仍要另讀公司行動資料,單靠這一欄無法還原全部現金流。

aapl$calculated_simple <- c(
  NA_real_,
  aapl$adjusted[-1] / head(aapl$adjusted, -1) - 1
)
aapl$calculated_log <- c(NA_real_, diff(log(aapl$adjusted)))

# 與固定資料內原有欄位比較,可發現排序或公式是否寫錯。
calculation_error <- data.frame(
  欄位 = c("simple_return", "log_return"),
  最大絕對差 = c(
    max(abs(aapl$simple_return - aapl$calculated_simple), na.rm = TRUE),
    max(abs(aapl$log_return - aapl$calculated_log), na.rm = TRUE)
  ),
  check.names = FALSE
)
knitr::kable(calculation_error, digits = 14)
欄位 最大絕對差
simple_return 0
log_return 0
stopifnot(
  calculation_error$最大絕對差[1] < 1e-12,
  calculation_error$最大絕對差[2] < 1e-12
)

knitr::kable(
  head(aapl[c(
    "date", "adjusted", "calculated_simple", "calculated_log"
  )], 6),
  digits = 6
)
date adjusted calculated_simple calculated_log
2019-01-02 38.22136 NA NA
2019-01-03 34.41423 -0.099607 -0.104924
2019-01-04 35.88335 0.042689 0.041803
2019-01-07 35.80349 -0.002226 -0.002228
2019-01-08 36.48602 0.019063 0.018884
2019-01-09 37.10561 0.016982 0.016839

最大絕對差 應小於 \(10^{-12}\),表示重算欄位與固定資料只差浮點誤差。前六列則讓我們直接看到:第一列報酬為缺值,從第二列開始,每個報酬都連結當日與前一個交易日的調整價格。

套件作法:用 arrange()mutate()lag() 計算報酬#

原課程的 AAPL 程式先以 tidyquant::tq_get() 取得 Yahoo 資料,再用 dplyr::arrange()mutate()lag() 建立報酬。本書沿用這套工作流程,但改讀上面的固定 CSV,避免資料供應者日後修訂歷史值而使答案改變。對應來源是 slides/L04_ARMA/W1L4_R_template_for_estimating_ARMA.Rslides/L07_ARCH_GARCH/W2L2_R_template_GARCH.R

arrange() 會把日期排好,lag() 會取同欄前一列,mutate() 則把公式加入資料框。這些函數不會替我們選擇正確的價格欄位,也不知道休市日應如何處理;資料尺度、排序鍵與缺值規則仍要由研究者先決定。

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

returns_package <- aapl |>
  # 先排序,再取落後值;顛倒次序會把原始列順序誤當成時間順序。
  dplyr::arrange(date) |>
  dplyr::mutate(
    simple_return_package =
      adjusted / dplyr::lag(adjusted) - 1,
    log_return_package =
      log(adjusted) - log(dplyr::lag(adjusted))
  )

package_return_check <- data.frame(
  核對項目 = c(
    "簡單報酬:套件版與手動版",
    "對數報酬:套件版與手動版",
    "簡單報酬:套件版與固定資料",
    "對數報酬:套件版與固定資料"
  ),
  最大絕對差 = c(
    max(abs(
      returns_package$simple_return_package -
        aapl$calculated_simple
    ), na.rm = TRUE),
    max(abs(
      returns_package$log_return_package -
        aapl$calculated_log
    ), na.rm = TRUE),
    max(abs(
      returns_package$simple_return_package -
        aapl$simple_return
    ), na.rm = TRUE),
    max(abs(
      returns_package$log_return_package -
        aapl$log_return
    ), na.rm = TRUE)
  ),
  check.names = FALSE
)
knitr::kable(package_return_check, digits = 14)
核對項目 最大絕對差
簡單報酬:套件版與手動版 0
對數報酬:套件版與手動版 0
簡單報酬:套件版與固定資料 0
對數報酬:套件版與固定資料 0
stopifnot(
  is.na(returns_package$simple_return_package[1]),
  is.na(returns_package$log_return_package[1]),
  max(package_return_check$最大絕對差) < 1e-12
)

四列 最大絕對差 都應接近零,因為手動版與套件版只是用不同語法計算同一定義。套件縮短了資料操作程式,資料責任仍然相同:先排序、保留第一筆自然產生的缺值,並確定分子與分母使用同一調整價格尺度。

價格與報酬的時間圖#

接著把價格水準與日報酬放在同一張圖的上下兩格。價格圖回答「資產價值如何隨時間累積」,報酬圖回答「每一交易日的相對變動有多大」;兩者的尺度與統計性質不同,不能只因來自同一欄價格就用同一模型處理。

old_par <- par(
  mfrow = c(2, 1), mar = c(4.5, 4, 3, 1),
  family = plot_family
)
plot(
  aapl$date, aapl$adjusted, type = "l", col = "#173B57",
  xlab = "日期", ylab = "調整價格(美元/股)",
  main = "AAPL 調整價格"
)
plot(
  aapl$date, 100 * aapl$log_return, type = "l", col = "#A34045",
  xlab = "日期", ylab = "日對數報酬(%)",
  main = "AAPL 日對數報酬"
)
abline(h = 0, col = "gray60")

AAPL 調整價格與由它計算的日對數報酬。報酬圖以百分比呈現。

par(old_par)

圖中的價格水準有明顯長期變動,日報酬則大多圍繞零上下波動,偶爾出現較大的跳動。這提醒我們在建模前先說清楚研究對象是價格、簡單報酬或對數報酬;價格有趨勢,不表示報酬也沿著同一趨勢前進。

簡單報酬與對數報酬的差距#

小幅報酬時 $r_t\approx R_t$,所以散點應貼近 45 度線;大幅漲跌時,對數轉換的非線性會讓兩者差距擴大。下圖中的每一點都是同一交易日的 AAPL 簡單報酬與對數報酬。

old_par <- par(family = plot_family)
plot(
  100 * aapl$simple_return, 100 * aapl$log_return,
  pch = 16, cex = 0.55, col = adjustcolor("#173B57", 0.55),
  xlab = "日簡單報酬(%)", ylab = "日對數報酬(%)"
)
abline(0, 1, lty = 2, lwd = 2, col = "#A34045")

AAPL 日簡單報酬與日對數報酬;虛線表示兩者完全相等。

par(old_par)

從單日報酬走到整段期間報酬#

要回答「從樣本第一天持有到最後一天,調整價格總共變動多少」,單日簡單報酬要逐期複利,而對數報酬可以先相加。從第一個調整價格到最後一個調整價格,三種算法應得到相同的多期簡單報酬:

  1. 每日簡單報酬逐期複利;
  2. 每日對數報酬相加後轉回簡單報酬;
  3. 最後價格除以最初價格再減一。
valid_simple <- aapl$simple_return[is.finite(aapl$simple_return)]
valid_log <- aapl$log_return[is.finite(aapl$log_return)]

compound_table <- data.frame(
  方法 = c(
    "每日簡單報酬複利",
    "對數報酬相加後轉回",
    "期末調整價格/期初調整價格"
  ),
  全期簡單報酬 = c(
    prod(1 + valid_simple) - 1,
    exp(sum(valid_log)) - 1,
    tail(aapl$adjusted, 1) / aapl$adjusted[1] - 1
  ),
  check.names = FALSE
)
knitr::kable(compound_table, digits = 8)
方法 全期簡單報酬
每日簡單報酬複利 2.541213
對數報酬相加後轉回 2.541213
期末調整價格/期初調整價格 2.541213
stopifnot(diff(range(compound_table$全期簡單報酬)) < 1e-12)

表中的三列應在浮點誤差內相同,這也檢查了單期報酬公式與複利方向是否一致。所得全期報酬只描述所選起迄日;樣本內累積幅度不等於固定的未來成長率。

交易日與日曆日不同#

相鄰兩列代表相鄰交易日,卻可能跨過週末或休市日。calendar_gap 計算兩個交易日期之間隔了幾個日曆日,並不是在宣告中間遺漏了幾筆交易報酬。

aapl$calendar_gap <- c(NA_integer_, as.integer(diff(aapl$date)))
gap_table <- as.data.frame(table(aapl$calendar_gap, useNA = "no"))
names(gap_table) <- c("相鄰交易日的日曆間隔", "次數")
knitr::kable(gap_table)
相鄰交易日的日曆間隔 次數
1 687
2 6
3 156
4 25

表中多於一天的間隔通常對應週末或休市。若要年度化波動、合併不同市場或對齊總體資料,下一步還要交代交易日曆、時區與缺值規則;直接把日曆日補成 0 報酬會改變樣本的統計性質。

極端日先查來源,不自動刪除#

最後列出絕對對數報酬最大的八個交易日。排序的目的,是把最值得回查原始來源的日期找出來,而不是先把它們排除在分析之外。

valid_rows <- aapl[is.finite(aapl$log_return), ]
extreme_rows <- valid_rows[
  order(abs(valid_rows$log_return), decreasing = TRUE)[1:8],
  c("date", "adjusted", "simple_return", "log_return")
]
knitr::kable(extreme_rows, digits = 6)
date adjusted simple_return log_return
303 2020-03-16 59.64409 -0.128647 -0.137708
302 2020-03-13 68.44997 0.119808 0.113157
2 2019-01-03 34.41423 -0.099607 -0.104924
301 2020-03-12 61.12651 -0.098755 -0.103978
399 2020-07-31 104.94921 0.104689 0.099564
309 2020-03-24 60.79407 0.100325 0.095606
293 2020-03-02 73.58179 0.093101 0.089018
318 2020-04-06 64.63309 0.087237 0.083640

極端報酬可能反映真實市場變動,也可能來自公司行動處理或建檔錯誤。若調整價格與原始來源一致,就應把真實極端日保留,並在後續分配或波動模型中正面處理厚尾;只有查到明確的資料錯誤時才修正。

從這份價格資料可以得到的結論#

這份 AAPL 固定資料顯示,先排序再取落後價格,可以一致地重算簡單報酬與對數報酬;套件作法與手動公式得到相同結果,多期複利也與起訖價格比一致。這些結果仍只是資料處理與歷史描述,沒有說明極端日由什麼事件造成,也沒有提供未來報酬預測。下一步若要研究報酬分配,應保留已查證的極端日並進入 R03;若要預測,則須從資訊可得時點出發,另行安排時間排序的樣本切分。

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