台股回測怎麼避免前視偏誤和生存者偏差?

兩件事分開處理。前視偏誤:不要用期末日對齊財報,要用「這筆數字最早可能被知道的那天」。月營收依規定在次月 10 日前公布,就用次月 10 日;季報用法定申報期限。生存者偏差:股票池要納入已下市個股,否則你的歷史裡只剩活下來的贏家。

先分清楚:這是兩個不同的錯誤

  • 前視偏誤=你在某個時間點用到了「當時還沒公布」的資訊。訊號本身可能是對的,但實單重現不出來。
  • 生存者偏差=你的股票池只有活到今天的公司。下市、被併、下櫃的都被拿掉了,剩下的自然好看。
  • 兩者會疊加:用未公布的財報,挑出的又都是後來沒倒的公司,回測曲線會漂亮到不像話。

前視偏誤:用「最早可能知道的那天」對齊

一家公司 2024 Q1 的財報,期末日是 3/31,但要等到 5 月中才真的公告。把 EPS 對到 3/31,等於在 4 月就用了 5 月才存在的數字。正確做法是替每一筆數字算出一個「知道日」,然後只用知道日已經到了的資料。

月營收:用次月 10 日當保守知道日

台股月營收依規定要在次月 10 日前公布。所以 10 月的營收,最保守的知道日就是 11 月 10 日——在那之前,你不能假設自己知道它。這條規則不依賴任何欄位有值,今天就能用。

import requests
from datetime import date

