研究筆記

用 Python 建立交易策略:由訊號到無偏回測

以 MCM 2022 黃金與 Bitcoin 題目為案例,建立只使用當時可得資料的交易策略,正確處理不同交易日曆、佣金、執行時點、walk-forward 選參與風險評估。

2024年9月30日 · 約 6 分鐘閱讀

封存文章

本文源自 MCM 2022 Problem C。它是回測方法教學,不是投資建議。 舊版本用完整五年資料選出 15/80 日參數,再在同一期間報 Sharpe 1.27 和 $15,789.84;這是樣本內最佳化結果,且交易執行存在 same-bar look-ahead, 不能視為可交易績效。

題目給定 2016-09-11 至 2021-09-10 的黃金與 Bitcoin 價格,起始資金 $1,000:

(C0,G0,B0)=(1000,0,0).(C_0,G_0,B_0)=(1000,0,0).

黃金佣金為 1%,Bitcoin 為 2%;Bitcoin 每日可交易,黃金只在其市場開放日交易。 每天的決策只能使用當時已知的價格歷史。

這個限制令研究重點由「找一條最高回報曲線」變成:

  1. 資料在甚麼時間變成可知?
  2. 訊號在甚麼價格執行?
  3. 不同日曆如何估值與交易?
  4. 參數如何在不偷看測試期下選擇?
  5. 佣金後的風險與 benchmark 比較是否仍成立?

一、狀態與交易

日末 portfolio value:

Vt=Ct+GtPtG+BtPtB.V_t=C_t+G_tP_t^G+B_tP_t^B.

若買入價值 qq 的資產 aa,比例佣金 αa\alpha_a

Ct+1=Ctq(1+αa),Ha,t+1=Ha,t+qPt+1,aexec.\begin{aligned} C_{t+1}&=C_t-q(1+\alpha_a),\\ H_{a,t+1}&=H_{a,t}+\frac{q}{P_{t+1,a}^{\rm exec}}. \end{aligned}

賣出 hh 單位:

Ct+1=Ct+hPt+1,aexec(1αa),Ha,t+1=Ha,th.\begin{aligned} C_{t+1}&=C_t+hP_{t+1,a}^{\rm exec}(1-\alpha_a),\\ H_{a,t+1}&=H_{a,t}-h. \end{aligned}

關鍵是訊號以 tt 日收市價計算時,不能亦假設以同一個未知收市價成交。 保守 backtest 可在 t+1t+1 的可交易 open/close 執行,並加入 slippage。

二、交易日曆

建立完整 calendar index,保留兩種資訊:

  • mark_price:非交易日可 forward-fill,只用於 portfolio valuation;
  • tradable:只有真實報價/市場開放日才可下單。
calendar = pd.date_range(start, end, freq="D")
prices = raw.reindex(calendar)

prices["gold_tradable"] = prices["gold"].notna()
prices["btc_tradable"] = prices["bitcoin"].notna()
prices["gold_mark"] = prices["gold"].ffill()
prices["btc_mark"] = prices["bitcoin"].ffill()

不可因黃金星期末沒有價格便 continue 整日,否則 Bitcoin 的週末交易及估值都被刪除; 亦不可把 forward-filled gold price 當成該日可成交價格。

三、訊號

Moving-average state

sta=1(MAS,ta>MAL,ta).s_t^a = \mathbf1 \left( \operatorname{MA}_{S,t}^a > \operatorname{MA}_{L,t}^a \right).

target position 是 stas_t^a,交易量由

Δsta=stast1a\Delta s_t^a=s_t^a-s_{t-1}^a

決定。

def ma_target(price, short, long):
    short_ma = price.rolling(short, min_periods=short).mean()
    long_ma = price.rolling(long, min_periods=long).mean()
    return (short_ma > long_ma).astype(float)

執行時把 target shift 一日:

orders = ma_target(close, short, long).shift(1)

這表示今日交易只使用昨日收市或更早資料。若題目假設每日報價在 decision 前可知, 也仍要把 timeline 寫清楚。

Mean reversion

rolling z-score:

zt=PtPˉt,Lst,L.z_t = \frac{P_t-\bar P_{t,L}}{s_{t,L}}.

可用 state machine:z<zenterz<-z_{\rm enter} 進場, z>zexitz>z_{\rm exit} 離場。舊程式先產生 1,0,1-1,0,1,再 .diff(), 可能得到 ±2\pm2,但 backtester 只處理恰好 1-1, 令部分訊號靜默失效。

