
止損距離是量化策略裡最難確定的引數之一。太緊頻繁被掃止損,太鬆單次虧損超預期。
憑直覺設引數沒有說服力。更可靠的方法是:用歷史資料系統地研究不同止損距離對策略表現的影響,找到在多個評估維度上表現均衡的引數區間。
這篇文章講這個研究過程的方法論,包括資料準備、引數搜尋、評估指標、以及防止過擬合的注意事項。
研究框架
1. 資料準備
├─ 選取歷史資料區間(訓練集)
└─ 準備價格序列和訊號序列
2. 引數網格搜尋
├─ 定義止損引數範圍(如 0.5% - 5%,步長 0.1%)
└─ 對每個引數值跑完整回測
3. 評估指標計算
├─ 總收益率
├─ 最大回撤
├─ 夏普比率
├─ 勝率 × 盈虧比
└─ 止損觸發率
4. 結果分析
├─ 繪製引數敏感性曲線
├─ 找穩健區間(小變化 → 小影響)
└─ 在驗證集上驗證
5. 確定最終引數
資料準備
import pandas as pd
import numpy as np
from binance.client import Client
def get_klines(symbol: str, interval: str, start: str, end: str) -> pd.DataFrame:
"""獲取 Binance K 線資料"""
client = Client() # 公開資料不需要 API 金鑰
klines = client.get_historical_klines(symbol, interval, start, end)
df = pd.DataFrame(klines, columns=[
'timestamp', 'open', 'high', 'low', 'close', 'volume',
'close_time', 'quote_vol', 'trades', 'taker_buy_base',
'taker_buy_quote', 'ignore'
])
for col in ['open', 'high', 'low', 'close', 'volume']:
df[col] = df[col].astype(float)
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
# 獲取資料:分成訓練集和驗證集(時間序列不能隨機分割!)
df_train = get_klines("BTCUSDT", "4h", "2023-01-01", "2024-06-30") # 訓練集
df_val = get_klines("BTCUSDT", "4h", "2024-07-01", "2024-12-31") # 驗證集
為什麼不能隨機分割?
時間序列有前向偏差問題:如果用2024年資料訓練,再在2023年資料上驗證,相當於"用未來預測過去",結果毫無參考價值。訓練集必須在時間上早於驗證集。
ATR 標準化止損距離
直接用固定百分比作為止損距離有個問題:市場低波動期 1% 已經很寬,高波動期 1% 可能很窄。
用 ATR 倍數作為引數更穩健:
def calc_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""計算 ATR"""
high_low = df['high'] - df['low']
high_close = (df['high'] - df['close'].shift(1)).abs()
low_close = (df['low'] - df['close'].shift(1)).abs()
tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
return tr.rolling(period).mean()
df_train['atr'] = calc_atr(df_train)
df_train['stop_pct_1atr'] = df_train['atr'] / df_train['close'] # 1 ATR 對應的百分比
研究的引數就變成:止損 = N 倍 ATR,其中 N 是我們要最佳化的引數(比如 0.5 到 3.0 之間的值)。
簡化的回測框架
def backtest(df: pd.DataFrame, signals: pd.Series, sl_atr_mult: float,
tp_atr_mult: float = None) -> dict:
"""
簡化回測,評估特定止損引數的策略表現
signals: 訊號序列,1=做多,-1=做空,0=空倉
sl_atr_mult: 止損距離(ATR 倍數)
tp_atr_mult: 止盈距離(ATR 倍數),None = 持有到下個訊號
"""
trades = []
position = 0
entry_price = 0
entry_atr = 0
for i in range(1, len(df)):
row = df.iloc[i]
prev_row = df.iloc[i-1]
sig = signals.iloc[i-1] # 用前一根 K 線收盤的訊號,下一根開盤執行
# 持倉狀態:檢查是否觸及止損/止盈
if position != 0:
if position == 1: # 多倉
stop_price = entry_price - entry_atr * sl_atr_mult
if row['low'] <= stop_price:
# 止損出場(假設以止損價成交)
pnl = (stop_price - entry_price) / entry_price
trades.append({'type': 'stop_loss', 'pnl': pnl})
position = 0
continue
if tp_atr_mult and row['high'] >= entry_price + entry_atr * tp_atr_mult:
tp_price = entry_price + entry_atr * tp_atr_mult
pnl = (tp_price - entry_price) / entry_price
trades.append({'type': 'take_profit', 'pnl': pnl})
position = 0
continue
# 沒有持倉:檢查新訊號
if position == 0 and sig == 1:
position = 1
entry_price = row['open']
entry_atr = row['atr']
elif position == 1 and sig != 1:
# 訊號消失,市價出場
pnl = (row['open'] - entry_price) / entry_price
trades.append({'type': 'signal_exit', 'pnl': pnl})
position = 0
if not trades:
return {'total_return': 0, 'max_drawdown': 0, 'sharpe': 0,
'win_rate': 0, 'stop_loss_rate': 0, 'n_trades': 0}
pnls = [t['pnl'] for t in trades]
cumulative = np.cumprod([1 + p for p in pnls])
# 最大回撤
peak = np.maximum.accumulate(cumulative)
drawdowns = (peak - cumulative) / peak
max_dd = drawdowns.max()
# 夏普(簡化)
mean_r = np.mean(pnls)
std_r = np.std(pnls) if np.std(pnls) > 0 else 1e-10
sharpe = mean_r / std_r * np.sqrt(len(pnls))
stop_loss_trades = sum(1 for t in trades if t['type'] == 'stop_loss')
return {
'total_return': cumulative[-1] - 1,
'max_drawdown': max_dd,
'sharpe': sharpe,
'win_rate': sum(1 for p in pnls if p > 0) / len(pnls),
'stop_loss_rate': stop_loss_trades / len(trades),
'n_trades': len(trades)
}
引數網格搜尋
import matplotlib.pyplot as plt
def grid_search_stop_loss(df: pd.DataFrame, signals: pd.Series,
sl_range: np.ndarray) -> pd.DataFrame:
"""遍歷止損引數,記錄各指標"""
results = []
for sl_mult in sl_range:
stats = backtest(df, signals, sl_atr_mult=sl_mult)
stats['sl_mult'] = sl_mult
results.append(stats)
return pd.DataFrame(results).set_index('sl_mult')
# 搜尋範圍:0.3 到 3.0 倍 ATR,步長 0.1
sl_range = np.arange(0.3, 3.1, 0.1)
results = grid_search_stop_loss(df_train, signals_train, sl_range)
print(results[['total_return', 'max_drawdown', 'sharpe', 'win_rate', 'stop_loss_rate']])
視覺化引數敏感性
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
metrics = ['total_return', 'max_drawdown', 'sharpe', 'stop_loss_rate']
titles = ['總收益率', '最大回撤', '夏普比率', '止損觸發率']
for ax, metric, title in zip(axes.flat, metrics, titles):
ax.plot(results.index, results[metric], marker='o', markersize=3)
ax.set_title(title)
ax.set_xlabel('止損倍數(× ATR)')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('stop_loss_sensitivity.png', dpi=150)
如何解讀結果
檢視圖表時,尋找以下特徵:
穩健區間:某個引數範圍內,夏普比率相對穩定(圖形平坦),而不是在某個點突然最優。如果夏普率的峰值非常尖(只有一個點最好,周圍很差),這是過擬合的訊號。
止損觸發率:
- 止損率 > 70%:止損太緊,大部分交易都被掃了
- 止損率 < 20%:止損太鬆,或者訊號本身質量很高
- 合理範圍:30%-60%,取決於策略型別
收益 vs 回撤權衡:夏普最高的引數未必就是最好的,還要看最大回撤是否在可承受範圍內(比如你能接受的最大回撤是 20%,超過這個就不考慮)。
驗證集驗證
在訓練集上找到候選引數後,必須在驗證集(hold-out set)上驗證:
# 在訓練集上找到的最優引數(假設是 1.5 倍 ATR)
best_sl_mult = 1.5
# 在驗證集上驗證
val_result = backtest(df_val, signals_val, sl_atr_mult=best_sl_mult)
train_result = backtest(df_train, signals_train, sl_atr_mult=best_sl_mult)
print(f"訓練集夏普:{train_result['sharpe']:.2f}")
print(f"驗證集夏普:{val_result['sharpe']:.2f}")
print(f"訓練集回撤:{train_result['max_drawdown']:.2%}")
print(f"驗證集回撤:{val_result['max_drawdown']:.2%}")
健康的狀態:驗證集結果略差於訓練集,但方向一致(都盈利,夏普都是正的,回撤在同一數量級)。
過擬合的跡象:訓練集夏普 3.0,驗證集夏普 -0.5。說明引數只是擬合了訓練資料的特定噪聲,沒有真實預測力。
過擬合防範
幾個實用原則:
- 不要只看一個最優點:選引數區間裡表現都穩定的值,而不是某個孤立的最優點
- 引數不能太多:每增加一個引數,需要的歷史資料量成倍增加
- 用不同市場環境分段驗證:牛市段、熊市段、震盪段分別測,引數要在三種情況下都能用
- 保留足夠的樣本外資料:至少 30% 的資料不參與最佳化
- 引數的經濟邏輯:結果要能用市場微結構原理解釋,純數字擬合出來的引數通常不耐用
止損引數研究是一個迭代過程,不是跑一次就完了。市場結構在變,定期重新驗證引數是否還在合理區間,是長期執行策略的必要維護工作。
→ 檢視 Binance 合約交易風控體系 → 檢視 Binance API 從申請到第一筆下單
📷 封面圖 · Flux Schnell AI 生成 · panshi.io 品牌風格