精華筆記

· @aihub.tw

AI 量化交易

策略基礎:均線、動量與過擬合陷阱

策略基礎:均線、動量與過擬合陷阱

第 2 課你拿到了乾淨的 OHLCV 資料:開高低收量都在 DataFrame 裡了,時間戳對齊,缺漏值補好,分割時區也對了。現在你面對這份資料,腦子裡浮現一個問題——「我要怎麼用它來做決策?」

這時候最常見的劇本是:拉開 TradingView,手動畫一條 20 日均線和 60 日均線,找到幾個交叉的位置,發現「欸好像每次黃金交叉之後都漲欸!」然後迫不及待寫成程式回測,跑出「年化報酬 156%、最大回撤 8%」。

然後你把策略上線——開始虧錢。

這堂課要解釋為什麼,以及你應該怎麼做。

風險警語 本課程所有內容僅供學習與研究用途,不構成任何投資建議。金融交易涉及本金虧損風險,過去的回測績效不代表未來實盤結果。本課程全程使用模擬盤教學;若你在學習後決定投入實盤,風險與責任由你自行承擔。量化策略在現實市場中可能持續虧損,請先確認自身風險承受能力。
這堂課適合誰(工程師應用專區) 適合:有 Python 基礎(會操作 pandas DataFrame、能執行 pip 安裝)、看過第 2 課資料清洗流程、想從程式層面理解技術策略原理的人。需要基礎:Python 初階 + pandas 基本操作(df['col']、.pct_change()、.rolling())。前置課:第 2 課(數據層:行情資料的取得與清洗)。

這堂學什麼

  • 三類經典策略白話解釋:均線交叉(趨勢跟隨)、動量、均值回歸——押的邏輯根本不一樣
  • pandasta 套件實作指標計算,從 SMA 到 RSI 逐行讀懂
  • 過擬合(Overfitting)是散戶頭號殺手:參數挖掘的騙局,為什麼你的回測靚麗到不科學
  • 樣本內(In-Sample)vs 樣本外(Out-of-Sample)測試,建立誠實評估習慣
  • 實戰:實作均線交叉與動量兩個策略,並誠實解讀結果

觀念一:三類策略押的是完全不同的市場假設

先別急著寫程式。你要先搞清楚:你相信市場是什麼形狀的?三類策略背後的假設互相矛盾,混著用通常兩邊都搞不好。

三類策略邏輯對照:趨勢跟隨、動量、均值回歸的假設、指標與弱點

趨勢跟隨(均線交叉)

最直覺的策略類型。核心假設:市場有慣性,趨勢不會瞬間反轉。你的程式只做一件事——等確認,再跟上

常見做法是看短期均線是否突破長期均線:短線從下往上穿越長線叫「黃金交叉」,做多;短線從上往下穿越長線叫「死亡交叉」,做空或離場。

這類策略最大的問題是震盪市:在沒有方向的橫盤行情裡,短均線和長均線不停你追我、我追你,每次交叉都觸發一筆交易,然後立刻反向,你的資金就這樣被手續費磨光。

動量策略

動量策略的假設比趨勢更簡單暴力:「最近漲最多的,之後還會漲。」

學術研究中的動量效應有相當紮實的實證支撐(Jegadeesh & Titman 1993 年的論文是奠基之作),在傳統股市上 3-12 個月的相對強弱確實存在系統性的超額報酬——但這是橫截面動量(選相對強的股票),跟你在加密貨幣市場用 20 日漲幅做擇時完全不同。不要把學術結論直接套用在自己的策略上。

均值回歸

均值回歸押的邏輯相反:偏離平均越遠,越可能回頭。RSI 超賣、布林通道下軌、與均線的偏差過大——都是這類訊號的影子。

這類策略在震盪市如魚得水,但遇到趨勢行情是夢魘:「這次不一樣」的崩盤走勢裡,越跌越買的均值回歸策略可以把帳戶歸零。2020 年 3 月疫情崩盤、2022 年 LUNA 崩潰,都有人在這類策略上全軍覆沒。

觀念二:用 pandas 與 ta 計算指標

環境準備

pip install ta pandas numpy

ta 這個套件(Technical Analysis Library)提供 130+ 個技術指標的純 Python 實作,不需要安裝 C 語言的 TA-Lib 函式庫(後者在 Windows 上裝起來會讓你懷疑人生)。

