ARK · 金融機器學習CHAPTER 19 / 19

CHAPTER 19 / 19 · PART 4 · 量化金融實務

量化交易策略與回測紀律

Quant Trading Strategies & Backtesting

拆解各類策略賺的是什麼錢,建立回測不自欺的紀律,沿一個策略的生命週期收官全課。

§01學習重點

§02課程內容

一、策略動物園:先問這門生意賺的是什麼錢

量化交易的本質,是在單筆勝負幾乎隨機的市場裡,尋找期望值上的微小傾斜,然後靠大量重複把傾斜兌現成穩定利潤。單日看,任何策略都像在擲骰子;拉長到幾百次、幾千次獨立的下注,只要每次的期望值真的偏正,雜訊就會在平均中互相抵銷,利潤曲線慢慢浮出來。所以評估一個策略,第一個該問的問題不是「它去年賺多少」,而是:它的期望值傾斜從哪裡來?憑什麼別人沒把它賺走?

按利潤來源,常見策略大致落進三個抽屜。

第一個抽屜:風險溢酬——你賺的是「替別人承擔他們不想要的風險」的報酬。最典型的是波動率溢酬:市場參與者普遍願意多付一點錢買下跌保護,系統性地賣出這種保護的人,長期收取保費、偶爾承受大賠。價值溢酬(買進便宜資產等待均值回歸)、規模與流動性溢酬(持有小型、難變現的資產)、carry(持有高收益資產對低收益資產)也都有這種「平時穩定收錢、壞時集中虧損」的保費結構。這類策略的報酬分布天生左偏,回測期若剛好沒踩到壞年,數字會漂亮得誤導人。

第二個抽屜:行為偏誤——你賺的是「對手系統性犯錯」的錢。動量是代表:資產近期表現好,後續往往繼續好一陣子。學界對它的主流解釋是投資人對新資訊反應不足——好消息出來,價格只走了一半,因為多數人的預期還錨定在舊觀點上;另一派解釋指向處分效應——投資人急著兌現獲利、死抱虧損,讓價格對資訊的吸收被人為拉長。不管哪個解釋為真,跨市場、跨資產類別的實證都指出這條規律持續存在,這正是第 1 章第七節那個訊號分類器縮影背後的經濟學。

第三個抽屜:結構性服務——你賺的是「市場結構付給服務提供者」的錢。做市商在買賣兩側掛單、賺取價差,本質是收「隨時願意當你對手方」的服務費;指數調整套利賺的是「被動資金必須在特定日換股」的結構性需求。這類錢的特點是單筆極小、極度依賴速度與容量管理,也最容易被競爭壓薄。

比喻: 三家公司都在市場裡賺錢,但生意模式完全不同。第一家是保險公司:平時月月收保費,颱風年一次賠掉好幾年利潤——賺的是風險溢酬,帳面穩定是假象,尾部才是本體。第二家是心理諮商所隔壁的當鋪:專門接住那些急著脫手、情緒性拋售的客人——賺的是別人犯錯的錢,只要人性不變就有生意。第三家是高速公路收費站:不預測任何車要去哪,只對每一輛經過的車收一小筆過路費——賺的是結構的錢,前提是你搶得到那個位置。看策略先看它是哪一家公司,你才知道該擔心什麼:保險公司怕沒見過的災難,當鋪怕客人變聰明,收費站怕別人修一條平行的路。

還有一條與抽屜正交的分類軸:交易頻率。高頻策略持倉以秒、分計,觀測數以百萬計,資料量足以餵養複雜模型,但工程與速度就是護城河的一半;低頻策略持倉以週、月計,十年也只有一百多個獨立的月度觀測,統計上餵不起大模型——頻率越低,越需要經濟邏輯而不是資料量來背書。這條軸決定了本章後面每一個工具的適用劑量。

二、訊號研究流程:從假說到部位,中間隔著成本

一個訊號從念頭到上線,標準流程是一條單行道:假說 → 資料 → 訊號 → 部位 → 成本後檢驗。順序不能反——最常見的職業病,是先翻資料翻出一個好看的規律,再回頭編一個它為什麼成立的故事。正確的姿勢是把零假設設成「這個訊號沒用」,讓證據負舉證責任;假說本身則最好能指向前一節三個抽屜之一:說得出誰在付你錢、為什麼他持續願意付。

從訊號到部位不是一步到位。假設你對每檔資產算出了一個訊號分數(動量強度、價差 z 值、模型輸出的機率),還得決定怎麼把分數變成持倉:最簡單的是排序後做多前 N 名、放空後 N 名、等權重;講究一點的按波動率倒數加權,避免高波動資產霸佔風險預算;最完整的做法是把訊號映射成期望報酬,餵進第 18 章「投資組合建構與風險管理」那套優化機器,順帶處理部位上限與風險約束。分數到部位的每一步都有自由度,而每個自由度都是一個待會兒要算進多重檢定的「嘗試」。

