Futuristic abstract digital render depicting geometric shape

止損距離是量化策略裡最難確定的引數之一。太緊頻繁被掃止損,太鬆單次虧損超預期。

憑直覺設引數沒有說服力。更可靠的方法是:用歷史資料系統地研究不同止損距離對策略表現的影響,找到在多個評估維度上表現均衡的引數區間。

這篇文章講這個研究過程的方法論,包括資料準備、引數搜尋、評估指標、以及防止過擬合的注意事項。

研究框架

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。說明引數只是擬合了訓練資料的特定噪聲,沒有真實預測力。

過擬合防範

幾個實用原則:

  1. 不要只看一個最優點:選引數區間裡表現都穩定的值,而不是某個孤立的最優點
  2. 引數不能太多:每增加一個引數,需要的歷史資料量成倍增加
  3. 用不同市場環境分段驗證:牛市段、熊市段、震盪段分別測,引數要在三種情況下都能用
  4. 保留足夠的樣本外資料:至少 30% 的資料不參與最佳化
  5. 引數的經濟邏輯:結果要能用市場微結構原理解釋,純數字擬合出來的引數通常不耐用

止損引數研究是一個迭代過程,不是跑一次就完了。市場結構在變,定期重新驗證引數是否還在合理區間,是長期執行策略的必要維護工作。

→ 檢視 Binance 合約交易風控體系 → 檢視 Binance API 從申請到第一筆下單


📷 封面圖 · Flux Schnell AI 生成 · panshi.io 品牌風格