回測結果不可信,原因很少出在策略邏輯上,更多出在數據管道。一個看起來能跑通的回測,可能因為還原處理錯誤、時間對齊偏差或缺失值填充,得出了完全失真的結論——而且這些錯誤不會報錯,只會讓回測曲線變好看。
這篇文章講回測數據管道的四個階段,以及每個階段最容易出錯的地方。
回測數據管道的四個階段
| 階段 | 目標 | 主要風險 |
|---|---|---|
| 採集與粒度選擇 | 拿到正確粒度的原始數據 | 粒度不足導致信號丟失 |
| 還原處理 | 價格序列可比 | 拆股分紅造成虛假跳空 |
| 時間對齊 | 多標的時間軸一致 | 時區與交易日曆錯位 |
| 儲存與查詢 | 高效可復現地取數 | 查詢口徑不一致 |
四個階段的順序不能顛倒——先採集後還原,再對齊,最後儲存。反過來做會導致大量重複計算。
階段一:粒度選擇決定了策略的上限
粒度選擇的判斷標準很簡單:策略信號的產生頻率,必須高於數據粒度。
- 按日調倉:日線數據足夠,且體積小、計算快。
- 日內策略:至少需要分鐘線,具體取決於信號週期。
- 高頻與微觀結構策略:必須用 tick 數據,且要關注 tick 級的成交方向。
一個常見的錯誤是「用分鐘線回測分鐘級信號」。這樣做會引入系統性偏差:分鐘線只保留該分鐘的 OHLC 四個點,策略在分鐘內的入場與出場價格無法還原,回測結果通常比實際樂觀。
另一個錯誤是反過來——用 tick 數據回測日頻策略。這不會讓結果更準,只會讓計算量和儲存成本高出一個數量級。
階段二:還原處理,最容易出錯的一步
股票發生拆股或分紅時,價格會「跳空」。如果不做還原處理,回測會把這次跳空當成一次真實的暴跌或暴漲,從而產生虛假信號。
還原有前還原與後還原兩種方式,選擇取決於回測類型:
| 方式 | 特點 | 適用場景 |
|---|---|---|
| 前還原 | 以最新價格為基準向前調整歷史價格 | 技術指標計算、圖表展示 |
| 後還原 | 以上市首日為基準向後調整 | 長期收益計算、回測 |
| 不還原 | 保留原始成交價 | 需要真實成交價的場景,如撮合模擬 |
回測場景通常推薦後還原,因為它不會因為新增數據而改變歷史價格,保證回測結果可復現。前還原的歷史序列會隨最新價格變化而變化,用前還原數據做的回測在不同時間跑出來的結果可能不同——這是很隱蔽的復現性問題。
還需要注意:還原因子必須來自可靠數據源,且要覆蓋所有公司行為(拆股、合股、分紅、配股)。漏掉任何一個都會在歷史上留下一個假跳空。
階段三:時間對齊與缺失處理
多標的回測時,時間對齊的核心原則是:不要用「向前填充」掩蓋缺失。
三種缺失需要區別對待:
- 休市:正常情況,從時間軸上移除即可,不應該算作缺失。
- 停牌:該標的當日無交易。回測中應保持倉位不變、標記為不可交易,而不是填充價格。
- 數據缺失:數據源問題,需要告警並補數,絕不能靜默填充。
把停牌當成缺失並向前填充價格,會讓回測產生「零波動區間的虛假持倉」,從而低估風險、高估收益。這是回測失真最常見的原因之一。
階段四:儲存與查詢口徑
最後一步的目標是可復現。同一個回測,在不同時間、不同機器上跑,應該得到完全相同的結果。要做到這一點需要:
- 數據版本化:每次回測記錄所用數據的快照標識或時間範圍,而不是「取最新數據」。
- 查詢口徑固定:明確還原方式、時區、缺失處理規則,並寫進代碼而不是文檔。
- 列式儲存:回測通常只需要少數幾列,列式格式能顯著減少掃描量。
- 保留原始數據:聚合數據可以快速迭代,但原始數據是重算的依據,不應丟棄。
三個最常見的回測數據錯誤
- 倖存者偏差:只用當前仍在交易的標的回測,退市與併購的標的被排除,結果系統性偏樂觀。
- 未來函數:用當日收盤價生成信號,又用當日開盤價成交。這在真實交易中不可能實現。
- 填充掩蓋缺失:用向前填充處理停牌,人為壓低波動率。
這三個錯誤都不會讓回測報錯,只會讓曲線變好看。所以回測系統的第一優先級不是「跑得快」,而是「能發現自己的數據問題」。
Python 實現:回測數據準備
import httpx
import pandas as pd
API_KEY = "your_api_key"
BASE = "https://api.tiqex.io/api/v1/market"
def fetch_candles(symbol: str, market: str, interval: str = "1d") -> pd.DataFrame:
"""拉取 K 線並規範化為 UTC 索引的 DataFrame。"""
resp = httpx.get(
f"{BASE}/{market}/kline",
params={"symbols": symbol, "interval": interval},
headers={"X-API-Key": API_KEY},
timeout=30,
)
rows = resp.json()["data"]
df = pd.DataFrame(rows)
# 1) 時間統一為 UTC,避免跨市場錯位
df["ts"] = pd.to_datetime(df["timestampMs"], unit="ms", utc=True)
df = df.set_index("ts").sort_index()
# 2) 校驗時間戳單調遞增,出現回退說明數據有問題
if not df.index.is_monotonic_increasing:
raise ValueError(f"{symbol} 時間戳非單調遞增,需人工檢查")
# 3) 只保留回測需要的列,降低掃描量
keep = [c for c in ("open", "high", "low", "close", "volume") if c in df.columns]
return df[keep]
def apply_backward_adjustment(df: pd.DataFrame, factors: pd.Series) -> pd.DataFrame:
"""後還原:以上市首日為基準向後調整,保證歷史價格可復現。"""
aligned = factors.reindex(df.index, method="ffill")
out = df.copy()
for col in ("open", "high", "low", "close"):
if col in out.columns:
out[col] = out[col] * aligned
return out
def mark_trading_gaps(df: pd.DataFrame, calendar) -> pd.DataFrame:
"""把休市 / 停牌 / 缺失分開標記,而不是一律向前填充。"""
expected = pd.DatetimeIndex(calendar.sessions_in_range(df.index[0], df.index[-1]))
missing = expected.difference(df.index)
if len(missing) == 0:
return df
# 用顯式標誌記錄,不填充價格
df = df.reindex(df.index.union(missing))
df["no_trade"] = df["close"].isna()
return df
candles = fetch_candles("AAPL", "equity")
print(f"行數 {len(candles)} 區間 {candles.index[0].date()} → {candles.index[-1].date()}")
print(candles.tail(3))
這段代碼體現了三個關鍵約束:時間統一為 UTC、時間戳單調性校驗、缺失用顯式標誌而非填充。apply_backward_adjustment 用後還原保證歷史序列不隨新數據變化,這是回測可復現的前提。
常見問題
回測應該用前還原還是後還原?
通常推薦後還原。後還原的歷史價格不會隨新增數據變化,保證回測結果可復現;前還原的歷史序列會隨最新價格調整,同一策略在不同時間跑出的結果可能不同。
停牌期間應該怎麼處理?
保持倉位不變並標記為不可交易,絕對不要向前填充價格。填充會產生零波動區間的虛假持倉,低估風險並高估收益。
回測需要 tick 數據嗎?
取決於策略週期。日頻與分鐘級策略用 K 線數據即可;只有高頻與微觀結構策略才需要 tick 數據。用 tick 回測日頻策略只會增加成本,不會提高準確性。
為什麼同樣的回測跑兩次結果不一樣?
通常是數據口徑不固定:用了「取最新數據」而不是固定快照,或者用了前還原數據。解決辦法是數據版本化,並把還原方式、時區、缺失規則寫進代碼。
怎麼避免未來函數?
嚴格區分「信號生成時點」與「成交時點」。信號只能使用該時點已經可得的數據,成交價必須用信號之後的價格,並在回測框架裡顯式約束這一點。
下一步
回測數據管道的核心是三個詞:可復現、不填充、顯式標記。完整的數據準備流程見 回測數據準備指南,還原機制的細節見 拆股還原,K 線字段定義見 K 線 OHLCV 字段,場景方案可參考 回測方案。歷史數據的拉取量直接影響 套餐額度,建議先做一次量級估算。