做量化回測時,不管是均線長度、停損停利(stop loss / take profit)、或持有天數等,都會影響績效。手動一個個試不但花時間,也很容易只在訓練資料上「挑到剛好」的參數(過擬合)。
Optuna 是一個好上手、效率高的自動化調參(hyperparameter optimization)工具。你只要把「想最大化的指標」塞進一個 objective()
函式,Optuna 就會幫你探索參數組合、找出表現好的結果。
trial.suggest_*
就能定義整數、連續、類別、條件式空間。objective()
裡處理。接下來我簡單用**雙均線策略(SMA crossover)**示範Optuna的用法跟優點:
SMA(fast) > SMA(slow)
持有,否則持有現金(long-only),下單在隔日(避免未來函數)。fast_sma
與 slow_sma
。安裝依賴:
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 搜。