把零散的每日股價整理成「以日期為索引、股票代號為欄位」的時間序列寬表,是所有量化分析的起點;移動平均、波動度、動能訊號、回測,全部建立在這張表上。這篇教學用台股全市場的真實資料,從 pandas 的四個核心工具教起,一路做到一個可重現的時間序列動能(TSMOM)回測,訊號、權重、成本都有程式碼與數據對應,整段流程可以下載重跑。

關鍵數字
回測區間 2018-01-01 至 2026-06-09,策略含 finlab 預設交易成本,0050 為含息買進持有(不含成本,口徑見「回測方法與限制」):
| 指標 | 時間序列動能 TSMOM 12-1 | 0050 含息 |
|---|---|---|
| 總報酬 | 375.8% | 558.5% |
| 年化報酬(CAGR) | 20.53% | 25.05% |
| 日夏普 | 1.08 | 1.22 |
| 月索提諾 | 1.72 | 1.92 |
| 最大回撤(MDD) | -30.8% | -33.96% |
| 平均持股 | 179.7 檔 | 1 檔 |
三件事先說清楚:
- 這段期間 TSMOM 沒有贏過 0050:報酬輸、風險調整後指標也輸,只有最大回撤略淺。本文把它定位成「時間序列工具的完整應用示範」,讓你看懂每一行 pandas 在策略裡的角色,而非推薦你照抄的策略。
- 波動度倒數加權有實際效果:同樣的訊號,從等權改成波動度倒數加權,日夏普從 0.96 升到 1.08,最大回撤從 -37.61% 縮到 -30.8%。
- 月頻換股的成本負擔可控:手續費加倍的壓力測試下,年化報酬從 20.53% 降到 19.22%,結論不變。
時間序列是什麼?為什麼量化分析都從它開始
時間序列(time series)是按時間順序排列的一連串觀測值。對股票來說,最常用的形式是一張二維表:每一列是一個交易日,每一欄是一檔股票,格子裡是當天的收盤價。這張「寬表」之所以重要,是因為 pandas 對它的每一種操作都是向量化的:算 60 日均線是一行程式,算全市場兩千多檔股票的 60 日均線,還是同一行程式。
回測的本質就是在這張表上做時間平移與聚合:用過去的資料算訊號,套到未來的報酬上驗證。所以在寫任何策略之前,先把資料整理成乾淨的時間序列寬表,並熟悉幾個核心操作,後面的事情都會變簡單。想看這套流程在完整交易系統裡的位置,可以先讀程式交易完整指南。
報酬率與波動度:你會反覆用到的三條公式
簡單報酬是兩期價格的變化率:
對數報酬取自然對數:
兩者的關鍵差異在「跨期累積」的方式:簡單報酬要連乘,對數報酬可以相加,
這讓對數報酬在統計分析(加總、平均、迴歸)時特別好用;要報給人看的績效,再轉回簡單報酬。
年化波動度把日報酬標準差按一年約 252 個交易日縮放:
用一個虛構的計算示例走一遍(以下數字純屬示範,非任何真實股票):某檔「示範股」連續三天的收盤價是 100、110、99 元。兩天的簡單報酬是 +10% 與 -10%,連乘 ,累積報酬是 -1%,而非直覺的 0%。對數報酬則是 與 ,兩者相加 -1.01%,正好等於 。再看波動度:若某股日報酬標準差是 2%,年化波動度就是 。對照真實資料,台積電(2330)截至 2026-06-09 的 60 日年化波動度是 34.5%,量級相近。
用 finlab 取得台股時間序列寬表
2020 年這篇文章的第一版,教的是自己寫 TWSE 爬蟲再用 transpose() 拼表(原始做法保留在文末附錄)。現在更穩的主路徑是用 finlab 套件,兩行就拿到整理好的寬表:
顯示程式碼
# pip install finlab
from finlab import data
# 收盤價:date x 股票代號 的時間序列寬表
close = data.get("price:收盤價")
# 還原股價:已調整除權息,適合算報酬與回測
adj = data.get("etl:adj_close")第一次執行時 finlab 會自動引導登入,照指示操作即可。兩張表都是「日期 × 股票代號」的 DataFrame,索引是 DatetimeIndex,可以套用 pandas 所有時間序列功能。
要特別記住 price:收盤價 與 etl:adj_close 的分工:收盤價是市場上看到的原始價格,但除權息當天會跳空,用它算報酬會把配股配息誤判成下跌;還原股價把這些調整補回去,算報酬、算動能、跑回測一律用它。本文後面所有報酬計算都基於 etl:adj_close。
pandas 時間序列四大工具:resample、rolling、shift、pct_change
pct_change:一行算出全市場日報酬
顯示程式碼
# 全市場每日簡單報酬
returns = adj.pct_change()
# 過去 60 個交易日的累積報酬
returns_60d = adj.pct_change(60)pct_change(n) 算的是相隔 n 列的變化率,正是上面簡單報酬公式的向量化版本。
resample:改變時間頻率
顯示程式碼
# 月底取樣:每個月留最後一個交易日的價格
monthly_close = adj.resample("ME").last()
# 月報酬
monthly_returns = monthly_close.pct_change()resample 是「換頻率再聚合」:日資料變月資料、變週資料,聚合方式可以是 last()、mean()、max() 等。月頻再平衡的回測,就是靠它把訊號對齊到每月一次。
rolling:移動視窗統計
顯示程式碼
# 60 日移動平均
ma60 = adj.rolling(60).mean()
# 60 日年化波動度
vol_60d = adj.pct_change().rolling(60).std() * (252 ** 0.5)rolling(n) 在每個時點往回看 n 列,名稱裡的「移動」就是視窗隨日期往前滑。移動平均公式
對應的程式就是 rolling(n).mean()。下圖用台積電示範 resample 與 rolling 的差別:月底取樣是「抽稀」,每月只留一點;60 日均線是「平滑」,每天都有值但把短期雜訊磨掉。