載入範例資料

假設你從第 2 課的流程拿到乾淨的 DataFrame:

import pandas as pd
import numpy as np
import ta

# 假設 df 是你從第 2 課清洗好的資料
# 欄位: open, high, low, close, volume
# index: DatetimeIndex (UTC, 無缺漏)
# 這裡用簡單的模擬資料示範結構
np.random.seed(42)
dates = pd.date_range("2022-01-01", periods=500, freq="D", tz="UTC")
price = 30000 * np.cumprod(1 + np.random.normal(0.0005, 0.02, 500))
df = pd.DataFrame({
    "open":   price * 0.999,
    "high":   price * 1.015,
    "low":    price * 0.985,
    "close":  price,
    "volume": np.random.randint(5000, 15000, 500).astype(float),
}, index=dates)

print(df.shape)   # (500, 5)
print(df.tail(3))

均線計算

# 簡單移動平均 Simple Moving Average
df["sma_20"] = ta.trend.sma_indicator(df["close"], window=20)
df["sma_60"] = ta.trend.sma_indicator(df["close"], window=60)

# 指數移動平均 Exponential Moving Average(對近期價格更敏感)
df["ema_20"] = ta.trend.ema_indicator(df["close"], window=20)

# 手動用 pandas rolling 算 SMA:效果一樣
df["sma_20_manual"] = df["close"].rolling(window=20).mean()

# 確認一致
print((df["sma_20"] - df["sma_20_manual"]).abs().max())  # 接近 0

RSI 與 ATR

# RSI:相對強弱指數 (14 日標準)
df["rsi_14"] = ta.momentum.rsi(df["close"], window=14)

# ATR:平均真實波幅,常用於停損設定
df["atr_14"] = ta.volatility.average_true_range(
    df["high"], df["low"], df["close"], window=14
)

# 看看計算結果
print(df[["close", "sma_20", "rsi_14", "atr_14"]].dropna().head(10))

技術指標解剖圖:價格加均線、RSI、ATR 三層疊圖,標出黃金交叉、死亡交叉與超買超賣

觀念三:過擬合是散戶的頭號殺手

這是這堂課最重要的部分。請認真讀。

什麼是過擬合?

你拿到 2019–2024 年的 BTC/USDT 日線資料,開始調整均線參數:試了 (20, 60)、試了 (10, 30)、試了 (15, 45)、試了 (25, 100)……最後找到 (23, 71) 這組參數,回測夏普值 2.1、年化 43%。

你有沒有想過:為什麼是 23 和 71?

答案是:因為在 2019–2024 年的這段歷史裡,23/71 恰好是表現最好的。但市場不是考試卷——它不會再出現完全一樣的題目。你「找到」的不是一個有預測力的規律,而是一個把歷史偶然性記死了的曲線

這就叫過擬合。

過擬合陷阱:左邊樣本內完美擬合、右邊樣本外淨值走平走低,中間以封存點分隔

參數挖掘(Parameter Mining)的騙局

以下這段程式碼寫起來很爽,但是錯的——它是在製造幻覺:

# !! 警告:這段程式碼示範的是錯誤的做法 !!
# 在全部資料上暴力搜尋最佳參數 = 過擬合

best_return = -999
best_params = (0, 0)

for short_w in range(5, 50):
    for long_w in range(20, 200):
        if short_w >= long_w:
            continue
        # 計算訊號
        sig = (
            df["close"].rolling(short_w).mean()
            > df["close"].rolling(long_w).mean()
        ).astype(int)
        daily_ret = df["close"].pct_change()
        strat_ret = (sig.shift(1) * daily_ret).sum()
        if strat_ret > best_return:
            best_return = strat_ret
            best_params = (short_w, long_w)

print(f"最佳參數: {best_params}, 總報酬: {best_return:.2%}")
# 你會得到一個「完美」的參數組合
# 但在未來的資料上,它幾乎一定輸給隨機

這段程式碼跑了 (549) × (20199) = 約 3,500 種組合。你從中挑最好的那個,就像讓 3,500 個猴子丟硬幣,再宣稱第一名的猴子「很會投資」。

過度最佳化的症狀

怎麼知道自己的策略可能過擬合了?看這幾個警訊:

