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

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

量化金融的世界觀與資料工程

Quant Landscape & Financial Data

認識量化金融的機構地景與工具,掌握資料清理與模型驗證這兩門看門手藝。

§01學習重點

§02課程內容

一、從方法到行業:Part 4 要補的那塊拼圖

前十二章我們把機器學習的武器庫走了一輪:橫斷面的監督式學習、時間序列、強化學習、再到前沿。但「會用武器」和「知道仗在哪裡打」是兩件事。一位剛學完梯度下降的工程師空降到投信或券商,最先撞上的往往不是數學,而是行話:買方賣方是誰?造市商在忙什麼?為什麼隔壁定價團隊算選擇權從來不預測漲跌?資料庫裡的「還原收盤價」和收盤價差在哪?這一部的目標,就是把這些「行業的世界觀」補起來,讓你手上的方法找得到落點。

進入行業視角之前,先立一個貫穿 Part 4 的心法:金融建模與物理化學那種硬科學有一個本質差異——你沒有辦法重複實驗。實驗室可以把同一組條件再跑一百次,市場不行;更麻煩的是,你甚至不確定今年的市場跟十年前的市場是不是「同一個分布」,因為制度會變(第 1 章房貸縮影的教訓),而且市場裡全是人,人會看著模型的輸出反過來改變行為,形成回饋迴圈。這是第 1 章「兩種文化」討論的延伸:在金融裡,不論你站在統計建模還是機器學習那一端,模型都只是對世界的粗糙近似,能不能清楚說出「我的模型假設了什麼、在哪裡會失效」,比模型本身花不花俏重要得多。這個心法會在本章的驗證一節、以及之後定價各章反覆出現。

具體來說,quant 的日常工作可以粗分成三類任務。定價:給一個衍生品或債券算出公平價格與敏感度——這是第 14 到 17 章的主場,工具是隨機過程與無套利論證,幾乎不涉及「預測明天漲跌」。預測:從資料裡擠出對未來報酬、波動或違約的看法——這正是本課程前兩部的監督式與時序學習。組合:把看法變成部位,在報酬與風險之間做配置——第 18 章的最佳化,加上第三部強化學習的序列決策視角。三類任務用的數學高度重疊(機率分布、最佳化、模擬),但目標函數完全不同;分清楚自己在做哪一類,是入行第一課。

二、量化金融地景:誰在做什麼

先看機構。金融市場的玩家大致排成三群。

買方(buy-side):資產管理公司、避險基金、退休基金、保險公司的投資部門。它們受託管理別人的錢,商業模式是「把錢變多」——收入來自管理費與績效費。策略光譜從純主觀(基金經理看研究報告下判斷)到純系統化(訊號進、部位出,人只管監控),中間有大量混合型。避險基金與傳統資產管理的差別主要在收費結構與自由度:前者收績效費、絕對報酬導向、能做空能上槓桿;後者通常貼著基準指數比相對表現。台灣讀者可以對照:投信發行的共同基金與 ETF 是典型的傳統買方,而壽險公司的投資部門管的錢規模更大、期限更長。

賣方(sell-side):投資銀行的交易台與造市商。它們不賭方向,商業模式是「服務」——替市場提供流動性、替客戶客製風險移轉方案,收入來自買賣價差與結構化服務費。造市商在買賣兩側同時掛價,低買高賣賺中間的差額;接下客戶的單之後,手上的風險部位要嘛找到反向的另一方沖掉,要嘛自己算好敏感度掛著避險。台灣對照:券商的自營與衍生品部門發行的權證,就是散戶最容易接觸到的賣方產品——發行商賣你權證的同時,背後正在用定價模型對沖它的風險。

比喻: 造市商像機場的外幣兌換櫃檯。櫃檯同時掛「銀行買入」與「銀行賣出」兩個價,賺的是中間那道價差,而不是賭台幣升貶。它最怕的是單邊生意:一整天全是人來換美元、沒人換回來,庫存越堆越歪,一個匯率跳動就把整天價差賺的吐回去。所以櫃檯會動態調價把人流「引」回平衡——這正是造市演算法在做的事:報價不是預測,是庫存管理。

金融科技與資料服務商:第三群玩家不直接下場交易,而是把資料與工具賣給上面兩邊——另類資料整理、分析平台、定價引擎。台灣對照:TEJ(台灣經濟新報)與 CMoney 這類資料商,角色就相當於美國市場的 CRSP 與各家另類資料供應商。

quant 在這張地圖上的分工,跟著機構的商業模式走。買方 quant 偏預測與組合:研究員找訊號、組合經理管風險預算、風控盯部位;賣方 quant 偏定價:交易台旁的 desk quant 算價格與敏感度、模型驗證團隊獨立複核每一個上線模型、自動造市 quant 寫報價演算法。兩邊都需要把模型寫成生產級代碼的開發者。值得一提的是行業近年的一個結構性變化:過去被當成「超額報酬」收績效費的許多東西——價值、動量、品質這類風險因子曝險——如今已經被指數化商品用極低費率複製,超額報酬的門檻因此逐年墊高。這對讀者反而是好消息:能識別並穩健收割新資料源的人,正是行業付錢請的對象。