四、事件驅動回測

一個可靠 loop 的次序是:

  1. 用截至 t1t-1 的資料取得 target;
  2. 檢查資產在 tt 是否 tradable;
  3. 用 execution price、commission、slippage 成交;
  4. 更新 cash 和 holdings;
  5. 用 mark prices 計算日末 value;
  6. 記錄 orders、fills 和 rejection reason。
def apply_trade(cash, units, target_value, price, fee):
    current_value = units * price
    delta = target_value - current_value

    if delta > 0:
        spend = min(delta, cash / (1 + fee))
        units += spend / price
        cash -= spend * (1 + fee)
    elif delta < 0:
        sell_value = min(-delta, current_value)
        sold_units = sell_value / price
        units -= sold_units
        cash += sell_value * (1 - fee)

    return cash, units

不要用 max(cash, 0)max(total, 0) 掩蓋 accounting bug; 若 cash 變負應觸發 assertion。

assert cash >= -1e-9
assert gold_units >= -1e-12
assert btc_units >= -1e-12

五、資產配置

舊策略每個 buy signal 使用「當時 cash 的 50%」:

  • Bitcoin 先買會改變 gold 可用 cash;
  • order of execution 影響配置;
  • repeated buy signal 可能累積持倉;
  • 沒有明確 target weights。

更清楚的是設定:

wt=(wtC,wtG,wtB),wtC+wtG+wtB=1,\mathbf w_t=(w_t^C,w_t^G,w_t^B), \qquad w_t^C+w_t^G+w_t^B=1,

按 pre-trade portfolio value 計算所有 target values,再一起 re-balance。 例如兩個訊號皆開時各 40%,保留 20% cash;只有一個開時可配置 60%。 這是策略假設,必須預先指定而非由 loop 次序偶然決定。

六、績效指標

日報酬:

rt=VtVt11.r_t=\frac{V_t}{V_{t-1}}-1.

若以 calendar-day series:

Sharpe=365rˉrf/365sr.\operatorname{Sharpe} = \sqrt{365} \frac{\bar r-r_f/365}{s_r}.

若只用共同交易日則可能用 252,但 Bitcoin 週末風險消失, 兩者要與資料日曆一致。舊公式文字把分母寫成 std × sqrt(252),程式則正確地用 mean/std × sqrt(252);文章應保持一致。

Maximum drawdown:

MDD=maxt(1VtmaxstVs).\operatorname{MDD} = \max_t \left( 1-\frac{V_t}{\max_{s\le t}V_s} \right).

另外報告:

  • CAGR;
  • annualized volatility;
  • downside deviation/Sortino;
  • turnover 和總 commissions;
  • exposure;
  • worst day/month;
  • time under water;
  • terminal wealth 的 uncertainty。

56.13% drawdown 不應僅因 Bitcoin 波動高便稱為「可接受」;可接受性取決於 mandate 和 investor loss tolerance。

七、Benchmark

至少比較:

  1. all cash;
  2. buy-and-hold Bitcoin;
  3. buy-and-hold gold;
  4. fixed-weight buy-and-hold;
  5. periodic rebalancing;
  6. same risk/same exposure benchmark。

所有 benchmark 使用同一初始日期、佣金、交易日曆和估值規則。 若策略持有大量 cash,應同時比較 volatility-matched benchmark, 避免把較低 exposure 當成 alpha。

八、參數選擇與 data snooping

舊程式在完整五年內遍歷 short/long windows,取最高 Sharpe, 再用同一五年報告 Sharpe 1.27 和 $15,789.84:

(S^,L^)=argmaxS,LSharpe^2016:2021(S,L).(\hat S,\hat L) =\arg\max_{S,L} \widehat{\operatorname{Sharpe}}_{2016:2021}(S,L).

這是 in-sample maximum,會從噪聲中挑出幸運組合。正確流程是 walk-forward:

for train_end, test_end in folds:
    train = data.loc[:train_end]
    test = data.loc[train_end:test_end]

    params = choose_params_on_train(train)
    result = backtest_out_of_sample(test, params, warmup=train)
    fold_results.append(result)

每個 test fold 的參數只能由之前資料選擇;最後串接所有 out-of-sample returns。 可使用 anchored expanding window 或 fixed rolling window。

Multiple testing

嘗試的 indicators、wavelets、HMM states、windows 和 rules 越多, 最佳 Sharpe 的 upward bias 越大。應:

  • 預先登記有限策略族;
  • 留 untouched final test period;
  • 報告所有試過的變體;
  • 使用 deflated Sharpe/White’s Reality Check 等修正;
  • 檢查鄰近參數是否形成穩定 plateau,而非單一尖峰。