症狀 說明
樣本內夏普值 > 2,但樣本外接近 0 最典型的過擬合特徵
策略僅在特定市場狀態下有效 例如只在 2021 牛市有效
參數稍微改動就天差地遠 穩健策略對參數不敏感
最佳參數是奇怪的數字(如 23/71) 沒有理論依據的數字更可疑
交易頻率極低或極高 可能是對噪音過度擬合

觀念四:樣本內外測試——最後一道防線

正確的流程是:在看結果之前,就先把資料鎖住一部分,不讓自己在開發過程中「偷看」。

# 正確做法:先切割,再開發
# 70% 訓練期 (In-Sample):開發策略、調整參數
# 30% 驗證期 (Out-of-Sample):封存,等策略定案後才打開

split_idx = int(len(df) * 0.7)

df_train = df.iloc[:split_idx].copy()   # 你開發時只能用這段
df_test  = df.iloc[split_idx:].copy()   # 策略定案後才動這段

print(f"訓練期: {df_train.index[0].date()} ~ {df_train.index[-1].date()}")
print(f"驗證期: {df_test.index[0].date()} ~ {df_test.index[-1].date()}")

關鍵紀律:驗證期的資料在策略定案之前不能動。 一旦你根據驗證期的結果回頭修改策略,驗證期就「污染」了,不再是真正的樣本外測試。這時候你要麼承認策略不過關、要麼收集更多新的測試資料。

樣本內外拆分時間軸:訓練期約 70% 可自由操作、驗證期約 30% 封存,只測一次

更嚴謹的做法是滾動驗證(Walk-Forward Analysis):把時間軸分成多個小視窗,每次用前 N 個月訓練,測第 N+1 個月,滾動前進。這是 freqtrade 第 4 課回測實戰的重點,這裡先建立觀念。

手把手實戰

我們來實作兩個策略:均線交叉和動量,並且誠實比較它們在訓練期和驗證期的差距。

Step 1:準備計算環境與資料

建立一個新的 Python 檔案 strategy_basics.py。先做好資料切割,這個順序不能換——先切才開發:

import pandas as pd
import numpy as np
import ta

# 用第 2 課清洗好的資料路徑,或沿用模擬資料
# 這裡示範讀取從 Binance 存下來的 CSV
# df = pd.read_csv("data/BTC_USDT_1d.csv", index_col=0, parse_dates=True)

# 示範用:生成 3 年模擬資料
np.random.seed(99)
dates = pd.date_range("2021-01-01", periods=1100, freq="D", tz="UTC")
price = 30000 * np.cumprod(1 + np.random.normal(0.0003, 0.025, 1100))
df = pd.DataFrame({
    "close":  price,
    "high":   price * np.random.uniform(1.005, 1.025, 1100),
    "low":    price * np.random.uniform(0.975, 0.995, 1100),
    "volume": np.random.randint(8000, 20000, 1100).astype(float),
}, index=dates)

# 切割:70/30
split = int(len(df) * 0.7)
df_train = df.iloc[:split].copy()
df_test  = df.iloc[split:].copy()

print(f"訓練期 {len(df_train)} 天 | 驗證期 {len(df_test)} 天")

Step 2:實作均線交叉策略

def sma_crossover_strategy(data: pd.DataFrame,
                           short_w: int = 20,
                           long_w: int  = 60) -> pd.DataFrame:
    """
    均線交叉策略
    - signal = 1:做多 (short SMA > long SMA)
    - signal = 0:空倉 (short SMA < long SMA)
    不做空,只有持有或不持有。
    """
    d = data.copy()
    d["sma_short"] = ta.trend.sma_indicator(d["close"], window=short_w)
    d["sma_long"]  = ta.trend.sma_indicator(d["close"], window=long_w)

    d["signal"] = 0
    d.loc[d["sma_short"] > d["sma_long"], "signal"] = 1

    # 每日報酬
    d["daily_ret"]  = d["close"].pct_change()
    # 策略報酬:用前一天的訊號(shift(1) 避免未來函數)
    d["strat_ret"]  = d["signal"].shift(1) * d["daily_ret"]

    return d.dropna()