成本模型是回測與白日夢的分界線。實際成交價永遠比模型想像的差一點,差額由四塊組成:手續費與交易稅(固定、可精確建模——台股現貨單邊千分之 1.425 手續費加賣出千分之 3 證交稅,本身就是一道不低的門檻);買賣價差(掛單簿上買一與賣一的距離,流動性越差越寬);市場衝擊(你的單量越大,把價格推離自己的幅度越大——第 10 章講過,這正是「行動改變環境」的第一現場,成本大致隨單量超線性成長);借券成本(放空要先借券,冷門券可能貴到借不到)。工程上常把前三項合併成一個「每次交易抽成 x 個基點」的簡化模型,再對 x 做敏感度分析。

跑一個實驗,看成本怎麼吃掉一個帳面上成立的策略。模擬一個帶輕微正自相關的日報酬序列(動量真實存在的世界),用過去 20 天累積報酬的正負號決定隔天多空:

PYTHON
import numpy as np

rng = np.random.default_rng(7)
T, phi = 2520, 0.06                      # 十年日報酬,自相關 0.06:動量真實存在
eps = rng.normal(0.0, 0.012, T)
r = np.empty(T)
r[0] = eps[0]
for t in range(1, T):
    r[t] = phi * r[t - 1] + eps[t]

look = 20
sig = np.array([np.sign(r[t - look:t].sum()) for t in range(look, T)])
ret_fwd = r[look:]                        # 訊號只用 t 之前的資料,賺 t 當天
turn = np.abs(np.diff(sig, prepend=sig[0]))   # 每日換手(部位翻面記 2)
ann = np.sqrt(252)

print(f"平均每日換手 {turn.mean():.3f}")
for bps in (0, 5, 20):
    net = sig * ret_fwd - turn * bps / 10000.0
    print(f"單邊成本 {bps:>2d} bp|年化 Sharpe {net.mean() / net.std() * ann:5.2f}")
# 輸出:
# 平均每日換手 0.171
# 單邊成本  0 bp|年化 Sharpe  0.60
# 單邊成本  5 bp|年化 Sharpe  0.49
# 單邊成本 20 bp|年化 Sharpe  0.14

零成本世界年化 Sharpe 0.60,5 個基點剩 0.49,20 個基點只剩 0.14——同一個訊號,成本假設決定它是資產還是廢紙。一個好用的體檢指標是損益兩平成本:毛日均報酬除以日均換手,本例是 \(0.000451 / 0.171 \approx 26\) 個基點——只要單邊全成本超過 26 個基點,這個策略在數學上就不可能賺錢。把這個數字跟你實際能拿到的成交條件對照,很多研究在這一步就該停了。這也呼應第 12 章那場「IC 冠軍輸給淨 Sharpe」的翻轉實驗:預測得準與行動之後淨賺,從來是兩回事。

三、回測的三大殺手

回測的目標只有一句話:忠實模擬「如果當年真的這樣做交易」會發生什麼。三個殺手,各自從一個方向破壞這種忠實。

殺手一:前視偏差——讓「當時不可能知道的資訊」滲進決策。粗的版本容易防:用未來資料算訊號、標籤跨進訓練集,第 4 章已立下「沿時間切+交界挖 gap」的規矩。細的版本才要命:用整段樣本估好參數再回去跑回測(參數本身就是未來的函數——正確做法是讓參數隨回測推進逐期重估,每一期只用「當時已有」的資料);總體經濟數據拿「事後修正值」而不是「當時發布值」,或忘了它發布本來就落後一兩個月;模型用當天收盤價算訊號、又假設能以同一個收盤價成交——現實中你得在收盤前跑完模型送出委託,用的必然是更早的價格。對付細版本有個實用的體檢法:把訊號整體延後一天再跑一次回測,若績效大幅衰減,說明利潤高度集中在「訊號誕生的那一瞬間」,上線後的執行延遲會把它磨掉大半。

殺手二:存活者偏差——回測的股票池只裝了「活到今天」的公司。下市、違約、被合併的股票從資料裡消失,等於偷偷刪掉了最慘的那批做多對象。做多為主的選股策略被這個偏差灌水最兇;多空對沖策略兩邊各吃一半偏差,反而部分抵銷。解法沒有捷徑:用含下市股票的時點正確(point-in-time)資料庫,成分股名單也要用「當時的名單」而不是今天的。

殺手三:過擬合——模型把訓練期的雜訊當規律背了下來。第 1 章的 U 形曲線、第 4 章的正則化講的都是它,但交易場景有個放大器:金融資料訊噪比極低、非平穩、樣本有限,連刻意節儉的模型都可能在不知不覺中過擬合。實務界的共識是把門檻設高:策略的每一個參數都要有經濟理由才准進場,參數在不同時期、不同市場上的估計值要穩定;一個訊號如果只在特定十年、特定市場、特定參數窗下成立,它大概率不是規律,是巧合。

比喻: 回測報表是試衣間裡的鏡子。燈光是暖的、角度是挑過的、鏡子還悄悄把人拉長了三公分——你在裡面看到的自己,是所有條件都對你有利時的自己。前視偏差是「先看了照片才挑衣服」,存活者偏差是「架上只掛賣得動的尺寸」,過擬合是「這件衣服只合你今天的姿勢」。真正的考驗是走出店門被路人隨手一拍:光線隨機、角度隨機、姿勢隨機——那才是樣本外。買衣服前你至少該問一句:這面鏡子,跟街上的差多少?

