策略基礎:均線、動量與過擬合陷阱
第 2 課你拿到了乾淨的 OHLCV 資料:開高低收量都在 DataFrame 裡了,時間戳對齊,缺漏值補好,分割時區也對了。現在你面對這份資料,腦子裡浮現一個問題——「我要怎麼用它來做決策?」
這時候最常見的劇本是:拉開 TradingView,手動畫一條 20 日均線和 60 日均線,找到幾個交叉的位置,發現「欸好像每次黃金交叉之後都漲欸!」然後迫不及待寫成程式回測,跑出「年化報酬 156%、最大回撤 8%」。
然後你把策略上線——開始虧錢。
這堂課要解釋為什麼,以及你應該怎麼做。
這堂學什麼
- 三類經典策略白話解釋:均線交叉(趨勢跟隨)、動量、均值回歸——押的邏輯根本不一樣
- 用
pandas與ta套件實作指標計算,從 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))

觀念三:過擬合是散戶的頭號殺手
這是這堂課最重要的部分。請認真讀。
什麼是過擬合?
你拿到 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()}")
關鍵紀律:驗證期的資料在策略定案之前不能動。 一旦你根據驗證期的結果回頭修改策略,驗證期就「污染」了,不再是真正的樣本外測試。這時候你要麼承認策略不過關、要麼收集更多新的測試資料。

更嚴謹的做法是滾動驗證(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))
看完這張表,問自己三個問題:
- 驗證期的 Sharpe 是否還是正的?
- 訓練期和驗證期的落差是否在 50% 以內?(落差越大,過擬合越嚴重)
- 最大回撤你能接受嗎?
如果三個問題都說得過去——恭喜,這個策略值得帶進下一課的 freqtrade 回測框架做更嚴謹的驗證。如果不行——扔掉,從觀念重新來。大多數策略就是這樣被扔掉的,這很正常。

常見坑
坑 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 的讀取語句只在最後的「正式評估」區塊才執行。
作業
- 用你第 2 課下載的真實行情資料(不要用模擬資料)跑這兩個策略,把訓練期和驗證期的 Sharpe 列出來。如果驗證期 Sharpe < 0,誠實記錄這個結果——這不是你失敗了,這是量化的正常過程。
- 試著在訓練期做一次錯誤的「暴力最佳化」(就是本課警告的那段程式碼),找出「最佳」參數;再拿同樣參數跑驗證期,親眼目睹落差。
- 選做進階:把均線交叉加上 ATR 停損——做多後若收盤跌破進場價 2×ATR,就平倉。對比加停損前後的最大回撤變化。
下一課預告
你現在有了能跑起來的策略邏輯,也建立了樣本內外測試的誠實習慣。但我們的評估還非常粗糙:手動計算的報酬沒有考慮訂單簿深度、沒有模擬真實的交易延遲、也沒有做資金管理。
第 4 課帶你進入 freqtrade 實戰——業界最主流的開源量化框架(免費、有完整回測引擎、支援 Telegram 控制、可直連 Binance 等主要交易所)。你會把今天的策略邏輯翻譯成 freqtrade 的格式、在它的框架裡跑一次完整回測,然後看到一件讓你意外的事:freqtrade 默默幫你處理了多少你剛才沒想到的細節。