shift:把資料往後搬,避免用到未來
顯示程式碼
# 12-1 動能:過去 240 個交易日的報酬,剔除最近 20 個交易日
momentum_12_1 = adj.shift(20) / adj.shift(240) - 1shift(n) 把整張表往後搬 n 列,是回測防前視偏差的關鍵:今天能用的訊號,只能由昨天以前的價格組成。上面這行同時用了兩個 shift,分子是 20 天前的價格、分母是 240 天前的價格,算出「過去 12 個月剔除最近 1 個月」的報酬,這正是動能研究的標準定義。
真實資料長什麼樣子:厚尾與波動聚集
工具會用之後,看兩個台股時間序列的真實性質。第一個是厚尾:把 2018 至 2026 年流動性池內所有股票的日報酬畫成分佈,超額峰度達 2.26(常態分佈為 0),極端漲跌出現的頻率遠高於常態分佈的預測;分佈兩端 ±10% 附近的突起,是台股漲跌幅限制造成的獨特印記。Cont (2001) 整理了跨市場金融時間序列的典型化事實,厚尾與波動聚集名列前茅,台股完全不例外。

第二個是波動聚集。用 autocorr() 算台積電日報酬的自相關:報酬本身的 lag-1 自相關只有 -0.021,幾乎是零,但「報酬絕對值」的 lag-1 自相關高達 0.148,而且往後二十天都顯著為正。意思是:明天漲跌的方向很難從今天預測,但明天的「波動大小」可以;大波動後面跟著大波動。

這兩個性質有學術根據也有實作意義。Lo & MacKinlay (1988) 用變異數比檢定拒絕了「股價是隨機漫步」的假設,發現指數與投資組合的週報酬存在顯著正自相關,個股層級反而偏弱,與我們算出的 2330 個股自相關接近零一致。實作上,「波動度可預測」正是下一節策略用波動度倒數加權的理由:波動度是少數真的能用昨天預測明天的量。
時間序列動能(TSMOM):把四個工具串成一個策略
動能策略有兩種問法。橫斷面動能問「誰比別人強」:把全市場按過去報酬排名,買前段班;我們在動能投資 18 年實測裡測的就是這種,搭配基本面因子後年化 32.27%。時間序列動能(TSMOM)問的是另一個問題:「這檔股票比自己的過去強嗎?」每檔股票只跟自己比,過去 12 個月(剔除最近 1 個月)報酬為正就持有,為負就空手,不需要橫向排名。
這個機制的出處是 Moskowitz, Ooi & Pedersen (2012):他們檢驗 58 個期貨與外匯市場,發現資產自身過去 12 個月的超額報酬正負號,對未來 1 到 12 個月的報酬有預測力,且部位用波動度縮放後,風險調整績效更好。本文把這個機制搬到台股現股、做純多頭版本:
其中 是訊號指示函數(12-1 自身報酬為正取 1,否則 0), 是個股 60 日年化波動度。波動度越大的股票拿越小的權重,全部權重正規化後總和為 1。下圖把訊號畫在台積電的股價上:綠色底色是 12-1 自身報酬為正的持有區間,可以看到訊號在 2022 年的長空頭裡大段退出,又在趨勢恢復後重新進場,但每次轉折都有遲到的代價。

