param_grid_search 补全回撤/Sharpe 指标:从"胜率游戏"到完整风险评估

本文最后更新于 2026年8月29日 凌晨

我曾经只看胜率做网格搜索,挑出一组胜率 78% 的参数,上线后当天回撤 15%。后来才明白:胜率高不等于抗跌强,最大回撤和 Sharpe 才是参数搜索真正的底线。

下面用纯 pandas 写一版 param_grid_search,把这两个指标塞进评分函数。

只按胜率排序,错在哪

胜率只是赢利交易占总交易的比例,它完全不说输的时候有多狠。

做 100 笔交易,赢 70 笔每笔赚 1 元,输 30 笔每笔亏 3 元。胜率 70%,净亏 20 元。

网格搜索里,按胜率排序就会专挑这种「赢小钱、输大钱」的脆弱参数组。它们胜率可能漂亮到 80%,但偶尔一次爆仓就把前几个月的利润吞干净。

所以评分函数至少要知道三件事:赚得多吗(收益)、赚得稳吗(波动)、亏得狠吗(回撤)。胜率只回答了一半。

回撤要算权益曲线

很多人直接用收盘价算回撤。这会把持仓期间的浮盈浮亏全部漏掉。

持有一笔多单,盘内最高涨 3%,收盘跌回原点。用收盘价算,回撤 0%。真实账户明明摸到过 +3%,然后全吐回去。

正确的做法是用权益曲线(equity curve)——账上现金加所有持仓的市值,也就是你随时能变现的总资产。比例回撤的标准公式是:

1
drawdown = equity / equity.cummax() - 1

equity.cummax() 是从起点到当前点的历史最高点。当前权益除以它再减一,就是从峰到谷的跌幅百分比。

拿权益曲线 [100, 110, 90, 95] 举例,历史最高点序列是 [100, 110, 110, 110],对应 drawdown 为 [0, 0, -0.1818, -0.1364],最大回撤就是 -0.1818,约 18.2%。

这里用的是除法,不是减法。equity.cummax() - equity 得到的是绝对亏损额,比如上面例子里的 [0, 0, 20, 15]。这在不同资金规模下不可比——10 万账户亏 2 万和 100 万账户亏 2 万,意义完全不同。我们要比例,才能跨策略、跨资金量比较抗压能力。

实现时还要确保权益曲线的索引是单调递增的时间序列,否则 cummax() 会给出错误结果。

Sharpe 的年化因子要对频率

Sharpe 比率等于年化收益率除以年化波动率。看起来简单,年化因子一旦用错,结果立刻失真。

因子取决于数据频率:

数据频率 年化因子
日线 252
周线 52
小时线 252 × 6(假设每天 6 个交易小时)

关键点在于:分子分母都要年化,缺一不可。

1
sharpe = (mean_return * ppy) / (std_return * np.sqrt(ppy))

如果你只把分母乘了 sqrt(252),分子却忘了年化,Sharpe 会系统性偏低差不多 25 倍(就是 sqrt(252) 本身)。我自己就踩过这个坑:跑出来的 Sharpe 全是 0.3 左右,以为策略完全不行,其实是公式少了一头。

再看因子方向。我用同一条权益曲线测了三种频率,结果是:

  • 日线(252)→ Sharpe 13.70
  • 周线(52)→ Sharpe 6.23
  • 小时线(252×6)→ Sharpe 33.57

也就是说,日线数据误套小时线因子,Sharpe 被抬高 2.4 倍;误套周线因子,被压掉一半多。两种错法都不报错,数字照样打印出来,只是完全不可信。

另外,交易次数太少时 Sharpe 没有统计意义。只跑了两笔交易,赢一输一,胜率 50%,这种小样本算出的 Sharpe 基本是噪声。所以评分前要加 min_trades 过滤。阈值看策略频率,日线策略 30 笔够用,高频策略可能要几百笔。

评分函数:Sharpe 除以绝对回撤

我常用的组合是:

1
score = sharpe / abs(mdd)

mdd 是最大回撤(负数),取绝对值后变成正数当惩罚项。分子越大越好(收益好、波动小),分母越小越好(抗跌强),用除法刚好兼顾两头。

这不是唯一选择:

  • 更看重绝对收益:score = sharpe * (1 - abs(mdd))
  • 极度怕回撤:score = sharpe / (abs(mdd) ** 2),回撤惩罚更重

关键是把风险指标塞进评分逻辑,别让胜率一家独大。

纯 pandas 实现

假设你有一个 run_backtest(params),接收参数字典,返回权益曲线和交易记录:

  • equity:时间索引的 pd.Series必须包含未平仓浮盈亏
  • trades:含 pnl 列的 pd.DataFrame,每笔交易一行,正为赢、负为输
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import itertools

import numpy as np
import pandas as pd


def compute_metrics(equity: pd.Series, trades: pd.DataFrame,
periods_per_year: int = 252) -> dict:
"""计算单组参数的关键风险收益指标。"""
returns = equity.pct_change().dropna()

ppy = float(periods_per_year)
mean_ret = returns.mean()
std_ret = returns.std(ddof=1)
if std_ret > 0:
# 分子分母都要年化,缺一头就偏约 sqrt(ppy) 倍
sharpe = (mean_ret * ppy) / (std_ret * np.sqrt(ppy))
else:
sharpe = 0.0