三、市場與工具速覽:無套利的第一課

工具層面只需要先認得五樣東西。股票是公司未來利潤的所有權憑證,站在資本結構最底層——公司倒閉時最後受償,所以要求的報酬也最高。債券是借據:到期還本、期間付息,價格主要對利率與違約風險敏感。遠期與期貨是「約好未來某天以某價交割」的合約,差別在遠期是店頭客製、期貨是交易所標準化商品。選擇權給你「權利而非義務」:買權(call)在到期日有權用履約價買進,賣權(put)有權賣出,到期收益分別是 \(\max(S_T-K,0)\) 與 \(\max(K-S_T,0)\)——那個取最大值的操作讓收益變成非線性,也讓定價從此需要整個機率分布而不只是期望值,這是第 15、16 章的主題。交換(swap)是「定期互換現金流」的約定,最常見的是固定利率換浮動利率。交易場所分兩種:交易所商品標準化、流動性好、適合自動化執行;店頭(OTC)商品靠詢價成交、條款客製,多數奇異選擇權都在這裡交易。這些市場的細節在第 17 章展開。

五樣工具裡,遠期最簡單,簡單到可以在這裡就把量化金融最重要的思想演一遍:無套利定價。問題:股票現價 \(S_0\),無風險年利率 \(r\),一年後交割的遠期合約,公平交割價 \(F\) 該是多少?直覺會想「看你覺得股票會漲多少」——錯,而且錯得有教育意義。正確做法是造一個替身:今天借 \(S_0\) 元買進一股,抱一年。一年後你手上有股票(正好可以交割),欠銀行 \(S_0 e^{rT}\) 元。這個替身組合跟「簽遠期合約」在到期日拿到的東西一模一樣,而它今天的成本是零——所以遠期的交割價必須等於替身的到期負債:

$$ F = S_0\, e^{rT} $$

逐項拆解:\(S_0\) 是現貨價格——替身的建倉成本;\(e^{rT}\) 是連續複利的融資因子——借錢抱股一年的利息成本;兩者相乘就是「今天鎖定未來交割」的公平價。注意整條式子裡沒有任何關於漲跌的預期:不管你多看多或看空,只要 \(F\) 偏離這個值,就有人能用「一邊做遠期、一邊反向操作替身」的組合鎖住無風險利潤,而套利者的買賣壓力會把價格推回去。

比喻: 同一瓶飲料,A 超商賣 25 元、B 超商賣 35 元,而且兩家距離三十公尺——有人會整箱從 A 搬到 B 門口賣 34 元,直到兩邊價差縮到「搬運的工錢」以內。無套利定價就是把這個常識當公理用:凡是能被便宜複製的東西,市價不能偏離複製成本太遠,否則「搬貨的人」就會出現。定價模型算的從來不是「這東西值多少」,而是「複製它要花多少」。

這個「不預測、只複製」的思路是整個定價世界的地基。第 14 章會把它升級成完整的機率語言:在無套利的世界裡,存在一套特殊的「定價專用機率」,讓所有資產折現後的價格變成公平賭局——那正是「風險中性」這個詞的由來,屆時你會看到它跟本章的替身論證是同一件事的兩種說法。現在只需要記住結論:定價 quant 不預測市場,他們複製收益。

四、金融資料工程(上):資料的長相與第一批坑

視角從機構拉回工作台。quant 專案的標準流程是四步:找資料、清資料、實作模型、驗證模型。新手最常見的誤判是把時間預算全部押在第三步——現實裡,資料相關的前兩步往往吃掉一半以上的工時,而且做壞的代價最高:模型錯了會報錯,資料錯了只會安靜地給你一個漂亮但虛假的回測。

先認識資料的三大類。價格類:報價、成交、限價簿,從 tick 級到月頻都有,是最容易取得的一類。基本面類:財報科目、經濟指標,頻率低(季、月)、發布有延遲。另類資料:新聞文字、網路流量、衛星影像、刷卡紀錄——第 1 章講過它們高維、非結構化的特性。同一類資料還有不同的「形狀」:單一資產是一條時間序列;一籃子股票每天一個橫斷面,疊起來是面板;利率有期限維度,每天是一條曲線;選擇權多了履約價維度,每天是一整張曲面。形狀決定了清理的難度——曲線與曲面除了缺值還要檢查內部一致性(例如相鄰履約價之間不能藏著免費午餐,本章作業三會實作)。免費資料源方面,美股有 Yahoo Finance、Fama-French 因子庫、FRED 總經資料庫;台股的價量與基本面可以從證交所網站與公開資訊觀測站取得,學術與業界則多用 TEJ 的資料庫。