def evaluate(d: pd.DataFrame, label: str) -> dict:
    """計算簡單績效指標"""
    total  = (1 + d["strat_ret"]).prod() - 1
    ann    = (1 + d["strat_ret"].mean()) ** 252 - 1
    vol    = d["strat_ret"].std() * np.sqrt(252)
    sharpe = ann / vol if vol > 0 else 0
    mdd    = (d["strat_ret"].cumsum() - d["strat_ret"].cumsum().cummax()).min()
    n_trades = d["signal"].diff().abs().sum() / 2  # 大約的進出場次數

    print(f"\n--- {label} ---")
    print(f"  總報酬:    {total:>8.1%}")
    print(f"  年化報酬:  {ann:>8.1%}")
    print(f"  年化波幅:  {vol:>8.1%}")
    print(f"  Sharpe:    {sharpe:>8.2f}")
    print(f"  最大回撤:  {mdd:>8.1%}")
    print(f"  約交易次數:{n_trades:.0f} 次")
    return {"total": total, "sharpe": sharpe, "mdd": mdd}


# 在訓練期開發,用預設參數 (20/60)
train_sma = sma_crossover_strategy(df_train, short_w=20, long_w=60)
evaluate(train_sma, "均線交叉 / 訓練期 (20/60)")

# 封存!不要根據訓練期結果調參數後再看驗證期
# 直接用相同參數在驗證期執行
test_sma = sma_crossover_strategy(df_test, short_w=20, long_w=60)
evaluate(test_sma, "均線交叉 / 驗證期 (20/60) — 誠實結果")

通常你會發現:驗證期的夏普值比訓練期差一截。這是正常的——如果兩邊差距在合理範圍內(例如訓練期 Sharpe 0.6,驗證期 0.3),策略還有討論空間;如果訓練期 Sharpe 2.1、驗證期 -0.4,那就是過擬合,這個策略不能用。

Step 3:實作動量策略

def momentum_strategy(data: pd.DataFrame,
                      lookback: int = 20,
                      threshold: float = 0.0) -> pd.DataFrame:
    """
    N 日動量策略
    - lookback 日報酬率 > threshold → 做多
    - lookback 日報酬率 <= threshold → 空倉
    """
    d = data.copy()
    # N 日報酬率 (Rate of Change)
    d["roc"] = d["close"].pct_change(periods=lookback)

    d["signal"] = 0
    d.loc[d["roc"] > threshold, "signal"] = 1

    d["daily_ret"] = d["close"].pct_change()
    d["strat_ret"] = d["signal"].shift(1) * d["daily_ret"]

    return d.dropna()


# 訓練期
train_mom = momentum_strategy(df_train, lookback=20, threshold=0.0)
evaluate(train_mom, "動量 20 日 / 訓練期")

# 驗證期
test_mom = momentum_strategy(df_test, lookback=20, threshold=0.0)
evaluate(test_mom, "動量 20 日 / 驗證期")

Step 4:誠實對比與結論

把兩個策略的訓練/驗證結果列成表格,強迫自己正視落差:

results = {
    "均線交叉_訓練期": evaluate(train_sma, ""),
    "均線交叉_驗證期": evaluate(test_sma,  ""),
    "動量_訓練期":     evaluate(train_mom, ""),
    "動量_驗證期":     evaluate(test_mom,  ""),
}

summary = pd.DataFrame(results).T
summary.columns = ["總報酬", "Sharpe", "最大回撤"]
print("\n=== 誠實結果對比 ===")
print(summary.to_string(float_format="{:.2f}".format))

看完這張表,問自己三個問題:

  1. 驗證期的 Sharpe 是否還是正的?
  2. 訓練期和驗證期的落差是否在 50% 以內?(落差越大,過擬合越嚴重)
  3. 最大回撤你能接受嗎?

如果三個問題都說得過去——恭喜,這個策略值得帶進下一課的 freqtrade 回測框架做更嚴謹的驗證。如果不行——扔掉,從觀念重新來。大多數策略就是這樣被扔掉的,這很正常。

策略評估流程圖:從想法到訓練期評估、封存驗證、樣本外落差判斷,每步有 Yes/No 分叉

常見坑

坑 1:未來函數(Look-Ahead Bias)——回測完美但根本不可能執行

最隱蔽的坑。症狀:策略看起來能準確預測每一次高低點,回測夏普值 > 3。

發生原因通常是忘記 .shift(1):

# 錯誤!用的是「今天」的訊號買「今天」的收盤
d["strat_ret"] = d["signal"] * d["daily_ret"]

