1. 盘口策略基线回测与压力测试目标设定
基线回测要回答的三个问题
压力测试不是先设阈值再找数据,而是先用未经任何压力假设的正常行情数据跑出一条可信的基线曲线,再从这条曲线上提取“正常水平”作为后续加压的参照。本例围绕一个具体的盘口做市/挂单策略,完成三件事:确定回测口径、跑出基线指标、把指标转成可判定的压力测试判据。
策略逻辑固定如下(后续所有压力测试都不得改动它,只改输入数据与执行假设):
- 标的:单一合约,逐笔委托 + 500ms 快照。
- 信号:买一/卖一价差 ≥ 2 跳时,在买一价挂 1 手买单、卖一价挂 1 手卖单;价差 < 2 跳时撤单不挂。
- 持仓约束:净头寸绝对值 ≤ 3 手,超限方向停止挂单。
- 成交模拟:仅当对手方主动单价格穿越我方挂单价时成交,成交价取挂单价,另计固定 1 跳滑点作为保守估计。
- 费用:按成交金额万分之 0.5 双边计。
回测口径必须先钉死
基线可复现的前提是口径唯一。本例采用的口径如下,后续压力测试只允许在“压力维度”上偏离,其余保持不变:
| 口径项 | 基线设定 | 说明 |
|---|---|---|
| 数据区间 | 连续 20 个交易日 | 剔除涨跌停封板日与半日市 |
| 快照频率 | 500ms | 快照仅用于价差判断,不用于成交判定 |
| 成交判定 | 逐笔主动单穿越 | 避免用快照近似成交造成虚高成交率 |
| 滑点 | 固定 1 跳 | 保守假设,例 3 将对其做敏感性扫描 |
| 手续费 | 万分之 0.5 双边 | 按成交金额计 |
| 初始资金 | 100 万元 | 用于计算收益率与最大回撤 |
| 撮合优先级 | 同价排队,按时间优先 | 我方挂单排在已有同价单之后 |
基线回测的核心实现
下面是一段自洽可运行的回测骨架,输入为逐笔成交与快照两个 DataFrame,输出基线指标字典。关键点在于:信号由快照产生,成交由逐笔产生,两者时间对齐用“快照时间戳 ≤ 逐笔时间戳”的最近快照。
import numpy as np
import pandas as pd
TICK = 0.2 # 最小变动价位(示例合约)
FEE_RATE = 0.00005 # 万分之0.5
SLIP_TICKS = 1
MAX_POS = 3
CAPITAL = 1_000_000.0
def run_baseline(trades: pd.DataFrame, snaps: pd.DataFrame) -> dict:
# trades: ts, price, qty, side('B'主动买/'S'主动卖)
# snaps : ts, bid1, ask1
trades = trades.sort_values('ts').reset_index(drop=True)
snaps = snaps.sort_values('ts').reset_index(drop=True)
pos = 0
cash = 0.0
equity_curve = []
fills = 0
quotes = 0
# 用快照生成挂单意图,按时间推进
snap_idx = 0
active_bid = None # (price, ts)
active_ask = None
for _, t in trades.iterrows():
# 推进到当前逐笔时刻之前最近的快照
while snap_idx + 1 < len(snaps) and snaps.ts.iloc[snap_idx + 1] <= t.ts:
snap_idx += 1
s = snaps.iloc[snap_idx]
spread_ticks = round((s.ask1 - s.bid1) / TICK)
# 更新挂单意图
if spread_ticks >= 2:
if active_bid is None and pos < MAX_POS:
active_bid = s.bid1
quotes += 1
if active_ask is None and pos > -MAX_POS:
active_ask = s.ask1
quotes += 1
else:
active_bid = active_ask = None
# 成交判定:主动卖单打到买一价及以下 → 我方买单成交
if active_bid is not None and t.side == 'S' and t.price <= active_bid:
fill_px = active_bid - SLIP_TICKS * TICK
cash -= fill_px * t.qty
cash -= fill_px * t.qty * FEE_RATE
pos += t.qty
fills += 1
active_bid = None
if active_ask is not None and t.side == 'B' and t.price >= active_ask:
fill_px = active_ask + SLIP_TICKS * TICK
cash += fill_px * t.qty
cash -= fill_px * t.qty * FEE_RATE
pos -= t.qty
fills += 1
active_ask = None
equity_curve.append(cash + pos * t.price)
eq = np.array(equity_curve)
peak = np.maximum.accumulate(eq)
dd = (eq - peak) / np.where(peak == 0, 1, peak)
pnl = eq[-1] - eq[0] if len(eq) else 0.0
return {
'total_pnl': pnl,
'return_pct': pnl / CAPITAL * 100,
'max_drawdown_pct': dd.min() * 100,
'fill_rate': fills / quotes if quotes else 0.0,
'fills': fills,
'quotes': quotes,
'final_pos': pos,
}
运行方式:把清洗后的逐笔与快照数据分别传入 run_baseline,得到基线指标字典。注意 quotes 统计的是挂单意图次数,fills 是实际成交次数,二者之比即成交率——这是后续压力测试最敏感的一个指标。
基线指标与压力测试判据
假设在示例数据上跑出的基线结果如下(示例值,用于说明判据设定方法,非实测):
| 指标 | 基线值 | 压力测试通过判据 | 判据来源 |
|---|---|---|---|
| 日胜率 | 62% | ≥ 50% | 基线减 12 个百分点 |
| 最大回撤 | 4.8% | ≤ 9.6% | 基线 2 倍 |
| 成交率 | 38% | ≥ 19% | 基线 50% |
| 单日最大亏损 | 1.2% | ≤ 3.0% | 基线 2.5 倍 |
| 净头寸越限次数 | 0 | = 0 | 硬约束,不可放宽 |
判据的设定原则是:收益类指标允许衰减到基线的一半,风险类指标允许放大到基线的 2 倍,硬约束(头寸、资金)不允许突破。这样后续每个压力场景只需对照这张表打勾,就能给出通过/失败结论,而不必每次重新讨论阈值。
基线回测的成交率对滑点假设非常敏感。本例固定 1 跳滑点是为了让基线偏保守;如果基线本身用了过于乐观的滑点,后续压力测试的“通过”就失去意义。例 3 会专门扫描滑点参数,验证这一假设的脆弱区间。
基线到判据的映射关系
下图把“正常行情基线 → 指标提取 → 判据阈值 → 压力场景对照”这条链路画清楚,重点在于展示每个基线指标如何被放大或收缩成一条可判定的红线,以及硬约束为何不参与缩放。
判据使用时的两个细节
- 成交率下限不能设得太低。成交率跌到基线一半时,策略的挂单大部分变成“挂而不成”,此时收益指标即使勉强达标,也说明策略在压力下已失去实际执行能力,应视为失败而非通过。
- 硬约束优先于所有软指标。净头寸越限次数只要不为 0,无论收益多好都直接判失败,因为越限意味着风控失效,收益数字不可信。
完成本例后,你手上应有一份固定的策略逻辑、一套固定的回测口径、一组基线指标和一张判据表。后续每个压力场景只需替换输入或执行假设,对照判据表给出结论即可。