第一個坑:公司行動。配息、配股、分割、合併都會讓「價格」瞬間跳一下,但那不是損益。台股的規則具體而清楚:除息日的參考價是前一日收盤減掉現金股利;除權日的參考價是前一日收盤除以(1+配股率)。如果直接拿原始收盤價算報酬,每個除權息日都會出現一個假暴跌。修正方法是「還原價」:把除權息日之前的所有價格乘上一個調整因子,讓跨日報酬恢復成持有人的真實報酬。用一檔虛構台股跑一次:

PYTHON
import numpy as np

# 虛構台股「南港精機」10 個交易日收盤價(元)
close = np.array([100.0, 101.5, 100.5, 97.5, 98.0,
                  99.0, 100.0, 83.5, 84.0, 85.0])
div_day, div_cash = 3, 3.0        # 第 4 天除息:現金股利 3 元
rights_day, ratio = 7, 0.2        # 第 8 天除權:每股配 0.2 股

raw_cum = close[-1] / close[0] - 1.0            # 未調整累積報酬

adj = close.copy()                              # 後向還原
adj[:div_day] *= (close[div_day-1] - div_cash) / close[div_day-1]
adj[:rights_day] *= 1.0 / (1.0 + ratio)
adj_cum = adj[-1] / adj[0] - 1.0

# 對照組:實際持有人(股利當日再投入、配股入帳)
shares, cash = 1.0, 0.0
for t in range(1, len(close)):
    if t == div_day:
        cash += shares * div_cash
        shares += cash / close[t]; cash = 0.0
    if t == rights_day:
        shares *= (1.0 + ratio)
wealth_cum = shares * close[-1] / close[0] - 1.0

print(f"未調整累積報酬: {raw_cum:+.2%}")        # -15.00%
print(f"還原後累積報酬: {adj_cum:+.2%}")        # +5.14%
print(f"持有人實際報酬: {wealth_cum:+.2%}")     # +5.14%

實跑結果:未調整數列說這檔股票十天跌了 15.00%——其中除息日「跌」2.99%、除權日「跌」16.50% 全是假訊號;還原後的累積報酬是 +5.14%,跟逐日把股利再投入的持有人財富完全一致。一正一負,方向都反了。這就是為什麼看台股歷史績效一定要用還原價:現金股利在台股報酬裡佔的份量極重,忽略它等於系統性低估。反過來的錯也常見——資料源已經調整過、你又自己調一次,報酬被重複灌水。防呆的辦法是抽查:挑幾個除權息日,手算參考價跟資料對一遍。

比喻: 股票分割像門牌重編。中正路 100 號一夜之間改成中正路 200 號,房子還是同一棟、價值一毛沒變——但如果你拿「門牌號碼」當房價數列,會以為這一帶暴漲了一倍。還原價做的事,就是把整條路的歷史門牌全部換算成新編制,讓數列裡只剩下真正的變化。

第二個坑:時間戳對齊。跨市場資料各有各的行事曆與收盤時刻:台股下午一點半收盤、美股要到台灣時間清晨才收;你以為的「同一天」,其實隔了半個交易日的資訊。拿美股收盤價當特徵去「預測」同一日曆日的台股收盤,模型會偷看未來還渾然不覺。原則只有一條:任何特徵在時間軸上必須嚴格早於它要預測的標籤,對齊要用「資訊可得時刻」而不是日曆日期。

五、金融資料工程(下):存活者偏差與缺值

第三個坑最陰險:存活者偏差。想回測一個台股策略,最順手的做法是拿「今天的上市名單」抓十年歷史價格——錯了。今天還在名單上的公司,是十年淘汰賽的贏家;那些下市、倒閉、被合併的輸家已經從名單裡消失。用贏家名單回測,等於讓策略只在「事後證明活得下來」的股票池裡選股,報酬必然灌水。量化一下傷害有多大:

PYTHON
import numpy as np

rng = np.random.default_rng(13)
N, T = 300, 120                            # 300 檔股票、120 個月
mu = rng.normal(0.004, 0.010, N)           # 各檔月漂移不同:有好公司有爛公司
mkt = rng.normal(0.004, 0.04, T)
beta = rng.uniform(0.6, 1.4, N)
ret = mu + beta * mkt[:, None] + rng.normal(0.0, 0.09, (T, N))

value = np.ones(N); alive = np.ones(N, dtype=bool)
ret_adj = ret.copy(); alive_hist = np.zeros((T, N), dtype=bool)
for t in range(T):                         # 淨值跌破期初 35% 即下市
    value[alive] *= 1.0 + ret[t, alive]
    crash = alive & (value < 0.35)
    ret_adj[t, crash] -= 0.30              # 下市當月的清算損失
    alive_hist[t] = alive
    alive[crash] = False

ann = lambda r: (1.0 + r).prod() ** (12.0 / len(r)) - 1.0
full_idx = np.array([ret_adj[t, alive_hist[t]].mean() for t in range(T)])
surv_idx = ret[:, alive].mean(axis=1)      # 拿「期末存活名單」回填整段歷史