策略核心程式碼如下,每一行都對應前面教過的工具:
顯示程式碼
import numpy as np
from finlab import data
from finlab.backtest import sim
# 載入還原股價與成交金額
adj = data.get("etl:adj_close")
amount = data.get("price:成交金額")
# 流動性池:近 20 日成交金額移動平均,排名前 250 檔
liquidity = amount.rolling(20).mean()
pool = liquidity.rank(axis=1, ascending=False) <= 250
# 訊號:12-1 自身報酬 > 0(shift 防前視)
momentum_12_1 = adj.shift(20) / adj.shift(240) - 1
signal = (momentum_12_1 > 0) & pool
# 權重:60 日年化波動度的倒數(rolling 算波動)
daily_returns = adj.pct_change()
vol_60d = daily_returns.rolling(60).std() * np.sqrt(252)
weights = (1.0 / vol_60d).where(signal)
position = weights.div(weights.sum(axis=1), axis=0).fillna(0)
# 回測:每月初再平衡,finlab 台股預設成本
report = sim(position, resample="M", upload=False)回測設定與結果
設定整理如下:
| 項目 | 設定 |
|---|---|
| 回測區間 | 2018-01-01 至 2026-06-09(資料快照日) |
| 股票池 | 近 20 日成交金額移動平均排名前 250 |
| 訊號 | 還原股價 12-1 自身報酬 > 0 |
| 權重 | 60 日年化波動度倒數,逐月正規化 |
| 再平衡 | 每月初(sim(resample="M")) |
| 交易成本 | finlab sim() 台股預設:手續費 0.1425% + 賣出證交稅 0.3% |
| 基準 | 0050 還原價買進持有,純指數算術、不含成本 |
互動式回測報告可以操作看每一期持股與績效:
完整結果,含參數變化與成本壓力測試:
| 版本 | CAGR | 日夏普 | 日索提諾 | 月索提諾 | MDD | 總報酬 | 平均持股 |
|---|---|---|---|---|---|---|---|
| TSMOM 12-1 波動度倒數加權(主) | 20.53% | 1.08 | 1.27 | 1.72 | -30.8% | 375.8% | 179.7 檔 |
| TSMOM 12-1 等權 | 21.64% | 0.96 | 1.16 | 1.55 | -37.61% | 413.7% | 181.8 檔 |
| TSMOM 6-1 波動度倒數加權 | 18.48% | 0.99 | 1.16 | 1.53 | -31.5% | 312.4% | 168.9 檔 |
| TSMOM 12-1 流動性前 150 檔 | 21.9% | 1.08 | 1.31 | 1.82 | -33.71% | 422.8% | 112.4 檔 |
| TSMOM 12-1 手續費加倍 | 19.22% | 1.02 | 1.2 | 1.62 | -31.32% | 334.4% | 179.7 檔 |
| 0050 含息買進持有 | 25.05% | 1.22 | 1.67 | 1.92 | -33.96% | 558.5% | 1 檔 |
怎麼解讀這個結果
在本文設定下、2018 至 2026 這段期間,每一個 TSMOM 版本的 CAGR 與夏普都輸給 0050。 主策略年化 20.53% 對 0050 的 25.05%,日夏普 1.08 對 1.22。逐年拆開看更清楚輸在哪:

