台積電 2024 年 6 月 13 日除息 3.4998 元。除息前收盤 909,交易所公告的除息參考價是 905.5。
那 3.5 元的差額不是下跌,是配息——它從股價裡出來,變成現金進你的帳戶。
有趣的是,那天股價還漲了,收在 919。所以你根本看不到「跳空」這件事:
用原始價算當日報酬 919 / 909 − 1 = +1.10%
把配息加回去 (919 + 3.5) / 909 − 1 = +1.49%
差 0.39 個百分點。一天。
一年四次,二十年。台積電從 2006 年到現在:
原始價 61.0 → 2,400 +3,834% 年化 19.41%
還原價 28.21 → 2,400 +8,408% 年化 23.94%
**不還原股價,台積電二十年的報酬會少算一半以上。**每年 3.8 個百分點的複利差距。
而這件事在單日尺度上幾乎看不出來——那天它甚至是漲的。
除權息只是其中一種。這 20 年的台股資料裡,總共有三種公司行為會讓價格跳空,而它們跳的方向不一樣:

筆數 中位係數 範圍
除權息 30,320 0.962 0.09 ~ 1.18
減資 670 1.363 0.92 ~ 40.04
ETF 分割 11 2.000 0.04 ~ 7.00
除權息:配息配股,價格往下跳。係數幾乎都小於 1。
減資:股數被銷除,你手上的股票變少但每股更值錢,價格往上跳。中位數 1.363 表示典型的減資讓股價漲三成六。極端值 40.04 是易福(5364)在 2013 年 12 月的減資——1.15 元變成 46.05 元。
ETF 分割:一單位拆成多單位,價格等比例下降。反分割則相反。只有 11 筆,但其中一筆是 0050。
三種的處理方式是同一條公式:
調整係數 = 事件後的參考價 ÷ 事件前的收盤價
某一天的還原係數 = 那天之後所有事件係數的連乘積
還原價 = 原始價 × 還原係數
方向由係數自己決定,不需要為每種事件寫不同的邏輯。除權息的係數小於 1,會把歷史價格往下調;減資的係數大於 1,往上調。
配息 3.5 元,把歷史價格減 3.5 不就好了?
不行,因為除權息可能同時配股。台積電只配現金,但很多公司是「配息 2 元 + 配股 1 元」,配股會讓股數增加,這不是減法能處理的。而且遇到現金增資、減資後現金增資這種組合,公式會越長越複雜。
交易所每天會公告除權息參考價——那是它自己算好的、把所有成分都納入的結果。我直接用兩個欄位:
before_price 停止買賣前(或除權息前)的收盤價
ref_price 交易所公告的參考價
減資和分割也是同樣兩個欄位。所以三種事件可以合併成同一串,用同一段程式處理:
_RATIO_BOUNDS = {"dividend": (0.01, 1.5), "reduction": (0.05, 100.0),
"split": (0.01, 100.0)}
for kind, src in (("dividend", dividends), ("reduction", reductions),
("split", splits)):
ev = src[["date", "stock_id", "before_price", "ref_price"]].copy()
ev["ratio"] = ev["ref_price"] / ev["before_price"]
lo, hi = _RATIO_BOUNDS[kind]
ev = ev[ev["ratio"].between(lo, hi)]
那個 _RATIO_BOUNDS 是用來擋髒資料的(參考價為 0 之類),不是業務規則。上界我一開始給減資設 20,結果易福那筆 40.04 直接被丟掉——所以現在被擋下來的事件會印出來,不是無聲跳過。
回測引擎最難的地方是你不知道正確答案是什麼。所以我先做一組答案已知的合成資料。
一檔股票,10 天:前 5 天 10 元,第 6 天減資(10 → 20),維持 3 天,第 9 天除息(20 → 18),最後 2 天 18 元。
這檔股票的真實報酬是零——每次跳空都是公司行為造成的,沒有任何真實的漲跌。所以正確的還原序列應該是一條水平線。

完全不還原 10 → 20 → 18 憑空多出 100% 的漲幅
只處理除權息 9 → 18 → 18 減資那天還是有 100% 的假漲幅
除權息 + 減資 18 → 18 → 18 水平線,正確
這個測試的價值在於:**如果我哪天改壞了程式,這條線會立刻不是水平的。**它比「拿真實資料跑一跑看起來合理」可靠得多,因為真實資料沒有標準答案。
0050 在 2025 年 6 月 18 日做了受益權單位分割,一單位拆成四單位。

原始價從 188.65 掉到 47.16。如果不處理,這一天在資料裡就是 −75% 的單日暴跌。
我是怎麼發現的?做 Day 3 的實驗時,0050 的最大回落算出來是 −75.98%。台股 2008 年再慘也沒有這麼慘,所以我去查了,才發現分割這件事,也才發現我的還原股價根本沒有分割的資料源。
補上之後:
0050 最大回落 -75.98% → -55.75%
−55.75% 是 2008 年金融海嘯造成的,那是真的。
分割的資料在 TWSE 的 split/TWTCAU(ETF 分割/反分割恢復買賣參考價格)。它給的是官方參考價:
恢復買賣日期 ETF代號 名稱 分割 停止買賣前收盤 恢復買賣參考價
114/06/18 0050 元大台灣50 分割 188.65 47.16
188.65 ÷ 47.16 = 4.0002。這比我從價格反推「大概是 1 拆 4」可靠得多——反推會把當天的市場漲跌也算進去。
我用的是後復權:最新一天等於實際成交價,越往前調整幅度越大。
這代表每次抓到新的除權息事件,整條歷史序列都會變。台積電今天除息,那麼它 2006 年的還原價明天就跟今天不一樣。
所以 transform 不是抓完資料之後的一次性工作,是每天都要跑的:
python3 -m crawler fetch dividend reduction split
python3 -m crawler transform
我把它放進每日排程的最後一步。如果忘了跑,歷史係數就會過期——而且這種過期不會報錯,只會讓你的報酬計算悄悄偏掉一點點。
實作上有一個細節值得提:交易所的減資表會預告未來兩週的事件。如果把未來事件算進去,後復權會把那個係數乘到每一天,連最新一天都被放大,「最新一天等於實際成交價」這個性質就破了。所以 transform 會先濾掉晚於最後一個交易日的事件:
last_px = px["date"].max()
future = ev["date"] > last_px
if future.any():
log.info("略過 %d 筆尚未生效的事件(晚於最後一個交易日 %s)",
int(future.sum()), last_px.date())
ev = ev[~future]