print(f"下市家數: {N - alive.sum()}/{N}")               # 88/300
print(f"全宇宙   年化報酬: {ann(full_idx):+.2%}")        # +9.43%
print(f"存活者   年化報酬: {ann(surv_idx):+.2%}")        # +15.55%

三百檔股票十年裡下市了 88 檔。誠實地逐月使用「當時活著」的股票(含下市當月的清算損失),等權指數年化報酬 +9.43%;拿期末存活的 212 檔回填整段歷史,年化變成 +15.55%——憑空多出 6.12 個百分點,什麼訊號都還沒加。多數「歷史年化 20%」的自製回測,第一刀該砍的就是這裡。防治方法:使用逐時點成分名單(point-in-time universe)的資料庫——美股的標準答案是 CRSP,台股則要在 TEJ 這類資料庫裡明確選用含下市公司的樣本;如果手上的資料源做不到,至少要對長多策略的回測結果打上折扣、並在報告裡明寫這個限制。順帶一提,做多做空對沖的策略受害較輕——偏差同時高估了多頭腿和空頭腿的報酬,互相抵掉一部分。

比喻: 畢業二十年的同學會,到場的通常是事業順利、方便露面的那一群;混得不好的多半缺席。如果你用「同學會出席者的平均收入」推估全班的平均境遇,會得到一個系統性偏高的數字——而且你不會收到任何錯誤訊息,因為缺席者是安靜的。存活者偏差正是如此:下市的公司不會在今天的名單裡舉手說「別忘了算我」。

第四個坑:缺值。停牌、資料斷線、不同市場的假日錯開,都會在數列上挖洞。洞怎麼補,取決於用途——風險估計對補法不敏感,回測則極度敏感。三種常見補法各有代價:前向填補(拿最後一個有效價格一路抄下去)等於宣稱缺失期間報酬為零,會壓低波動率;線性插值把缺口兩端的漲幅攤平成每天等量的小碎步,同樣壓低波動,還憑空製造自相關,而且它用到了缺口右端「未來」的價格——回測中是前視偏差;迴歸填補利用相關資產:先用有資料的日子估出這檔股票對市場的 \(\beta\),缺失日用 \(\hat r = \beta\, r_{\text{mkt}}\) 補,能保住波動與相關結構的大半。實跑對比:

PYTHON
import numpy as np

rng = np.random.default_rng(21)
T = 750
r_mkt = rng.normal(0.0004, 0.011, T)
r_full = 1.2 * r_mkt + rng.normal(0.0, 0.008, T)   # 真實 β=1.2

miss = np.zeros(T, dtype=bool)                      # 40 天長缺口+40 個零星缺日
miss[300:340] = True
miss[rng.choice(np.setdiff1d(np.arange(T), np.arange(300, 340)),
                40, replace=False)] = True

ann_vol = lambda r: r.std(ddof=1) * np.sqrt(252)
beta = lambda r, m: np.cov(r, m, ddof=1)[0, 1] / np.var(m, ddof=1)

r_ffill = r_full.copy(); r_ffill[miss] = 0.0        # 前向填補:缺日報酬 0
b = beta(r_full[~miss], r_mkt[~miss])               # 迴歸填補
r_reg = r_full.copy(); r_reg[miss] = b * r_mkt[miss]

print(f"真實  : 波動 {ann_vol(r_full):.2%}  β {beta(r_full, r_mkt):.3f}")
print(f"前向  : 波動 {ann_vol(r_ffill):.2%}  β {beta(r_ffill, r_mkt):.3f}")
print(f"迴歸  : 波動 {ann_vol(r_reg):.2%}  β {beta(r_reg, r_mkt):.3f}")
# 真實  : 波動 22.51%  β 1.139
# 前向  : 波動 21.40%  β 1.025
# 迴歸  : 波動 22.28%  β 1.146

一成出頭的缺值,就讓前向填補把年化波動從 22.51% 壓到 21.40%、把 \(\beta\) 從 1.139 拉低到 1.025——如果這個 \(\beta\) 接著被拿去做對沖,部位會系統性沖不乾淨。迴歸填補兩項都貼著真值。更講究的做法是拔靴法(bootstrap):從歷史報酬裡隨機抽「整天」來填洞——同一天所有資產一起抽——能同時保住整個橫斷面的相關結構。跟缺值同屬資料體檢的還有離群值偵測:先畫圖、再用標準差門檻掃極端值,並跟同日其他資產交叉比對——大盤平靜而單一 ETF 暴跌五個標準差,多半是壞資料而不是行情。此處有一個第 6 章講過的老朋友要記得:對報酬這種平穩序列算標準差才有意義,對價格這種帶趨勢的序列算標準差是沒有意義的。

六、資料頻率與問題選型