策略贏的年份是 2021(+31.66% 對 +21.92%)、2022(-18.73% 對 -21.37%)與 2023(+42.26% 對 +27.52%);輸的年份集中在 2024(+11.33% 對 +48.64%)與 2025(+18.77% 對 +36.83%),2019 與 2020 也小輸,圖中 2026 年為截至 6 月 9 日的部分年度(+61.98% 對 +60.0%,策略暫時小幅領先,半年數字不足以下結論)。原因不難理解:這兩年台股的報酬高度集中在台積電與 AI 供應鏈權值股,0050 的市值加權結構天然重押它們,而 TSMOM 平均分散在約 180 檔股票上,波動度倒數加權還會壓低高波動飆股的權重,多頭集中行情裡注定跟不上。2018 年策略也輸(-8.42% 對 -5.46%),因為 12-1 訊號對趨勢反轉的反應有約一個月的延遲,下跌初期來不及退場。
MOP 2012 的核心主張「波動度縮放改善風險調整報酬」在台股是成立的:等權版日夏普 0.96、MDD -37.61%;改成波動度倒數加權後日夏普 1.08、MDD -30.8%,代價是 CAGR 從 21.64% 降到 20.53%。用約一個百分點的年化報酬,換到淺了近 7 個百分點的回撤,這個交換對多數人是划算的。
最後講明:主策略日夏普 1.08,未達本站策略文慣用的 1.5 風險調整門檻,所以本文不把它包裝成可上線的策略,而是當成時間序列機制的完整示範。想看靠大盤訊號控制回撤的另一條路,可以接著讀大盤濾網降低回撤。
穩健性檢查:換參數會不會改變結論
上表已經內建了四個方向的敏感度測試,結論對參數不敏感:
- 回看視窗:12-1 換成 6-1,年化從 20.53% 降到 18.48%,夏普從 1.08 降到 0.99;短視窗訊號更吵、換股更頻繁,沒有更好。
- 加權方式:等權對波動度倒數,上一節已拆解,方向一致。
- 流動性門檻:股票池從前 250 檔縮到前 150 檔,年化 21.9%、夏普 1.08,結果略好但量級不變,結論不靠特定門檻。
- 成本壓力:手續費加倍(模擬滑價與較差的成交品質),年化掉 1.3 個百分點到 19.22%,夏普 1.02,策略沒有被成本擊穿,但也沒有因此翻盤。
四個方向都指向同一件事:台股 2018 至 2026 的長多結構裡,純多頭 TSMOM 跑不贏市值加權的 0050,這不是某組參數挑壞了,是機制本身在這個市場環境的特性。 把它當成防守型的機制元件(空頭退場、波動控管)來理解,比把它當報酬引擎合理。
回測方法與限制
| 項目 | 本文做法 |
|---|---|
| 交易成本 | finlab sim() 預設已內扣手續費 0.1425% 與賣出證交稅 0.3%;另測手續費加倍的壓力組 |
| 滑價 | 未另外假設滑價;手續費加倍組可視為粗略的滑價代理 |
| 股票池 | 全上市櫃中近 20 日成交金額排名前 250;排名用當時點資料,不挑事後存活的股票 |
| 流動性過濾 | 即上述成交金額排名;平均持股約 180 檔,單檔部位小,但本文未估算策略容量 |
| 排除類別 | 未排除金融股、ETF 與 KY 股;高成交金額的 ETF 可能進入股票池 |
| 前視偏差 | 訊號全部由 shift 後的價格組成;僅用價量資料,無財報公布日對齊問題 |
| 權重 | 波動度倒數加權,未另設單檔上限;因持股分散,集中度風險天然有限 |
| 周轉率 | 未統計具體 turnover 數值;月頻再平衡且訊號為慢速動能,換手率屬中等 |
| 樣本內外 | 全段 in-sample,未做樣本外測試;以參數敏感度與成本壓力測試替代部分穩健性檢查 |
附錄:把台股每日爬蟲資料整理成 time series(2020 年原始做法)
這篇文章 2020 年的第一版,示範自己從 TWSE 抓資料再拼成時間序列。以下程式碼保留作教學示意(TWSE 回應格式可能已變動,未持續維護),想走自建爬蟲路線的話,先讀台股每日爬蟲教學確認最新寫法:
顯示程式碼
import requests
from io import StringIO
import pandas as pd
def crawl_price(date):
# 抓 TWSE 單日全市場行情,回傳以證券代號為索引的 DataFrame
datestr = str(date).split(" ")[0].replace("-", "")
r = requests.post(
"https://www.twse.com.tw/exchangeReport/MI_INDEX"
"?response=csv&date=" + datestr + "&type=ALL"
)
lines = [
line.translate({ord(c): None for c in " "})
for line in r.text.split("\n")
if len(line.split('",')) == 17 and line[0] != "="
]
ret = pd.read_csv(StringIO("\n".join(lines)), header=0)
ret = ret.set_index("證券代號")
return ret逐日呼叫存進字典 data 之後,當年最受歡迎的就是這兩行轉換:把每天一張的「股票 × 欄位」表,拼成「日期 × 股票」的收盤價寬表:
顯示程式碼
close = pd.DataFrame({k: d["收盤價"] for k, d in data.items()}).transpose()
close.index = pd.to_datetime(close.index)transpose() 把列與欄對調,to_datetime 讓索引變成可以 resample、rolling 的 DatetimeIndex。原理到今天都沒變,只是 data.get("price:收盤價") 把抓資料、清理、拼表全部做完了,你可以把時間花在策略本身。
常見問題 FAQ
Python 做時間序列分析要用哪些套件?
核心是 pandas(資料整理、resample、rolling)與 numpy(數學運算),畫圖用 matplotlib。台股資料來源用 finlab 的 data.get() 最省事;績效統計另有現成的 ffn 套件,drawdown、年化報酬一行算完。
pandas 的 resample 和 rolling 有什麼差別?
resample 改變資料頻率:日資料變月資料,輸出的列數變少。rolling 保持頻率不變,在每個時點往回看固定長度的視窗做統計,輸出列數不變。月頻換股用 resample,算均線與波動度用 rolling。
計算報酬率該用簡單報酬還是對數報酬?
做統計分析(平均、加總、迴歸)用對數報酬,因為跨期可加;對人報告績效用簡單報酬,因為符合直覺。單日層級兩者差異很小,報酬越大差異越大。
回測為什麼要用還原股價而非收盤價?
除權息當天收盤價會跳空下調,原始收盤價會把配股配息誤算成虧損。還原股價(etl:adj_close)把股利調整回價格序列,算出來的報酬才是含息總報酬。本文所有報酬與回測都用還原股價。
時間序列動能與橫斷面動能差在哪?
時間序列動能是每檔股票跟自己的過去比,報酬為正就持有,市場全弱時可以全數退場;橫斷面動能是股票之間互相排名,永遠持有相對最強的一批,市場全跌時照樣持股。兩者可以共存,動能投資 18 年實測測的是橫斷面版本。
TSMOM 在台股有效嗎?
以本文設定與 2018 至 2026 的窗口,純多頭 TSMOM 年化 20.53%、日夏普 1.08,輸給 0050 含息的 25.05% 與 1.22,僅最大回撤略淺(-30.8% 對 -33.96%)。波動度縮放改善風險調整報酬的效果有重現,但整體跑不贏市值加權大盤;把它當風險控管元件比當報酬引擎合理。
沒寫過 Python 也能跑這個回測嗎?
可以從複製開始。文末的 strategy.py 是完整可執行版本,安裝 finlab 後執行即可重現本文數字;想系統性入門,從量化交易完整指南的學習路徑開始,再回頭逐行理解本文程式碼會快很多。
延伸閱讀
- 程式交易是什麼?教學、優缺點、策略總整理:時間序列只是第一步,這篇給你從策略到下單的全貌。
- Pandas 魔法筆記:財經數據處理常用招式:本文四大工具之外的常用 pandas 技巧速查。
- 利用 Pandas 輕鬆選股:把寬表用在橫斷面篩選的姊妹篇。
- 用 TA-Lib 計算 158 種技術指標:時間序列寬表接上技術指標庫的下一步。
- 台股選股回測 2026:單因子與複合策略實測:因子選股的完整對照實驗。
- 夏普比率與最大回撤:本文績效表所有指標的定義都在詞彙表。
免費註冊 FinLab 即可用 pip install finlab 取得本文使用的全部資料表,跑通第一個回測。
下載資源
| 檔案 | 說明 |
|---|---|
| strategy.py | TSMOM 12-1 完整策略程式碼,可重現本文主策略數字 |
| equity.csv | 主策略、等權版與 0050 的每日淨值序列 |
免責聲明:本文所有策略與回測結果僅供教學與研究用途,不構成投資建議。過去績效不代表未來表現。投資一定有風險,請審慎評估自身風險承受能力。
最後更新:2026-06|回測區間:2018-01-01 至 2026-06-09(資料快照 2026-06-09)|作者:FinLab 量化研究團隊(經量化研究員審閱)
FinLab AI
想建立自己的策略?
用自然語言描述你的選股想法,AI 自動驗證、回測、給你答案
免費開始