4. 盘口策略对手续费与返佣结构的压力测试
费率结构建模:把手续费与返佣拆成可扫描的参数
盘口策略的净收益对费率极其敏感,因为单笔毛利往往只有 1~2 个最小报价单位。压力测试的第一步不是跑策略,而是把"手续费 + 返佣"抽象成一组可独立扫描的参数,并明确它们作用在成交的哪一侧。
本例采用如下费率模型(示例参数,非实测):
- taker 费率
f_t:主动吃单成交时按成交金额比例收取,基准 0.02%(2 bp)。 - maker 费率
f_m:被动挂单成交时收取,基准 0.00%,即零费。 - maker 返佣
r_m:被动成交后按成交金额返还,基准 +0.01%(1 bp),即净收 1 bp。 - 单笔净费率:主动腿
-f_t,被动腿r_m - f_m。一次完整往返(一主一被)的净费率 =r_m - f_m - f_t。
基准情景下往返净费率为 0.01% - 0% - 0.02% = -0.01%,即每往返一次净付出 1 bp。这个数字必须与策略的平均单笔毛利对比,才能判断生存空间。
策略侧的关键耦合:换手频率决定费率侵蚀总量
单笔净费率只是"单价",真正决定净收益的是费率 × 换手次数。同一策略在费率上调后,除了单笔成本上升,还可能因为信号阈值相对变差而改变成交节奏,因此必须同时记录换手频率,否则会误判策略的生存能力。
本例用一个最小可运行的盘口策略回放框架来量化这一点。策略逻辑固定:当买一价与卖一价之差(点差)大于阈值时,在买一价挂被动买单、在卖一价挂被动卖单;成交后立即用主动单平掉库存。这样每完成一次"被动开仓 + 主动平仓"就构成一次往返,便于把费率直接映射到每笔盈亏。
import numpy as np
# ---------- 费率情景定义(示例参数) ----------
SCENARIOS = {
"基准": {"f_t": 0.0002, "f_m": 0.0, "r_m": 0.0001},
"taker上调": {"f_t": 0.0003, "f_m": 0.0, "r_m": 0.0001},
"taker大幅上调":{"f_t": 0.0005, "f_m": 0.0, "r_m": 0.0001},
"返佣取消": {"f_t": 0.0002, "f_m": 0.0, "r_m": 0.0},
"返佣转负": {"f_t": 0.0002, "f_m": 0.0, "r_m": -0.00005},
"最不利组合": {"f_t": 0.0005, "f_m": 0.0, "r_m": 0.0},
}
def run_strategy(snapshots, fee, spread_threshold=0.02, max_rounds=200):
"""snapshots: list of dict(bid, ask, mid)
返回:往返次数、毛收益、净收益、平均单笔毛利"""
gross = 0.0
rounds = 0
for s in snapshots:
if rounds >= max_rounds:
break
bid, ask = s["bid"], s["ask"]
spread = ask - bid
if spread <= spread_threshold:
continue
# 被动腿:买一价买入,卖一价卖出(各成交一半库存,简化为一次往返)
# 毛收益 = 点差(以中间价为基准的比例)
mid = (bid + ask) / 2
gross_pnl = spread / mid # 单次往返毛收益率
# 净费率:被动腿收入 r_m - f_m,主动腿支出 f_t
net_fee = (fee["r_m"] - fee["f_m"]) - fee["f_t"]
net_pnl = gross_pnl + net_fee
gross += gross_pnl
rounds += 1
# 记录单笔净收益用于后续统计
s["_net"] = net_pnl
return rounds, gross, gross + rounds * ((fee["r_m"] - fee["f_m"]) - fee["f_t"])
# ---------- 构造示例行情切片(非实测,仅用于演示) ----------
rng = np.random.default_rng(42)
snapshots = []
for _ in range(500):
mid = 100.0 + rng.normal(0, 0.05)
half_spread = abs(rng.normal(0.015, 0.008))
snapshots.append({"bid": mid - half_spread, "ask": mid + half_spread, "mid": mid})
# ---------- 扫描 ----------
print(f"{'情景':<12}{'往返次数':>8}{'毛收益(bp)':>12}{'净收益(bp)':>12}{'单笔净(bp)':>12}")
for name, fee in SCENARIOS.items():
rounds, gross, net = run_strategy(snapshots, fee)
per = (net / rounds * 1e4) if rounds else 0
print(f"{name:<12}{rounds:>8}{gross*1e4:>12.2f}{net*1e4:>12.2f}{per:>12.2f}")
运行这段代码会得到一张按情景排列的对照表。关键判读点有三个:
- 往返次数是否变化:本例策略的入场条件只依赖点差,不依赖费率,所以往返次数在各情景下应保持一致。如果实际策略的入场阈值包含费率或预期收益,往返次数会随费率上升而下降——这本身就是"策略自我收缩"的信号,必须单独记录。
- 单笔净收益是否转负:当
单笔净(bp) < 0时,策略在该费率下每做一笔都在亏钱,属于"结构性死亡",不是靠提高换手能救回来的。 - 净收益对费率的弹性:比较"基准"与"最不利组合"的净收益差额,除以费率差额,得到每 1 bp 费率变化对应的净收益变化,用于判断策略的费率敏感度。
spread_threshold 从 0.02 调到 0.03 再跑一遍,可以观察"提高入场门槛"能否抵消费率上调。如果提高门槛后往返次数骤降、净收益反而更差,说明该策略的毛利来源高度依赖高频小额点差,费率上调对其是致命的。生存能力判定:用盈亏平衡费率反推安全边际
比"跑几个情景看结果"更有决策价值的是:直接求出盈亏平衡的 taker 费率,即令单笔净收益为零时的 f_t。由 gross_pnl + (r_m − f_m) − f_t = 0 可得:
f_t* = gross_pnl + r_m − f_m
把基准情景下的平均单笔毛利代入,就能得到策略能承受的 taker 费率上限。当前费率与 f_t* 之间的差距就是安全边际。若安全边际小于 1 bp,说明费率只要微调就会击穿策略,属于高风险暴露。
| 情景 | taker 费率 | 返佣 | 往返净费率 | 单笔净收益(示例) | 判定 |
|---|---|---|---|---|---|
| 基准 | 0.02% | +0.01% | −1 bp | 正 | 可生存 |
| taker 上调 | 0.03% | +0.01% | −2 bp | 接近零 | 边际 |
| taker 大幅上调 | 0.05% | +0.01% | −4 bp | 负 | 不可生存 |
| 返佣取消 | 0.02% | 0 | −2 bp | 接近零 | 边际 |
| 返佣转负 | 0.02% | −0.005% | −2.5 bp | 负 | 不可生存 |
| 最不利组合 | 0.05% | 0 | −5 bp | 显著负 | 不可生存 |
交付物:费率压力测试记录表
为了让结论可复核、可对比,建议每次压力测试都填写一张固定字段的记录表。字段只保留与费率决策直接相关的项,避免引入无关的管理指标。
| 字段 | 填写说明 | 示例值 |
|---|---|---|
| 行情切片来源 | 数据文件、时间范围、快照频率 | 示例切片,500 条,1s 快照 |
| 策略版本 | 策略逻辑标识,确保各情景用同一版本 | spread_maker_v1 |
| 入场阈值 | 点差阈值,单位与行情一致 | 0.02 |
| 费率情景 | f_t / f_m / r_m 三元组 | 0.0005 / 0 / 0 |
| 往返次数 | 该情景下实际完成的往返数 | 187 |
| 单笔净收益 | 净收益 / 往返次数,单位 bp | −3.2 bp |
| 盈亏平衡 f_t* | 由平均单笔毛利反推 | 0.028% |
| 安全边际 | f_t* − 当前 f_t | −0.022%(已击穿) |
| 判定 | 可生存 / 边际 / 不可生存 | 不可生存 |
完成这张表后,策略在费率上调情景下的生存能力就有了可比较的量化结论:不是"感觉费率高了会亏",而是"当 taker 费率超过 0.028% 时,该策略的单笔净收益转负,且提高入场阈值无法挽回"。这个结论可以直接用于决定是否调整策略的成交方式(例如减少主动腿、改为双边被动成交),或是否需要在费率谈判中争取返佣。