第 1 章的應用地圖(FIG. 01-3)用「資料頻率 × 任務性質」把金融機器學習的場景攤開;走過 Part 1–3 之後,現在可以把那張地圖接到方法選擇上,並補上量化實務的一條鐵律。tick 級與日內資料:樣本量以百萬計,撐得起深度網路與強化學習,主戰場是執行與造市(第 9–10 章的序列決策)。日頻:樣本量數千,橫斷面選股與時序訊號的主場——第 2–5 章的監督式方法加第 6–8 章的時序工具,正則化與驗證紀律(第 4 章)是生死線。月頻與季頻:一檔資產十年只有一百多個點,資產配置與基本面研究的地盤,模型越簡單越好,貝葉斯方法(第 2–3 章)靠先驗撐住小樣本。頻率越低,「參數多的模型」越是奢侈品——這是偏差–方差取捨在資料量維度上的直接投影。

鐵律是:資料頻率決定你有多少樣本,樣本量決定你養得起多複雜的模型;選模型之前先數一數你的有效樣本。行業裡最常見的悲劇不是方法不會用,而是拿週頻資料餵一個該吃 tick 資料的深度模型——參數比樣本還多,回測漂亮,上線即歸零。反過來,定價任務(第 14–17 章)幾乎不受這條限制,因為它根本不從歷史學:它從今天的市場報價反推參數,資料需求是「今天這一張曲面」,難點在無套利一致性而不在樣本量。同一張地圖上,預測與定價是兩種完全不同的資料經濟學。

七、模型驗證紀律:在錢燒掉之前抓到錯

資料乾淨了、模型選好了,最後一道工序是說服自己(和別人):這套代碼算出來的數字是對的。這件事在金融裡有專門的名字——模型驗證(model validation)——大型機構甚至設有獨立團隊,模型不過他們那關就不准上線。難點在於:越是值得驗證的模型,越沒有標準答案可以對。簡單模型有閉式解可查表;一旦進入奇異選擇權或隨機波動的世界,連「正確答案應該長什麼樣」都不知道,驗證變成一門手藝。

這門手藝可以組織成三層金字塔。底層:單元測試。對模型的每個零件寫「輸入這個,必須輸出那個」的檢查:邊界值(零利率時遠期價等於現貨、價外選擇權到期時價值歸零)、已知值(教科書例題的數字)、極端參數(波動率趨近零或極大時行為是否合理)。單元測試的價值不只在第一次寫對,更在每次改動都全部重跑——改了一行代碼、某個本來過的測試突然掛掉,你就在上線之前抓到了迴歸錯誤。這跟第 4 章講過的驗證紀律是同一種精神:把「正確性」變成可自動重複檢查的程序,而不是一次性的人工確認。中層:模型層驗證。整個模型沒有標準答案時,製造標準答案:把參數退化到已知解存在的特例(隨機波動率關掉之後應該退回常數波動的世界),或者用一套獨立的第二實作互相對照——閉式解對蒙地卡羅、樹方法對有限差分。兩套獨立方法給出一致的答案,不能證明都對,但大幅提高了信心;同時把模型的假設與適用範圍寫成文件,讓下一個使用者知道它在哪裡會失效。頂層:回測與上線監控。模型正確不等於策略賺錢:交易成本、滑價、市場衝擊在這一層才進場,樣本外測試與上線後的基準偏移警報是最後的防線——這是第 19 章的主題。

中層的「獨立重作」用本章學過的遠期定價演一次:

PYTHON
import numpy as np

def forward_price(s0, r, t):
    """遠期理論價:F = S0 · exp(rT)(無股利、常數利率)"""
    return s0 * np.exp(r * t)

# 底層:單元測試——邊界值與已知值
assert forward_price(100.0, 0.0, 1.0) == 100.0     # 零利率:遠期 = 現貨
assert forward_price(100.0, 0.02, 0.0) == 100.0    # 到期日 = 今天
assert abs(forward_price(100.0, 0.02, 1.0) - 102.0201) < 1e-4

# 中層:獨立重作——蒙地卡羅模擬對照閉式解
rng = np.random.default_rng(5)
s0, r, sigma, t, n = 100.0, 0.02, 0.25, 1.0, 200_000
z = rng.standard_normal(n)
s_t = s0 * np.exp((r - 0.5 * sigma**2) * t + sigma * np.sqrt(t) * z)
mc, se = s_t.mean(), s_t.std(ddof=1) / np.sqrt(n)

print(f"閉式解 F = {forward_price(s0, r, t):.4f}")   # 102.0201
print(f"蒙地卡羅 E[S_T] = {mc:.4f}(標準誤 {se:.4f})")  # 102.0765(0.0581)
assert abs(mc - forward_price(s0, r, t)) < 3 * se
print("PASS:兩套獨立實作在 3 個標準誤內一致")