# 正確!用「昨天」收盤計算的訊號,買「今天」收盤
d["strat_ret"] = d["signal"].shift(1) * d["daily_ret"]

另一個常見來源是在計算指標時不小心用到「未來」的價格(例如 .rolling().mean() 配合時間對齊出錯)。每次看到「完美到不科學的回測結果」,第一反應要懷疑自己有未來函數。

坑 2:交易成本沒算——回測賺、實盤虧

錯誤訊息不會報出來,這個坑更危險。一個均線策略如果一年進出 150 次,每次用市價單承受 0.1% 的滑點加上 0.1% 的手續費,一年光成本就吃掉 30%。

修法:在計算策略報酬時扣掉成本:

# 偵測進場動作:signal 從 0 變 1 或從 1 變 0
trades = d["signal"].diff().abs().fillna(0)

# 每次交易假設總成本 0.2%(手續費 + 滑點)
transaction_cost = 0.002

d["strat_ret_net"] = (
    d["signal"].shift(1) * d["daily_ret"]
    - trades * transaction_cost
)

evaluate_net = (1 + d["strat_ret_net"]).prod() - 1
print(f"扣費後總報酬: {evaluate_net:.1%}")

很多「年化 20%」的策略扣完交易成本後變成「年化 -5%」。這才是真相。

坑 3:.rolling() 的 NaN 沒處理,訊號莫名其妙

執行完策略後發現前 N 個交易日的訊號是空的(NaN),結果統計出來比預期少好幾百筆:

ValueError: cannot convert float NaN to integer
# 或是訊號欄位裡出現大量 NaN,導致策略報酬計算錯誤

原因:.rolling(window=60).mean() 在前 59 天沒有足夠資料,回傳 NaN

解法:用 dropna() 或設定 min_periods:

# 方法一:直接 dropna 去掉前 N 天
d = d.dropna(subset=["sma_long", "sma_short"])

# 方法二:允許部分資料就開始計算(謹慎使用,可能失真)
d["sma_60"] = df["close"].rolling(60, min_periods=30).mean()

坑 4:在全資料集上先「看一眼」就汙染了驗證期

這個坑是習慣問題。你在做 EDA(探索性分析)時把整份資料拉出來畫圖、看分布、找感覺——然後你的「直覺」其實已經包含了驗證期的資訊。

解法:建立嚴格的工作流程:所有 EDA 和參數調整只對 df_train 操作。df_test 的讀取語句只在最後的「正式評估」區塊才執行。

作業

  1. 用你第 2 課下載的真實行情資料(不要用模擬資料)跑這兩個策略,把訓練期和驗證期的 Sharpe 列出來。如果驗證期 Sharpe < 0,誠實記錄這個結果——這不是你失敗了,這是量化的正常過程。
  2. 試著在訓練期做一次錯誤的「暴力最佳化」(就是本課警告的那段程式碼),找出「最佳」參數;再拿同樣參數跑驗證期,親眼目睹落差。
  3. 選做進階:把均線交叉加上 ATR 停損——做多後若收盤跌破進場價 2×ATR,就平倉。對比加停損前後的最大回撤變化。
再一次:風險在這裡,不要跳過 本課任何程式碼和策略邏輯都是教學示範用途。策略在模擬資料或歷史回測上有正報酬,**不代表在真實市場能賺錢**。過擬合、市場狀態改變、流動性衝擊都可能讓看起來有效的策略在實盤快速虧損。第 6 課會深入討論風控;在那之前,請維持全程模擬盤操作。

下一課預告

你現在有了能跑起來的策略邏輯,也建立了樣本內外測試的誠實習慣。但我們的評估還非常粗糙:手動計算的報酬沒有考慮訂單簿深度、沒有模擬真實的交易延遲、也沒有做資金管理。

第 4 課帶你進入 freqtrade 實戰——業界最主流的開源量化框架(免費、有完整回測引擎、支援 Telegram 控制、可直連 Binance 等主要交易所)。你會把今天的策略邏輯翻譯成 freqtrade 的格式、在它的框架裡跑一次完整回測,然後看到一件讓你意外的事:freqtrade 默默幫你處理了多少你剛才沒想到的細節。

#AI 量化交易#量化策略#均線交叉#動量策略#過擬合#pandas#ta

← 回所有文章