三個殺手有一個共同的解毒劑方向:讓回測環境更像上線環境。但還有一種偏差,連完全誠實的單次回測都躲不掉——它來自「你跑了很多次」這件事本身。

四、多重檢定:最佳回測是被選出來的

假設你老老實實做了一個回測:沒有前視、資料乾淨、模型節儉,回測 Sharpe 1.5。這個數字能信嗎?還不能——先回答一個問題:你總共試了幾個版本?

每個回測 Sharpe 都是一個帶雜訊的估計值。試一次,雜訊是雜訊;試一千次然後挑最好的,你等於從一千個雜訊裡挑了最大的那個——被選中的那份「績效」有相當一部分不是技能,是運氣的極值。這就是多重檢定問題,量化研究裡最隱蔽也最普遍的自我欺騙。用模擬直接量出它的大小——一千個真實期望報酬為零的策略,挑回測最好的:

PYTHON
import numpy as np

rng = np.random.default_rng(19)
T, ann = 756, np.sqrt(252)        # 三年日報酬

def best_sharpe(n_strat, n_rep=200):
    """n_strat 個純雜訊策略中挑最好者的年化 Sharpe(重複 n_rep 次取平均)"""
    best = []
    for _ in range(n_rep):
        r = rng.normal(0.0, 0.01, size=(n_strat, T))   # 真實期望報酬 = 0
        sh = r.mean(axis=1) / r.std(axis=1) * ann
        best.append(sh.max())
    return np.mean(best)

for n in (1, 10, 100, 1000):
    print(f"N={n:>4d}|最佳回測 Sharpe 平均 {best_sharpe(n):.2f}")
# 輸出:
# N=   1|最佳回測 Sharpe 平均 0.01
# N=  10|最佳回測 Sharpe 平均 0.92
# N= 100|最佳回測 Sharpe 平均 1.45
# N=1000|最佳回測 Sharpe 平均 1.88

世界裡沒有任何可賺的錢,但試一千個組合再挑冠軍,回測 Sharpe 平均 1.88——一個會讓多數資金方心動的數字,全部由雜訊構成。理論上這個膨脹的量級是 \(\sqrt{2\ln N}\):

$$ \mathbb{E}\big[\widehat{\mathrm{SR}}_{\max}\big] \;\lesssim\; \sqrt{\frac{2\ln N}{T}} \times \sqrt{252} $$

逐項拆解:\(N\) 是你嘗試過的策略版本數,\(T\) 是回測天數;\(\sqrt{2\ln N}\) 是「\(N\) 個標準常態變數的最大值」的典型大小——注意它只隨 \(\ln N\) 成長,所以每多試十倍,膨脹加一個固定量;除以 \(\sqrt{T}\) 是因為樣本越長、單一 Sharpe 估計的雜訊越小;乘 \(\sqrt{252}\) 把日頻換算成年化。代入 \(N=1000\)、\(T=756\):上限約 2.15,模擬值 1.88,同一個量級。這條公式反過來讀更有用:回測期越短、嘗試次數越多,你需要越高的回測 Sharpe 才能自證不是運氣

這正是 deflated Sharpe ratio(折減 Sharpe)的直覺:把回測 Sharpe 拿去跟「\(N\) 次嘗試下純運氣能達到的期望極值」比,而不是跟零比。你的 1.5 如果是試了三個版本得到的,遠超雜訊基準線,值得認真對待;如果是掃過一千組參數挑出來的,連運氣的期望值都沒跑贏。誠實的研究流程因此必須記帳:每一次調參數、換宇宙、改進出場規則,都是一次嘗試,都要算進 \(N\)。刪掉失敗實驗的紀錄,等於在統計上湮滅證據。

比喻: 辦一場一萬人的擲硬幣大賽,每人擲十次,正面多者晉級。賽後你找到一位「十連正」的選手——這不需要任何解釋,一萬人裡本來就該出現九、十位。真正的問題是主辦方的海報只印冠軍,不印參賽人數:看到「十連正」你該問的第一句話永遠是「總共多少人參賽」。回測報告也一樣——Sharpe 多高是第二個問題,試了幾次才是第一個。

五、時序資料的正確切法:兩層 walk-forward 與 purged CV

對付多重檢定的第一道防線,是把「選參數」與「評成績」分開,而且兩層都要沿時間走。第 6 章已經示範過第一層:評估模型必須 walk-forward——訓練永遠在測試之前,隨機打散會把誤差低估到千倍之外。本章補上第二層,也是實務裡最常被漏掉的一層:連「挑哪組參數」這個動作本身,都只准用當時已有的資料。全樣本掃參數挑最好、再報同一段樣本上的績效,是變相的前視——參數是看完整部歷史之後選的。量一下這兩種做法差多少。模擬一個制度會漂移的市場(自相關係數每兩年換一次正負與大小),候選參數是動量回看窗長:

PYTHON
import numpy as np