蒙地卡羅估出 102.0765、閉式解 102.0201,差距落在三個標準誤之內——兩套從完全不同路徑出發的實作彼此印證。注意判準寫的是「三個標準誤」而不是一個固定小數:模擬本身有統計噪音,驗證的容忍度必須跟著噪音的尺度走,這是寫數值測試的基本功。這段代碼裡的模擬手法(用隨機路徑估期望值),正是第 14 章定價引擎的雛形。

比喻: 大樓電梯的安全不是靠一次檢查,而是靠三層制度:出廠時每個零件有規格測試(單元測試);安裝後載重驗收,掛上額定重量實際跑幾趟(模型層驗證:拿已知情境對答案);啟用後每年強制年檢、鋼纜磨耗有監測(上線監控)。而且驗收的人不能是安裝的人——模型驗證團隊獨立於開發團隊,道理相同:自己驗自己的代碼,看到的永遠是自己的盲點以外的東西。

最後把 Part 4 開場的世界觀收攏成三句話。第一,模型是近似,金融沒有可重複實驗,所以「知道模型哪裡會失效」是能力而不是免責聲明。第二,資料工程不是雜務,是報酬的第一道來源——把存活者偏差和還原價做對,比多調三個超參數值錢。第三,驗證是製度不是美德——單元測試、獨立重作、變更重跑,做成流程才靠得住。帶著這三句話,下一章進入定價的數學核心:隨機過程與風險中性測度。

§03原書對照

本章重組了 Kelliher 原書第一、六、七章的骨架,以下內容原書有、但本課未展開,供進階讀者按頁碼深入。其一,避險基金策略的五分類——全球宏觀、相對價值、事件驅動、風險溢酬、統計套利——各附典型運作方式(pp.5–6)。其二,quant 職涯的逐一側寫:買方的 desk quant、資產管理 quant、研究 quant、開發者、量化組合經理,賣方的定價、風控、模型驗證、自動造市各角色的技能側重(pp.6–9)。其三,債務工具的定價入門:零息債券 \(V_0 = e^{-yT}P\) 的殖利率語言,以及股與債在資本結構上的受償順位如何把兩者連到同一家公司的違約風險(pp.10–11,式 1.1)。其四,遠期定價的完整複製表格與含股利版本 \(F = S_0 e^{(r-q)T}\),並示範價格偏離時的套利操作方向(pp.11–12,式 1.6–1.7)。其五,選擇權的收益結構、跨式組合的損益圖,與買賣權平價 \(C - P = S_0 - Ke^{-rT}\) 的靜態複製推導(pp.13–15,式 1.8–1.11)。其六,交換的固定腿與浮動腿現值公式、日數計算慣例,及標普總報酬交換的實例(pp.15–17,式 1.12–1.14)。其七,產業趨勢五論:自動化、資料爆炸、因子溢酬商品化(alpha 變 beta)、機器學習滲透、技能門檻上移(pp.20–22)。其八,資料收集的工程細節:pandas 讀寫、逐行解析複雜格式檔案、網頁抓取,與 ODBC 資料庫連線的代碼模式;原書強調以 API 自動化取數優先於手動下載(pp.128–131)。其九,常用資料源清單的完整版與各自定位——Yahoo Finance、Ken French 因子庫、FRED、Treasury.gov、Quandl、HistData、OptionMetrics、CRSP(pp.134–135)。其十,期貨資料的換月(rolling)處理與現貨期貨間的套利檢查(pp.132–133)。其十一,選擇權報價的無套利條件全列表:買權價格對履約價單調遞減、斜率界於負一與零之間、凸性條件(p.137,式 6.1)。其十二,KNN 填補缺值、KNN 距離做離群值偵測的完整流程,及科技 ETF 報酬的實例——偏態 −0.34、峰度 13.39 遠離常態基準(pp.141–144,式 6.3–6.4)。其十三,模型驗證流程的四組件:模型文件、獨立代碼審查、單元測試(Python unittest 模組)、生產模型變更流程,以及用 L'Hôpital 法則處理分母趨零的邊界案例(pp.147–149)。其十四,簡約與真實的收官取捨:複雜模型假設更真但更難驗證、更易過擬合,建議從簡約出發、按應用需要加複雜度(p.151)。原書第一、六、七章分別對應印刷頁 pp.3–22、pp.127–144、pp.145–151。

§04作業和解答

作業一:指數成分股偏差

延續第五節的模擬:除了「期末存活者回填」,實務上更常見的錯誤是拿「今天的指數成分股」回測——相當於挑期末市值最大的贏家。請在同一組模擬資料上,取期末淨值前 50 名的股票回填整段歷史,計算等權年化報酬,與全宇宙、存活者兩個基準比較,並解釋為什麼偏差比存活者版本更嚴重。

解答 SOLUTION
PYTHON
import numpy as np

rng = np.random.default_rng(13)
N, T = 300, 120
mu = rng.normal(0.004, 0.010, N)
mkt = rng.normal(0.004, 0.04, T)
beta = rng.uniform(0.6, 1.4, N)
ret = mu + beta * mkt[:, None] + rng.normal(0.0, 0.09, (T, N))