# 比例最大回撤,基于权益曲线
drawdown = equity / equity.cummax() - 1
mdd = drawdown.min() # 负数,比如 -0.18 表示 18% 回撤

win_rate = (trades['pnl'] > 0).mean() if len(trades) > 0 else 0.0
return {
'sharpe': round(float(sharpe), 3),
'mdd': round(float(mdd), 3),
'win_rate': round(float(win_rate), 3),
'trade_count': int(len(trades)),
'abs_mdd': abs(float(mdd)),
}


def param_grid_search(param_grid: dict, run_backtest, min_trades: int = 30):
"""执行参数网格搜索,返回含风险指标的结果 DataFrame。"""
results = []
keys, values = zip(*param_grid.items())
for combo in itertools.product(*values):
params = dict(zip(keys, combo))
equity, trades = run_backtest(params)

m = compute_metrics(equity, trades)
m.update(params) # 参数也塞进结果,方便对照

# 交易不够,或回撤为 0(曲线单调上涨),都视为无效
if m['trade_count'] >= min_trades and m['abs_mdd'] > 1e-9:
m['score'] = m['sharpe'] / m['abs_mdd']
else:
m['score'] = -np.inf

results.append(m)

results_df = pd.DataFrame(results)
results_df = results_df.sort_values(
['score', 'sharpe'], ascending=[False, False]
)
return results_df

两个细节值得说。

第一,returns.std(ddof=1) 用的是样本标准差。几百个点用 ddof=0 也差不了多少,但样本少的时候,ddof=0 会让 Sharpe 虚高。

第二,mdd 为 0 必须单独处理。曲线一路单调上涨时回撤恒为 0,sharpe / 0 直接炸成无穷大,把这种参数组顶到第一名。这类结果通常是回测没跑满或数据没接对。标记 -inf 让它沉到最底下,比硬除一个 1e-9 干净。

完整代码

run_backtest 需要你自己实现,接口约定如上。这里给一个确定性的假回测函数(用三角函数造曲线,不用随机数,跑多少次结果都一样),用来跑通整条流水线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def dummy_backtest(params):
"""假的回测函数,返回 (equity, trades)。"""
ma_len, thresh = params['ma_len'], params['thresh']
t = np.arange(300)

equity = pd.Series(
100 * (1 + 0.002 * t)
* (1 + 0.05 * np.sin(t / (ma_len * 3))
+ 0.03 * np.cos(t / (ma_len * 1.7))
- 0.001 * thresh * t),
index=pd.date_range('2023-01-01', periods=300),
)

n_trades = 35 + int(ma_len / 5)
i = np.arange(n_trades)
trades = pd.DataFrame(
{'pnl': np.sin(i * (0.7 + thresh * 40)) * 1.2
+ np.cos(i * 1.31) * 0.6}
)
return equity, trades


grid = {'ma_len': [5, 10, 20], 'thresh': [0.01, 0.02, 0.03]}
result = param_grid_search(grid, dummy_backtest, min_trades=30)

print(result[['ma_len', 'thresh', 'win_rate', 'mdd', 'sharpe',
'trade_count', 'score']].to_string(index=False))

实际输出:

1
2
3
4
5
6
7
8
9
10
ma_len  thresh  win_rate    mdd  sharpe  trade_count      score
10 0.01 0.541 -0.021 13.828 37 672.561721
10 0.02 0.486 -0.021 13.703 37 650.332504
10 0.03 0.568 -0.022 13.578 37 628.982792
5 0.01 0.528 -0.088 7.636 36 87.064216
5 0.02 0.500 -0.088 7.575 36 85.842561
5 0.03 0.583 -0.089 7.514 36 84.633644
20 0.01 0.538 0.000 28.466 39 -inf
20 0.02 0.487 0.000 28.153 39 -inf
20 0.03 0.590 0.000 27.840 39 -inf

最后一行最值得看。ma_len=20 那三组的 Sharpe 有 28,比排第一的 13.8 高出一倍,但回撤是 0。只看 Sharpe 它会稳赢,可这个 0 说明曲线全程单调上涨,多半是回测数据有问题。标成 -inf 沉到最底下,比让它骗你强。

再对比前六行:胜率最高的是 ma_len=5, thresh=0.03(0.583),排序里它排第六。按胜率选参数,你会选它;按 score 选,你会选 ma_len=10, thresh=0.01。两个答案差得很远,因为 0.583 那组回撤 8.9%,是 0.541 那组的四倍多。

怎么选

  • 旧代码能跑,只想快速止血:加一行回撤过滤,if mdd < -0.3: continue。今天就能改完。
  • 想认真评估风险:补 Sharpe,用 sharpe / abs(mdd) 排序。改动就是上面三个函数。
  • 想更进一步:把胜率、回撤、Sharpe、盈亏比、交易次数一起输出,跑完先看分布再定参数。

思路和我之前写的《用 5 个公开指标量化「美元信用健康度」》一脉相承:单一指标永远讲不清全故事,得把维度打开看。

能赚钱的策略很多,能赚钱还能好好睡觉的才难得。

参考文献


param_grid_search 补全回撤/Sharpe 指标:从"胜率游戏"到完整风险评估
https://normdist.com/2026/08/29/ND-20260829-001-param-grid-search-mdd-sharpe-risk-scoring/
作者
小瑞
发布于
2026年8月29日
许可协议