rng = np.random.default_rng(42)
T = 2520                                   # 十年;制度每兩年漂移一次
phis = np.repeat(rng.uniform(-0.10, 0.10, T // 504 + 1), 504)[:T]
eps = rng.normal(0.0, 0.012, T)
r = np.empty(T); r[0] = eps[0]
for t in range(1, T):
    r[t] = phis[t] * r[t - 1] + eps[t]

LOOKS, ann = (5, 10, 20, 60, 120), np.sqrt(252)

def strat_ret(lb, t0, t1):
    """回看 lb 天的動量策略在 [t0, t1) 的每日報酬"""
    return np.array([np.sign(r[t - lb:t].sum()) * r[t]
                     for t in range(max(t0, lb), t1)])

def sharpe(x):
    return x.mean() / x.std() * ann if len(x) and x.std() > 0 else 0.0

# 做法一:全樣本挑最好的回看窗,績效也在同一段資料上報
full = {lb: sharpe(strat_ret(lb, 0, T)) for lb in LOOKS}
best = max(full, key=full.get)
print(f"全樣本優化:挑 lookback={best},報表 Sharpe {full[best]:.2f}")

# 做法二:walk-forward——每年只用「至今為止」的資料挑參數,下一年實戰
oos = []
for y in range(2, 10):
    t0, t1 = y * 252, (y + 1) * 252
    tr = {lb: sharpe(strat_ret(lb, 0, t0)) for lb in LOOKS}
    oos.append(strat_ret(max(tr, key=tr.get), t0, t1))
print(f"walk-forward:樣本外 Sharpe {sharpe(np.concatenate(oos)):.2f}")
# 輸出:
# 全樣本優化:挑 lookback=20,報表 Sharpe 0.43
# walk-forward:樣本外 Sharpe -0.04

同一個市場、同一族策略:全樣本優化的報表寫 0.43,walk-forward 的誠實答案是 −0.04——這個市場的動量規律漂移得太快,「用過去挑的參數」到了下一年已經失效,而全樣本優化把每段制度各自最順的表現拼成了一張不存在的成績單。0.43 與 −0.04 之間的落差,就是「參數層前視」的價格。

工程落地有現成工具:scikit-learn 的 TimeSeriesSplit 直接產生「訓練永遠在前」的切分,gap 參數就是第 4 章說的交界隔離帶。但金融標籤還有一個特有的麻煩:標籤常是未來一段窗口的累積報酬(例如未來 20 天報酬),相鄰兩天的標籤共享 19 天的未來——它們根本不是獨立觀測。這時光切開還不夠,要 purged CV(淨化交叉驗證):把訓練集中「標籤窗口與測試集重疊」的樣本整批剔除(purge),再把緊貼測試集之後的一小段也挖掉(embargo,防止訊息透過殘餘的自相關滲透)。不淨化會發生什麼、淨化後真相如何,作業三讓你親手量——預告一句:在一個完全無訊號的世界裡,未淨化的交叉驗證能讓一個死背訓練資料的模型「贏過基準 48%」。

六、回測報表怎麼讀:每個數字都在度量一件事,也都在隱瞞一件事

一份標準回測報表的主角們,值得逐一過堂。

年化報酬與年化波動。日均報酬乘 252 換算年報酬;波動年化要乘 \(\sqrt{252}\)——因為獨立增量下變異數隨時間線性疊加,標準差隨 \(\sqrt{T}\) 成長。這個 \(\sqrt{T}\) 法則的前提是報酬近似無自相關:策略報酬若有正自相關(趨勢型策略常見),日頻年化會低估真實波動;負自相關(均值回歸型)則高估。同一策略用日報酬與月報酬各算一次年化波動,兩個數字的差距本身就是有用的診斷。

Sharpe。風險調整後報酬的通用貨幣:

$$ \mathrm{SR} = \frac{\mathbb{E}[R] - r_f}{\sigma} $$

逐項拆解:分子是策略期望報酬減無風險利率 \(r_f\)(多空自融資的組合常直接省略 \(r_f\),因為本金仍可放在無風險資產生息);分母 \(\sigma\) 是報酬標準差。若報酬近似常態,Sharpe 還能翻譯成「賠錢的機率」:單期虧損機率約為 \(\Phi(-\mathrm{SR})\)——Sharpe 1 對應約 16%、Sharpe 2 對應約 2%。這個翻譯是絕佳的照妖鏡:有人給你看 Sharpe 4 的回測,等於宣稱「幾乎每年必賺」,你的第一反應不該是興奮,該是回到第四節問他試了幾次。Sharpe 最大的盲點是只看前兩階動差:一個賣保險型策略(第一節的第一個抽屜)可以長期展示漂亮的 Sharpe,把災難全部藏在還沒發生的尾部。

Sortino 與 Calmar。兩個補洞的變體:Sortino 把分母換成「只計下跌日」的下行標準差——漲的波動不該算罪;Calmar 把分母換成最大回撤,直接用歷史上最痛的一段當風險刻度。最大回撤(MDD)本身是「從歷史高點到其後最低點的最大跌幅」,它對觀測期極度敏感——十年的 MDD 天然比三年的深,跨策略比較必須同期;而且它是單一路徑的極值,樣本誤差大,別把 −18% 與 −22% 的差異當成訊號。

換手率與槓桿。換手率度量部位翻動的速度,直接決定成本敏感度——第二節的損益兩平成本就是它的應用;槓桿(部位絕對值總和相對本金的倍數)度量策略對融資條件的依賴——高槓桿策略的隱藏風險不在市場,在「壞日子裡被抽銀根」:回撤觸發融資方縮限額度,被迫在最差的價格減倉,這種「路徑上的強制平倉」是回測裡最難模擬的東西之一。

最後是上線後的必然衰減。就算研究流程無懈可擊,實盤 Sharpe 通常仍低於回測——殘存的過擬合、制度漂移、同行擁擠三股力量都往同一個方向壓。業界流傳的粗糙折減法是「回測 Sharpe 砍半再說」;比折減更重要的是逐日對帳:每天把實盤損益跟回測引擎在同一天的模擬損益對照,兩者若系統性分歧,通常是成本模型或執行假設出了錯——這是少數上線後還能修的東西,值得做成日常儀表板。

七、機器學習進場:正確的位置與失敗的方式

前面十八章的機器學習軍火庫,進入策略管線時有三個正確的插槽。

插槽一:訊號生成。把「預測下期報酬」改造成分類問題(漲跌方向、或分五檔的報酬強度)往往比直接迴歸連續報酬穩健——報酬的訊噪比太低,估計連續值的誤差常常淹沒訊號本身,這是第 1 章「問題形塑重於演算法」的又一次應驗。另類資料是這個插槽的主戰場:新聞情緒分類、財報電話會文字的語氣分析,本質都是「用機器學習把非結構化資料翻譯成訊號」。第 5 章的可解釋性工具在這裡不是裝飾——訊號要過投委會,你得說得出它在看什麼。

插槽二:組合建構。機器學習在共變異結構上的用武之地常被低估:對資產做階層分群、在分群結構上分配風險,避開了直接求逆共變異矩陣的數值地雷;對報酬做分群還能當制度偵測器用——市場資料常自然聚成「風險開/風險關」兩群,各資產在兩種制度下的行為差異一目了然。這一路的方法不預測任何報酬,只整理結構,反而是 ML 在低頻投資裡最穩的落點。

插槽三:執行。訂單簿的世界觀測以百萬計、回饋以毫秒計,是金融裡少數「資料量配得上深度模型」的地方:用深度網路讀限價簿預測微觀價格變動(DeepLOB 一系工作)、用強化學習學拆單與對沖時機(第 10 章的最適執行),都在這個插槽。頻率越高,ML 的比較優勢越大——這與第一節的頻率光譜完全一致。

失敗的方式則高度雷同,四個結構性障礙輪流出現:資料太少(低頻策略十年只有一百多個月度觀測,餵不起深模型——複雜度必須配得上資料量);訊噪比太低(可預測性本身就是被套利的對象,訊號一被發現就開始衰減);非平穩(市場制度會換,訓練分布不承諾等於未來分布——第 1 章房貸縮影的教訓在交易裡每天重演);可解釋性(黑盒訊號難以歸因,出問題時你不知道該修模型還是該關策略)。還有一個容易忽略的細節:機器學習的超參數調校靠交叉驗證,而金融時序的交叉驗證必須用第五節的淨化切法——用 sklearn 預設的打散 K-fold 調超參數,等於在調參層重演前視偏差。第 12 章講過的端到端觀點在此收攏成一句話:特徵、模型、部位、成本要在同一個目標函數裡對話,任何一段被單獨優化,都會產出對整體有害的局部冠軍

八、上線之後:紙上交易、容量與衰減

回測通過只是拿到入場券。標準的上線階梯是:紙上交易 → 小額實盤 → 逐步加碼,每一階都在測回測測不到的東西。

紙上交易(用實時行情模擬下單、不動真錢)的價值不在「再確認一次會賺」,而在抓工程與假設的錯:訊號計算的時序在實時環境下對不對、委託在當時的盤口真的成交得掉嗎、資料饋送的延遲有多大。小額實盤則開始量真實的成交品質——滑價比想像大多少、借券利率是多少——並啟動第六節說的逐日對帳。

加碼階段撞上的牆叫容量:策略的期望報酬不隨資金線性放大。規模變大,單量變大,市場衝擊成本隨之超線性成長,某個規模之後增量資金的邊際報酬歸零甚至轉負。每個策略都有自己的容量上限——高頻做市可能只容得下小資金,低頻價值策略容量大但報酬慢。把一個小容量策略的回測 Sharpe 乘上十倍資金去募資,是這個行業經典的自欺套路。

就算規模控制得當,還有最後一個對手:衰減。alpha 是有壽命的。你能發現的規律,別人也能;越多資金追逐同一個訊號,進場價越差、規律被抹平得越快。監控衰減的訊號包括:實盤 Sharpe 持續走低、訊號的預測相關性逐季下滑、擁擠指標(同類策略的持倉重疊度)上升。應對只有兩條路——策略組合化(多個低相關的訊號輪流扛,單一訊號衰減不至於致命;分散是這一行少數接近免費的午餐)、以及接受「研究是常態而非一次性專案」:管線要持續產出新假說,舊訊號要有退役機制。

比喻: 一位釣客找到了一個祕密釣點,漁獲穩定得不像話。他犯的第一個錯是租了一條更大的船——船一大,馬達聲把魚嚇散了一半:這是容量,釣點的產量不隨船的噸位放大。第二個錯是跟釣友炫耀了座標——三個月後那裡停滿了船,每艘的漁獲都掉到不如在家門口亂釣:這是衰減,規律的價值與知道它的人數成反比。老釣客的做法是同時養著五、六個小釣點,輪流去、絕不張揚,並且接受每個釣點終有枯竭的一天——真正的資產不是任何一個釣點,是持續找到新釣點的能力。

九、收官:一個策略的生命週期,走完十九章

課程走到終點。第 12 章曾以「監督式學習 → 時序 → 決策」的方法論光譜總結前三部;這裡換一個視角收官——不看方法,看一個真實策略從生到死會依序經過誰

假說期:你需要知道錢可能藏在哪裡——量化金融的世界觀(第 13 章)、利率、信用與外匯等各市場的地形(第 17 章),以及機器學習為什麼在此刻有機會(第 1 章)。資料期:清洗、對齊、時點正確性(第 13 章的資料工程),是後面一切的地基。訊號期:橫斷面預測的全套武器——線性到非線性(第 4 章)、貝葉斯的不確定性語言(第 2、3 章)、模型給出的答案要能被解釋(第 5 章);時序結構另有一套——平穩性與記憶(第 6 章)、隱狀態(第 7 章)、深度序列模型(第 8 章)。檢驗期:本章的主場——成本、三大殺手、多重檢定、兩層 walk-forward。定價與對沖知識在衍生品策略裡貫穿全程:隨機模型與風險中性定價(第 14 章)、定價實務(第 15 章)、Greeks 與波動率曲面(第 16 章)。組合期:訊號變成部位、部位變成風險——建構與風險管理(第 18 章)。執行期:預測讓位給決策——強化學習的框架(第 9 章)、最適執行與動態組合(第 10 章)、從專家行為反推目標(第 11 章)。運營期:上線、對帳、監控衰減,然後回到假說期——前沿的端到端觀點(第 12 章)提醒你,這個迴圈本身也在被機器學習重塑。

十九章合起來只講了一件事:金融機器學習的難,從來不在模型——模型是全行業共享的公共財;難在驗證的紀律。資料會騙人、回測會騙人、你自己挑結果的手最會騙人。這門課如果只留下一個習慣,希望是這個:看到任何一個好看的數字,先問它是怎麼被算出來的、試了幾次才出現、在它沒看過的世界裡還成不成立。這個習慣在模型換代之後依然值錢——而模型,一定會換代。

§03原書對照

本章整合 Kelliher《Quantitative Finance with Python》第 20 章(量化交易模型,pp.545–592)與第 21 章(機器學習整合,pp.593–626)的骨架。以下內容原書有、本課未展開。其一,配對交易的完整方法論:以共整合檢定(Engle–Granger 兩步法、Johansen 檢定)找股票對、對沖比率迴歸、以價差 z 分數設進出場與停損門檻,並以 2010–2020 年黃金對金礦股 ETF 為實例——該例雖未通過平穩性檢定,帳面損益卻頗可觀,作者藉此示範「樣本內校準對沖比率」的美化效果(pp.562–565)。其二,PCA 統計因子策略:相關矩陣特徵分解、特徵組合(eigenportfolio)作為因子、對殘差做均值回歸交易,含 S&P 500 前二十個主成分解釋逾 95% 變異的實測(pp.567–571)。其三,因子模型建訊號的三條路——預測式迴歸、同期迴歸殘差的自相關、因子本身的自相關(pp.565–567)。其四,選擇權系統性策略家族:賣出賣權、賣跨式與勒式的短波動策略、隱含對實現波動率的相對價值、偏斜與期限結構交易、指數對成分股的離散度交易,及 QQQ 掩護性買權回測的實跑損益與左偏報酬分布(pp.576–586)。其五,策略組合的分散化數學:兩策略在不同相關係數下的 Sharpe 曲線、N 個不相關策略的 Sharpe 隨 \(\sqrt{N}\) 成長,書中引述「diversification is the only free lunch in finance」(p.590)並主張與其打磨單一策略不如累積低相關的建構塊(pp.586–591)。其六,Sharpe 對虧損機率的完整對照表與 0.5 到 2 之間各檔位的解讀、上線後 Sharpe 折減的三種經驗法則(pp.557–560)。其七,系統化與主觀投資的哲學對照:持續曝險收割溢酬對比擇時捕捉資料外的資訊,以 2020 年疫情初期為例(pp.591–592)。其八,動態參數估計的操作細節:在回測中逐期重估係數以避免前視,以及樣本內單一係數校準何時可接受的討論(pp.548–549)。其九,第 21 章對機器學習專案階段的完整拆解:資料蒐集、清理(視覺化、離群值、探索性分析)、訓練/驗證/測試三分、k-fold 交叉驗證調超參數,並對照計量經濟學傳統上只切兩份的差異(pp.594–597)。其十,演算法細節:probit 與 logistic 迴歸的概似推導、SVM 分離超平面的拉格朗日對偶與核技巧(RBF、sigmoid、多項式核)、混淆矩陣(pp.612–621);k-means 與階層分群、三種距離度量、以 AIC 選群數、股債 ETF 的風險開關分群實例(pp.604–610);SPY 隔日方向的 SVM 訊號實例(p.622);特徵重要性與敏感度分析(pp.622–623);RL 對沖與執行、信用風險分類、NLP 另類資料、深度學習加速波動率曲面校準四個應用速寫(pp.624–626)。

§04作業和解答

作業一:冠軍策略的明天

延續第四節的模擬:一千個真實期望報酬為零的策略,在三年回測期裡選出 Sharpe 最高的冠軍。(1) 把這個冠軍拿到全新的三年資料上重測,重複一百輪,報告冠軍的回測 Sharpe 與新期間 Sharpe;(2) 用 deflated Sharpe 的語言解釋這組數字;(3) 若一個真實策略試了 N=20 個版本、回測 T=756 天、報表 Sharpe 1.1,用第四節的公式估算純運氣基準線,判斷這個 1.1 值不值得繼續投入。

解答 SOLUTION
PYTHON
import numpy as np

rng = np.random.default_rng(19)
T, N, ann = 756, 1000, np.sqrt(252)

best_is, best_oos = [], []
for _ in range(100):
    r_is = rng.normal(0.0, 0.01, size=(N, T))    # 回測期:三年
    r_os = rng.normal(0.0, 0.01, size=(N, T))    # 上線後:新的三年
    sh_is = r_is.mean(axis=1) / r_is.std(axis=1) * ann
    k = sh_is.argmax()                            # 選出回測冠軍
    best_is.append(sh_is[k])
    best_oos.append(r_os[k].mean() / r_os[k].std() * ann)

print(f"冠軍回測 Sharpe 平均 {np.mean(best_is):.2f}")
print(f"同一策略、新三年     {np.mean(best_oos):+.2f}(標準差 {np.std(best_oos):.2f})")
# 輸出:
# 冠軍回測 Sharpe 平均 1.88
# 同一策略、新三年     +0.05(標準差 0.58)

(1) 冠軍的回測 Sharpe 平均 1.88,新三年平均 +0.05、標準差 0.58——與「隨便抓一個策略」無異。選擇過程只挑出了運氣,而運氣不會延續。

(2) deflated Sharpe 的語言:冠軍的 1.88 該對照的基準不是 0,而是「一千次嘗試下純雜訊的期望極值」(約 1.9)。1.88 沒有超出這條基準線,折減之後的證據強度是零——這 1.88 完全可以由「試得夠多」解釋,不需要「有技能」這個假設。

(3) \(N=20\)、\(T=756\):純運氣基準 \(\approx \sqrt{2\ln 20 / 756} \times \sqrt{252} = \sqrt{5.99/756} \times 15.87 \approx 1.41\)。報表 Sharpe 1.1 低於二十次嘗試的運氣基準線,不足以自證含有真訊號。注意這不等於「策略必然無效」——它只說明現有證據不夠;想繼續投入,正路是凍結參數後累積更長的樣本外紀錄(拉大 \(T\)、鎖死 \(N\)),讓基準線降下來。

作業二:動量策略的成本帳

延續第二節的模擬(自相關 0.06、回看 20 天、日頻調倉)。(1) 計算策略的損益兩平成本;(2) 有人建議「改成每 5 天才調一次倉,換手降了成本就省了」——實作並檢驗,在 0 與 10 個基點兩種成本下比較日頻與 5 天頻的淨 Sharpe;(3) 解釋為什麼降頻在這裡失敗,以及什麼條件下降頻才是有效的省成本手段。

解答 SOLUTION
PYTHON
import numpy as np

rng = np.random.default_rng(7)
T, phi = 2520, 0.06
eps = rng.normal(0.0, 0.012, T)
r = np.empty(T); r[0] = eps[0]
for t in range(1, T):
    r[t] = phi * r[t - 1] + eps[t]

look, ann = 20, np.sqrt(252)
sig = np.array([np.sign(r[t - look:t].sum()) for t in range(look, T)])
ret_fwd = r[look:]

# (1) 損益兩平成本
turn0 = np.abs(np.diff(sig, prepend=sig[0]))
be = (sig * ret_fwd).mean() / turn0.mean() * 10000
print(f"毛日均報酬 {(sig * ret_fwd).mean():.6f}|損益兩平成本 {be:.1f} bp")

# (2) 降頻調倉:每 k 天才准動一次部位
for k in (1, 5):
    pos = sig.copy()
    for t in range(len(pos)):
        if t % k:
            pos[t] = pos[t - 1]
    turn = np.abs(np.diff(pos, prepend=pos[0]))
    for bps in (0, 10):
        net = pos * ret_fwd - turn * bps / 10000.0
        print(f"每 {k} 天調倉|換手 {turn.mean():.3f}/日|{bps:>2d} bp|"
              f"Sharpe {net.mean() / net.std() * ann:5.2f}")
# 輸出:
# 毛日均報酬 0.000451|損益兩平成本 26.3 bp
# 每 1 天調倉|換手 0.171/日| 0 bp|Sharpe  0.60
# 每 1 天調倉|換手 0.171/日|10 bp|Sharpe  0.37
# 每 5 天調倉|換手 0.088/日| 0 bp|Sharpe  0.22
# 每 5 天調倉|換手 0.088/日|10 bp|Sharpe  0.11

(1) 損益兩平成本 26.3 個基點:單邊全成本高於此,策略必虧。

(2) 降頻確實把換手從 0.171 砍半到 0.088,但 10 個基點下的淨 Sharpe 反而從 0.37 掉到 0.11——省下的成本遠補不上毛利的損失(毛 Sharpe 從 0.60 崩到 0.22)。

(3) 失敗的原因是訊號速度與調倉頻率的錯配:這個世界的自相關只有一天的記憶(AR(1)),動量訊號的預測力集中在「翻面後的頭幾天」,延遲執行等於拿著過期的預測硬做。降頻省成本的前提是訊號本身是慢的——半衰期以週、月計的訊號(如價值、低頻動量),晚幾天執行毛利幾乎不損失,此時降頻才是純賺。一般法則:先量訊號的半衰期,再定調倉的頻率;半衰期短於成本能容忍的持有期的訊號,正確的處置不是降頻,是放棄(或者去找成本更低的市場執行它)。這正是第 12 章「特徵選擇要服務長期目標」在單一策略上的重演——快訊號的預測力再高,過不了成本這關就不是 alpha。

作業三:purged 時序 CV 的最小實作

構造一個「完全無訊號」的世界:特徵是三個緩慢漂移的市場狀態變數(隨機漫步),標籤是未來 20 天的累積報酬(純雜訊)。用最會死背的 1-最近鄰模型,比較三種交叉驗證的 MSE:(1) 打散式 K-fold(sklearn 預設 shuffle 的陷阱);(2) 連續區塊 K-fold(不淨化);(3) 連續區塊+purge+embargo。以「一律猜零」的基準 MSE 為參照,解釋三個數字各自說了什麼。

解答 SOLUTION
PYTHON
import numpy as np

rng = np.random.default_rng(4)
T, H = 1000, 20
X = np.cumsum(rng.normal(0.0, 1.0, size=(T, 3)), axis=0)  # 三個慢變數
n = rng.normal(0.0, 0.01, T + H)
y = np.array([n[t:t + H].sum() for t in range(T)])         # 未來 H 天累積報酬
base = y.var()                                             # 一律猜零的基準

def onenn_mse(folds, purge):
    errs = []
    for te in folds:
        tr = np.setdiff1d(np.arange(T), te)
        if purge:   # 淨化+禁運:剔除標籤窗口與測試區塊重疊或緊鄰的訓練點
            bad = np.arange(max(0, te.min() - H), min(T, te.max() + H))
            tr = np.setdiff1d(tr, bad)
        pred = [y[tr[((X[tr] - X[t]) ** 2).sum(axis=1).argmin()]] for t in te]
        errs.append(np.mean((np.array(pred) - y[te]) ** 2))
    return np.mean(errs)

idx = np.arange(T); K = 5
sh = idx.copy(); rng.shuffle(sh)
shuffled = [np.sort(sh[i::K]) for i in range(K)]
contig = [idx[i * 200:(i + 1) * 200] for i in range(K)]
print(f"猜零基準 MSE          {base:.6f}")
print(f"打散 K-fold           {onenn_mse(shuffled, False):.6f}")
print(f"連續 K-fold(未淨化)  {onenn_mse(contig, False):.6f}")
print(f"連續 + purge/embargo  {onenn_mse(contig, True):.6f}")
# 輸出:
# 猜零基準 MSE          0.002545
# 打散 K-fold           0.001317
# 連續 K-fold(未淨化)  0.004190
# 連續 + purge/embargo  0.005068

三個數字的供詞:打散 K-fold 報 0.001317,比基準低 48%——在一個確定無訊號的世界裡,它宣稱模型大勝。機制:特徵是慢變數,時間上相鄰的兩天在特徵空間裡也相鄰;打散切分讓每個測試點的「時間鄰居」大多留在訓練集,1-NN 抓到的最近鄰常是隔壁那天,而隔壁那天的標籤與測試標籤共享 19 天的未來報酬——模型不是在預測,是在抄一份寫了大半的答案。連續區塊切分報 0.004190,假象大部分消失(只剩區塊邊界附近的殘餘洩漏)。purge+embargo 報 0.005068,恰好接近基準的兩倍——這才是真相:對純雜訊標籤,1-NN 的預測本身也是一份獨立雜訊,誤差變異數自然是單份的兩倍(\(2 \times 0.002545 \approx 0.00509\))。誠實的評估不僅拆穿「模型有用」的假象,還正確地指出這個模型比什麼都不做更糟。工程備忘:sklearn 的 TimeSeriesSplit(gap=...) 能做到連續切分加禁運;完整的 purged K-fold 需要照本題自行實作標籤窗口的重疊剔除。

§05參考資料