value = np.ones(N); alive = np.ones(N, dtype=bool)
ret_adj = ret.copy(); alive_hist = np.zeros((T, N), dtype=bool)
for t in range(T):
    value[alive] *= 1.0 + ret[t, alive]
    crash = alive & (value < 0.35)
    ret_adj[t, crash] -= 0.30
    alive_hist[t] = alive
    alive[crash] = False

ann = lambda r: (1.0 + r).prod() ** (12.0 / len(r)) - 1.0
full_idx = np.array([ret_adj[t, alive_hist[t]].mean() for t in range(T)])
surv_idx = ret[:, alive].mean(axis=1)
top50 = np.argsort(np.where(alive, value, -np.inf))[-50:]   # 期末前 50 大
top_idx = ret[:, top50].mean(axis=1)

print(f"全宇宙        年化: {ann(full_idx):+.2%}")   # +9.43%
print(f"存活者(n=212) 年化: {ann(surv_idx):+.2%}")   # +15.55%
print(f"期末前 50 大  年化: {ann(top_idx):+.2%}")    # +33.86%

實跑結果:全宇宙 +9.43%、存活者回填 +15.55%、期末前 50 大回填 +33.86%——膨脹將近 25 個百分點。原因:存活者篩選只要求「沒死」,前 50 大篩選要求「不但沒死,還是全班前六分之一的贏家」。選樣條件與「歷史報酬高」的相關性越強,回填造成的偏差越大;市值加權指數的成分股正是用這種「事後贏家」條件選出來的。這也解釋了為什麼「回測台灣五十成分股、年化打敗大盤」這類結果多半不可信:成分名單本身就是用歷史績效的結果選的,必須改用逐時點的當期成分名單才算數。

作業二:通用還原價函式

把第四節的還原邏輯寫成通用函式 adjusted_close(close, events),其中 events(除權息日索引, 現金股利, 配股率) 的清單,支援多次公司行動與「同日除權息」。台股慣例:同日除權息時參考價為(前收盤 − 現金股利)÷(1 + 配股率)。用一段 20 天、含三次公司行動的價格數列驗證:還原後的累積報酬應等於「現金股利當日再投入、配股直接入帳」的持有人財富報酬。

解答 SOLUTION
PYTHON
import numpy as np

def adjusted_close(close, events):
    """後向還原:events = [(索引, 現金股利, 配股率), ...]"""
    adj = np.asarray(close, dtype=float).copy()
    for t, cash, ratio in events:
        prev = close[t - 1]
        adj[:t] *= ((prev - cash) / prev) / (1.0 + ratio)
    return adj

rng = np.random.default_rng(7)
close = 60.0 * np.cumprod(1.0 + rng.normal(0.001, 0.015, 20))
events = [(5, 1.8, 0.0), (11, 0.0, 0.15), (16, 1.2, 0.05)]
for t, cash, ratio in events:      # 事件日把價格挪到參考價附近,模擬除權息缺口
    close[t:] *= ((close[t-1] - cash) / close[t-1]) / (1.0 + ratio) \
                 / (close[t] / close[t-1])

adj = adjusted_close(close, events)

shares = 1.0                        # 持有人財富:兩種股利都按除權息前股數計
for t, cash, ratio in events:
    shares = shares * (1.0 + ratio) + shares * cash / close[t]
wealth_ret = shares * close[-1] / close[0] - 1.0

print(f"未調整累積報酬: {close[-1]/close[0]-1.0:+.2%}")   # -25.46%
print(f"還原後累積報酬: {adj[-1]/adj[0]-1.0:+.2%}")       # -4.92%
print(f"持有人實際報酬: {wealth_ret:+.2%}")               # -4.92%
assert abs(adj[-1]/adj[0] - 1.0 - wealth_ret) < 1e-9
print("PASS:還原報酬與再投資財富一致")

實跑結果:未調整 −25.46%、還原後 −4.92%、持有人實際 −4.92%,斷言通過。兩個實作細節值得記下。第一,調整因子作用在事件日之前的整段歷史,多次事件的因子直接連乘,所以由早到晚或由晚到早套用都可以。第二,財富對照組的股數更新必須寫成「新股數 = 舊股數 ×(1+配股率)+ 舊股數 × 現金股利 ÷ 當日收盤」——現金與股票股利都以除權息的股數計算;若先把股利再投入的新股數也乘上配股率,會多算一筆不存在的配股,對不上還原報酬(這正是驗證函式時最容易犯的順序錯誤,斷言會替你抓住它)。

作業三:選擇權報價的無套利體檢器

第四節提到曲面資料要檢查內部一致性。對同一到期日的買權報價,無套利要求:價格對履約價單調遞減、相鄰履約價間的斜率界於 −1 與 0 之間、且價格對履約價是凸函數(蝶式組合成本不得為負)。請寫一個 check_call_quotes(strikes, prices) 體檢器,回傳所有違規;再用報價 K=[90,95,100,105,110]C=[12.10, 8.60, 6.30, 4.90, 2.70] 測試,若抓到違規,構造對應的套利部位並驗證其到期損益恆為正。

