iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

做量化回測時,不管是均線長度、停損停利(stop loss / take profit)、或持有天數等,都會影響績效。手動一個個試不但花時間,也很容易只在訓練資料上「挑到剛好」的參數(過擬合)。

使用 Optuna

Optuna 是一個好上手、效率高的自動化調參(hyperparameter optimization)工具。你只要把「想最大化的指標」塞進一個 objective() 函式,Optuna 就會幫你探索參數組合、找出表現好的結果。

Optuna 優點

  • 搜尋效率高:預設使用 TPE(Tree-structured Parzen Estimator),通常比純網格(grid)或隨機(random)更省試算次數。
  • API 簡潔:用 trial.suggest_* 就能定義整數、連續、類別、條件式空間。
  • 容易加上實務約束:像「快均線 < 慢均線」這種規則可以直接在 objective() 裡處理。
  • 可重現 & 可擴充:支援 seed、平行化、早停(pruning)、視覺化(visualization)。
  • 易整合:純 Python,跟你既有的回測程式直接接上就行。

範例

接下來我簡單用**雙均線策略(SMA crossover)**示範Optuna的用法跟優點:

  • 策略SMA(fast) > SMA(slow) 持有,否則持有現金(long-only),下單在隔日(避免未來函數)。
  • 要調的參數fast_smaslow_sma
  • 目標:最大化訓練期的年化夏普比(Sharpe ratio)
  • 驗證:用最佳參數在測試期做一次乾淨評估,降低過擬合疑慮。
  • 交易成本:以單邊比例(例:5 bps = 0.05%)近似扣掉進出成本。

安裝依賴:

pip install optuna pandas numpy yfinance

想用美股可把 ticker 改成 "SPY";以下預設台灣 0050(0050.TW)。

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import optuna
from optuna.samplers import TPESampler
import yfinance as yf

# ----------------------------
# 1) 下載資料
# ----------------------------
def load_data(ticker="0050.TW", start="2012-01-01", end=None) -> pd.DataFrame:
    if end is None:
        end = pd.Timestamp.today().strftime("%Y-%m-%d")
    df = yf.download(ticker, start=start, end=end, progress=False)
    df = df[["Close"]].dropna()
    if df.empty:
        raise RuntimeError(f"下載不到 {ticker} 的資料")
    return df

# ----------------------------
# 2) 策略回測:雙均線(long-only)
# ----------------------------
def sma_crossover_backtest(prices: pd.Series, fast: int, slow: int, fee: float = 0.0005):
    """
    規則:
    - 訊號:SMA_fast > SMA_slow -> 持有;否則持有現金
    - 交易成本:fee 為單邊比例(例:0.0005 = 5 bps)
    - 下單:今日訊號,隔日持倉(避免未來函數)
    回傳:Sharpe, CAGR, MaxDD, 交易次數
    """
    if slow >= len(prices):
        return None

    fast_ma = prices.rolling(fast).mean()
    slow_ma = prices.rolling(slow).mean()
    signal = (fast_ma > slow_ma).astype(int)

    pos = signal.shift(1).fillna(0)      # 隔日才持倉
    ret = prices.pct_change().fillna(0)  # 日報酬
    strat_ret = pos * ret

    # 成本:部位變化當天扣一邊成本
    trades = pos.diff().abs().fillna(0)
    strat_ret -= trades * fee

    equity = (1 + strat_ret).cumprod()

    ann = 252
    mu, sigma = strat_ret.mean(), strat_ret.std()
    sharpe = (np.sqrt(ann) * mu / sigma) if sigma > 0 else -np.inf

    years = max(len(equity) / ann, 1e-9)
    cagr = equity.iloc[-1] ** (1 / years) - 1

    maxdd = float((equity / equity.cummax() - 1).min())
    return {"sharpe": float(sharpe), "cagr": float(cagr), "maxdd": maxdd, "trades": int(trades.sum())}

# ----------------------------
# 3) 訓練/測試切分
# ----------------------------
def split_series(df: pd.DataFrame, ratio: float = 0.7):
    n = len(df)
    cut = int(n * ratio)
    return df["Close"].iloc[:cut].copy(), df["Close"].iloc[cut:].copy()

df = load_data(ticker="0050.TW", start="2012-01-01")
train_prices, test_prices = split_series(df, ratio=0.7)

# ----------------------------
# 4) Optuna 目標函式:最大化訓練期 Sharpe
# ----------------------------
def objective(trial: optuna.trial.Trial) -> float:
    fast = trial.suggest_int("fast_sma", 5, 60)
    slow = trial.suggest_int("slow_sma", 30, 300)

    # 實務約束:快均線 < 慢均線,否則剪枝
    if fast >= slow:
        raise optuna.TrialPruned("fast_sma >= slow_sma")

    res = sma_crossover_backtest(train_prices, fast, slow, fee=0.0005)
    if res is None:
        raise optuna.TrialPruned("invalid window")

    return res["sharpe"]

# ----------------------------
# 5) 執行搜尋 + 報告結果
# ----------------------------
sampler = TPESampler(seed=42)  # 固定種子,便於重現
study = optuna.create_study(direction="maximize", sampler=sampler)
study.optimize(objective, n_trials=60, show_progress_bar=True)

print("最佳訓練期 Sharpe:", round(study.best_value, 3))
print("最佳參數:", study.best_params)

best = study.best_params
res_train = sma_crossover_backtest(train_prices, best["fast_sma"], best["slow_sma"], fee=0.0005)
res_test  = sma_crossover_backtest(test_prices,  best["fast_sma"], best["slow_sma"], fee=0.0005)

def show(tag, r):
    print(f"[{tag}] Sharpe={r['sharpe']:.3f}  CAGR={r['cagr']:.2%}  MaxDD={r['maxdd']:.2%}  Trades={r['trades']}")

show("Train", res_train)
show("Test ", res_test)

# 想畫淨值曲線可自行加 matplotlib,可選:
# import matplotlib.pyplot as plt
# plt.figure(figsize=(9,4))
# (1 + (train_prices.pct_change().fillna(0))).cumprod().plot(label="Buy & Hold (Train)")
# plt.legend(); plt.title("Reference Equity"); plt.tight_layout(); plt.show()

可以把目標函式換成 Sortino、Calmar 或多指標加權,並加入最小持有天數、停損/停利等超參數一起交給 Optuna 搜。


上一篇
Day 18 - 回測程式
下一篇
Day 20 - 回測切割價格
系列文
從零開始:AWS 部署 Python 自動交易程式與交易監測 Dashboard 實戰筆記23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言