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 品牌风格