解答 SOLUTION
PYTHON
import numpy as np

def check_call_quotes(strikes, prices):
    k, c = np.asarray(strikes, float), np.asarray(prices, float)
    issues = []
    dc = np.diff(c) / np.diff(k)
    for i, s in enumerate(dc):                       # 斜率條件
        if not (-1.0 < s <= 0.0):
            issues.append(f"斜率違規: K∈[{k[i]:.0f},{k[i+1]:.0f}] 斜率 {s:+.3f}")
    for i in range(1, len(k) - 1):                   # 凸性條件(蝶式成本)
        h1, h2 = k[i] - k[i-1], k[i+1] - k[i]
        bfly = (c[i-1] - c[i]) / h1 - (c[i] - c[i+1]) / h2
        if bfly < 0:
            issues.append(f"凸性違規: K={k[i]:.0f} 蝶式成本 {bfly:+.4f}")
    return issues

strikes = [90, 95, 100, 105, 110]
prices = [12.10, 8.60, 6.30, 4.90, 2.70]
for msg in check_call_quotes(strikes, prices):
    print(msg)                       # 凸性違規: K=105 蝶式成本 -0.1600

# 套利部位:買 1 口 K=100、買 1 口 K=110、賣 2 口 K=105 的蝶式
cost = prices[2] - 2 * prices[3] + prices[4]
print(f"蝶式建倉成本: {cost:+.2f}")   # -0.80(建倉先收 0.80 元)
s_t = np.linspace(80, 120, 9)
payoff = (np.maximum(s_t - 100, 0) - 2 * np.maximum(s_t - 105, 0)
          + np.maximum(s_t - 110, 0))
print("含建倉現金流的到期損益:", np.round(payoff - cost, 2))
# [0.8 0.8 0.8 0.8 0.8 5.8 0.8 0.8 0.8] —— 全域為正

實跑結果:體檢器抓到 K=105 的凸性違規(這筆報價被灌水了)。對應的套利部位是蝶式:買一口 K=100 與一口 K=110、賣兩口 K=105,建倉現金流 6.30 − 2×4.90 + 2.70 = −0.80 元——建倉先收 0.80 元。到期收益結構是一座以 105 為頂點的小山,最低為零:無論到期價落在哪裡,總損益至少 +0.80 元、在 105 附近最高 +5.80 元,穩賺不賠,確認這組報價確實藏著套利。實務意義:市場報價幾乎不會真的送你免費午餐——體檢器抓到違規時,第一解讀應該是「資料有問題」(過期報價、單邊報價、錯檔),這正是第四節說的「曲面資料要檢查內部一致性」;若不清理就餵給定價模型,模型會為了遷就髒資料而扭曲整張曲面的參數。

作業四:三層金字塔的歸位練習

以下四個錯誤,各應該被驗證金字塔的哪一層抓到?請說明理由,並指出「如果那一層缺席」錯誤會以什麼形式現身。(1)選擇權定價函式在波動率輸入為零時回傳 NaN;(2)新版蒙地卡羅引擎改了亂數生成邏輯後,同一組參數的定價悄悄偏移了 0.5%;(3)回測裡策略每天換股一次、年化 30%,上線後扣掉手續費與滑價變成 −2%;(4)隨機波動率模型在「波動率的波動」歸零時,價格沒有收斂到常數波動模型的閉式解。

解答 SOLUTION

(1)底層:單元測試。波動率為零是典型邊界值——此時選擇權價值應退化為確定性收益的折現值,一條 assert 就能鎖住。若缺席:NaN 會在生產環境的某次極端參數下才爆出來,而且可能被下游的加總運算吞掉,變成整本帳的 NaN。(2)底層與中層的介面:基準重跑。單元測試的另一半價值在「每次改動全部重跑」——改動亂數邏輯後基準測試偏移 0.5%,會立刻被既有測試抓住,迫使開發者判斷這是預期中的改進還是缺陷。若缺席:0.5% 的定價偏移小到沒人肉眼發現,卻大到讓造市部位長期單邊吃虧。(3)頂層:回測與上線監控。交易成本與滑價只有在策略層才存在,單元與模型層驗證再完備也看不到它們——這正是第 19 章回測紀律的主題。若缺席:模型「正確」、策略賠錢,且賠得理直氣壯。(4)中層:模型層驗證。「參數退化到已知解」是中層的招牌手法:波動率的波動歸零時,隨機波動率世界應塌縮回常數波動的世界,兩者對不上代表實作與理論有分歧。若缺席:模型在校準時會用錯誤的自由度去硬貼市場報價,參數失去意義,外推全錯。共同教訓:四個錯誤各有專屬的天敵層,任何單一層都無法包辦——金字塔的意義不是冗餘,是分工。

§05參考資料