def knowledge_date_for_revenue(revenue_month: str) -> date:
"""月營收依規定次月 10 日前公布,取次月 10 日為保守知道日。"""
year, month = (int(x) for x in revenue_month.split("-"))
return date(year + (month // 12), (month % 12) + 1, 10)

r = requests.get(
"https://api.twmarketdata.com/v2/datasets/monthly-revenue",
headers={"X-API-Key": "sk_live_你的金鑰"},
params={"symbol": "2330", "limit": 6},
timeout=20,
)
r.raise_for_status()

as_of = date(2026, 7, 1)
for row in r.json()["rows"]:
known_on = knowledge_date_for_revenue(row["revenue_month"])
if known_on <= as_of:
print(row["revenue_month"], row["revenue"], row["yoy"], "可用(知道日", known_on, ")")
else:
print(row["revenue_month"], "→ 在", as_of, "當下還不知道,跳過")

季報:用法定申報期限,算法一樣

財報三表套同一個模式:拿 period_end_date 加上法定申報期限,得到保守知道日。依證券交易法第 36 條,第 1~3 季財報是每季終了後 45 日內,年報是每會計年度終了後 3 個月內。

from datetime import date, timedelta

# 證券交易法第 36 條:Q1–Q3 為季終後 45 日內,年報為年度終了後 3 個月內。
# Q4 用 90 天是「3 個曆月」的保守近似;要精確就改成期末加 3 個曆月。
DEADLINE_DAYS = {1: 45, 2: 45, 3: 45, 4: 90}

def knowledge_date_for_report(period_end: str, quarter: int) -> date:
y, m, d = (int(x) for x in period_end.split("-"))
return date(y, m, d) + timedelta(days=DEADLINE_DAYS[quarter])

rows = requests.get(
"https://api.twmarketdata.com/v2/datasets/income-statement",
headers={"X-API-Key": "sk_live_你的金鑰"},
params={"symbol": "2330", "limit": 8},
timeout=20,
).json()["rows"]

as_of = date(2026, 7, 1)
for row in rows:
known_on = knowledge_date_for_report(row["period_end_date"], row["fiscal_quarter"])
usable = "可用" if known_on <= as_of else "還不知道"
print(row["fiscal_year"], "Q" + str(row["fiscal_quarter"]),
"期末", row["period_end_date"], "保守知道日", known_on, usable, "EPS", row["eps"])

為什麼年報要用 90 天,而不是更短的 75 天

實收資本額達新臺幣 100 億元以上的上市櫃公司(2330 就是其中之一),自 2022 會計年度起年報須於 75 日內申報,比一般的 3 個月更早。既然如此,用 90 天豈不是把知道日算得太晚?正是要這樣。做 PIT 的原則是寧可晚知道、不早知道——把知道日算得保守一點,最多是少用幾天資料;算得太早,就是偷看未來。真的要精準到公司別,就依各公司適用的期限分別設定。

一個更基本的坑:用當天收盤價產生訊號

收盤價要收盤之後才知道。用「當天收盤價」算出訊號、又假設「當天收盤成交」,這是同一天既知道結果又下單。訊號用 T 日收盤算,成交要放到 T+1,回測才對得上實單。

生存者偏差:股票池要放得進已下市的代號

已下市個股跟在市個股打同一支端點,換掉 symbol 就好。目前保留 311 檔已停止交易個股的價格歷史,其中 264 檔有官方登記的下市日;上市價格資料回溯到 2004-02-11。你可以直接拿一個已下市代號去查,確認它真的還在。

def price_history(symbol: str, limit: int = 5):
r = requests.get(
"https://api.twmarketdata.com/v2/datasets/twse-daily-price",
headers={"X-API-Key": "sk_live_你的金鑰"},
params={"symbol": symbol, "limit": limit},
timeout=20,
)
r.raise_for_status()
return r.json()["rows"]

# 換成任何一個已下市代號,查法完全一樣
for row in price_history("2330"):
print(row["date"], row["close"], row["price_method"], row["price_confidence"])

怎麼檢查自己的資料源有沒有這個問題

  • 拿一檔你知道已經下市的代號去查。查不到,你的回測就有生存者偏差。
  • 看它有沒有給你「這筆什麼時候可以被知道」的依據——公告日欄位有值最好,沒有的話至少要有期別,你才能自己套法定公告時點。兩者都沒有,就無法避免前視偏誤。
  • 看缺漏是怎麼呈現的。缺口被靜默補成前一筆或 0,比缺口本身更危險,因為事後看不出來。

我們沒有的東西,先說清楚

  • 沒有即時報價。這裡的定位是歷史與盤後資料,不宣稱即時。
  • 目前沒有一個 as_of 查詢參數可以叫伺服器幫你做時點過濾。
  • 公告日欄位(月營收 announcement_date、財報 report_date)目前尚在回填,實測仍為空值。在回填完成前,本頁的 PIT 一律以法定公告時點為保守依據——這也是為什麼上面的程式碼算的是「知道日」,而不是去讀那個欄位。
  • 資料有缺就標成缺(data_gaps),不會內插或用前值填平。序列被補得漂亮,回測就會騙你。

常見問題

1前視偏誤和生存者偏差,哪一個影響比較大?
看策略。做財報選股的,前視偏誤影響最直接,因為每一季都在用還沒公告的數字。做長期持有或全市場回測的,生存者偏差累積得更兇,因為被拿掉的都是表現最差的那些。兩個都要處理。
2為什麼不能直接用期末日對齊財報?
因為期末日那天財報還不存在。台股季報公告有法定期限但不是當天,2024 Q1 的期末日是 3/31、實際公告在 5 月中。用期末日對齊,等於讓策略提前一個半月知道結果。
3已下市的股票要去哪裡查?
跟在市股票同一支端點,把 symbol 換成那個已下市代號即可。目前保留 311 檔已停止交易個股的完整價格歷史,其中 264 檔有官方登記的下市日。
4有沒有 as_of 這種時點查詢參數?
目前沒有。而且公告日欄位還在回填、實測是空的,所以現在可行的做法是自己用法定公告時點算出「知道日」再篩——月營收用次月 10 日,季報用法定申報期限。好處是你看得見自己篩掉了什麼。

想自己查這些資料?免費註冊

相關文章