九、HMM、Granger 與 wavelet

這些方法只有在進入交易決策並 out-of-sample 驗證時才有價值。

  • 在完整資料 fit HMM,再用同一資料 decoded states 交易,會偷看未來;
  • Granger causality 需要 stationary specification 和 lag selection, 也不等於可獲利因果;
  • wavelet decomposition 若使用雙側 filter,當前 coefficient 可能依賴未來值;
  • normality test 拒絕與否不會直接產生交易規則。

每個 feature 必須有 as-of timestamp 和 real-time computability test。

十、交易成本與市場摩擦

題目只指定比例 commission,但研究仍要測試:

Ctrade=αΔq+spread+slippage(size, volatility).C_{\rm trade} =\alpha|\Delta q| +\text{spread} +\text{slippage}(\text{size, volatility}).

原敏感度表顯示成本提高時 Sharpe 下降,這是合理方向; 但因同一資料已用來選參數,不能證明 robustness。 應在每個 walk-forward fold 以不同成本重跑,並報 break-even fee。

黃金資料若是 benchmark fixing price,而非可交易即時價格, execution assumption 亦要標明。

十一、壓力測試

直接把某幾日 return 改成 crash/rally 而不重新計算 signal、fill 和 liquidity, 只能作 portfolio shock。較完整情景包括:

  • spread 擴大;
  • trading halt/gold holiday;
  • Bitcoin weekend gap;
  • price crash 同時 liquidity 下降;
  • exchange/custody outage;
  • delayed execution;
  • regime shift 後趨勢策略連續 whipsaw。

壓力測試應報 margin/cash constraint、最大單日損失、能否執行 exit, 而非只畫 shocked equity curve。

十二、可重現研究設計

資料切分

  • 2016–2018:初始研究與參數範圍;
  • 2019–2020:walk-forward validation;
  • 2021:最終 untouched test;

或使用多個 rolling folds,視樣本量調整。

預先固定

  • signal timestamp;
  • execution timestamp;
  • allocation rule;
  • commissions 和 slippage;
  • benchmark;
  • missing-day policy;
  • parameter grid;
  • primary metric;
  • failure criteria。

測試

  1. 無交易時 portfolio 恆等於 cash;
  2. 單次買賣的 commission 可手算;
  3. 黃金休市不能成交,但仍可估值;
  4. Bitcoin 週末可交易;
  5. signal shift 防止 same-bar fill;
  6. cash 和 holdings 永不憑空增加;
  7. final liquidation 是否計佣金要一致;
  8. benchmark 使用同一日曆。

十三、舊結果的合適標籤

原輸出:

  • short 15、long 80;
  • 樣本內 Sharpe 1.27;
  • maximum drawdown 56.13%;
  • $1,000 變 $15,789.84。

應標為:

探索性、樣本內、經參數搜尋後的 backtest output。

它沒有經 walk-forward 或 untouched test,且執行時點和 missing-day accounting 仍有問題,所以不能稱為 robust strategy,也不能預期未來重現。

結論

交易策略研究最難的部分不是計算 moving average,而是重建每一天的資訊集合和執行條件。 一個可信的答案需要:

  1. 分開 valuation 與 tradability;
  2. 將訊號至少 lag 到下一個可執行時點;
  3. 用 target weights 而非 order-dependent cash fractions;
  4. 完整記錄佣金、slippage 和 skipped orders;
  5. 用 walk-forward 選參與測試;
  6. 與公平 benchmark 比較;
  7. 把所有嘗試納入 multiple-testing 風險。

修正後,這篇文章不再以 $15,789.84 作成功宣言。 真正有研究價值的成果是一個不偷看未來、會在休市日正確運作、 並能讓讀者重現每一筆交易的 backtesting protocol。

參考資料

  • COMAP, Inc. (2022). MCM 2022 Problem C: Trading Strategies and accompanying price data.
  • Bailey, D. H., Borwein, J. M., López de Prado, M., & Zhu, Q. J. (2017). The probability of backtest overfitting. Journal of Computational Finance, 20(4), 39–69.
  • Harvey, C. R., Liu, Y., & Zhu, H. (2016). …and the cross-section of expected returns. Review of Financial Studies, 29(1), 5–68.
  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley.
  • White, H. (2000). A reality check for data snooping. Econometrica, 68(5), 1097–1126.