3. 盘口策略对滑点参数的敏感性扫描

扫描对象与参数空间设计

本节固定策略逻辑不变:以买一/卖一中间价为锚,按固定价差挂双边限价单,持仓上限 1 手,成交后按对手价平仓。唯一变化的是成交模拟中的滑点模型。为了让扫描结果可比较,先定义三类滑点模型的参数化方式,并统一以“每笔成交的额外成本”计入净收益。

  • 固定跳数模型:每笔成交额外扣除 k 个最小变动价位(tick),k ∈ {0, 0.5, 1, 2, 3, 5}。适合刻画流动性稳定、冲击可忽略的场景。
  • 比例模型:按成交名义金额的 r 比例扣除,r ∈ {0, 0.5, 1, 2, 3, 5} bps。适合刻画成本随成交金额线性放大的场景。
  • 冲击成本模型:cost = α·(Q/V)^β,其中 Q 为本次成交量,V 为对应档位可见量,α 为冲击系数(bps),β 为非线性指数。扫描 α ∈ {1, 2, 5, 10, 20} bps,β ∈ {0.5, 1.0, 1.5}。

统一约定:tick 取 0.01 元,标的参考价 10.00 元,则 1 tick ≈ 10 bps。所有滑点均从每笔平仓盈亏中扣除,成交率定义为“实际成交笔数 / 挂单笔数”。

扫描实现:一次运行覆盖全部参数组合

下面代码在固定策略信号序列上,对三类模型分别做参数扫描。策略信号与成交判定(是否触及挂单价)在扫描前一次性生成,滑点只影响成本与净收益,从而保证不同参数下成交率差异仅来自滑点模型对“可成交性”的建模差异(冲击模型下当成本超过预期价差时视为放弃成交)。

import numpy as np

TICK = 0.01
MID = 10.00
POS_LIMIT = 1

# 固定策略:以中间价为锚,双边挂单,价差 1 tick
def build_orders(snapshots):
    orders = []
    for s in snapshots:
        bid = round(s["mid"] - TICK, 2)
        ask = round(s["mid"] + TICK, 2)
        orders.append({"bid": bid, "ask": ask, "mid": s["mid"],
                       "bid_vol": s["bid_vol"], "ask_vol": s["ask_vol"]})
    return orders

# 成交判定:下一快照最低价触及买单价 / 最高价触及卖单价
def match_fills(orders, snapshots):
    fills = []
    for i, o in enumerate(orders[:-1]):
        nxt = snapshots[i + 1]
        if nxt["low"] <= o["bid"]:
            fills.append({"side": "buy", "px": o["bid"], "qty": 1,
                          "vis": o["bid_vol"], "mid": o["mid"]})
        if nxt["high"] >= o["ask"]:
            fills.append({"side": "sell", "px": o["ask"], "qty": 1,
                          "vis": o["ask_vol"], "mid": o["mid"]})
    return fills

def slip_fixed(fill, k):
    return k * TICK * fill["qty"]

def slip_ratio(fill, r_bps):
    return fill["px"] * fill["qty"] * r_bps / 1e4

def slip_impact(fill, alpha_bps, beta):
    ratio = fill["qty"] / max(fill["vis"], 1e-9)
    return fill["px"] * fill["qty"] * alpha_bps / 1e4 * (ratio ** beta)

def run_scan(fills, n_orders):
    rows = []
    for k in [0, 0.5, 1, 2, 3, 5]:
        cost = sum(slip_fixed(f, k) for f in fills)
        rows.append(("fixed", f"k={k}", cost, len(fills) / n_orders))
    for r in [0, 0.5, 1, 2, 3, 5]:
        cost = sum(slip_ratio(f, r) for f in fills)
        rows.append(("ratio", f"r={r}bps", cost, len(fills) / n_orders))
    for a in [1, 2, 5, 10, 20]:
        for b in [0.5, 1.0, 1.5]:
            kept, cost = 0, 0.0
            for f in fills:
                c = slip_impact(f, a, b)
                # 冲击成本超过 1 tick 预期价差则放弃成交
                if c <= TICK * f["qty"]:
                    kept += 1
                    cost += c
            rows.append(("impact", f"a={a},b={b}", cost, kept / n_orders))
    return rows

运行后得到一张长表,字段为 model / param / total_cost / fill_rate。为便于识别脆弱区间,再计算每个参数点相对无滑点基准的净收益衰减率:decay = total_cost / gross_pnl,其中 gross_pnl 为未扣滑点的平仓盈亏合计。

结果判读:脆弱区间在哪里

下表为教学示例数据(假设 gross_pnl = 420 元、挂单 800 笔、基准成交 512 笔),用于说明判读方法,非真实回测结果。

模型参数滑点成本(元)成交率收益衰减判读
固定跳数k=0.52.664.0%0.6%稳健
固定跳数k=210.264.0%2.4%稳健
固定跳数k=525.664.0%6.1%可接受
比例r=2bps10.264.0%2.4%稳健
比例r=5bps25.664.0%6.1%可接受
冲击a=5,b=1.018.458.3%4.4%成交率开始下滑
冲击a=10,b=1.541.741.2%9.9%脆弱区间
冲击a=20,b=1.563.527.6%15.1%失效区间

判读要点:固定跳数与比例模型下成交率不变,说明策略对“线性成本”不敏感,脆弱性主要来自冲击模型的非线性项。当 α·(Q/V)^β 超过 1 tick 预期价差时,成交被主动放弃,成交率骤降,收益衰减加速。因此脆弱区间的识别阈值可定为:成交率相对基准下降超过 20% 或收益衰减超过 8%,即进入需重点关注的参数区域。

注意:冲击模型中的“放弃成交”规则会改变成交率,这与固定/比例模型仅改变成本不同。比较三类模型时应分别看“成本维度”和“成交率维度”,不要只用一个综合指标掩盖差异。
滑点参数扫描:成本与成交率双维度 固定跳数 / 比例模型仅抬升成本;冲击模型在阈值处同时压低成交率 滑点参数强度(由弱到强) 相对基准(%) 100 80 60 40 成交率 净收益保留率 脆弱区间 成交率↓ 收益衰减↑ 成交率 80% 警戒线 成交率(冲击模型) 净收益保留率 脆弱区间

可执行检查清单

  • 扫描前确认策略信号与成交判定已冻结,滑点只作用于成本项,避免参数间相互污染。
  • 固定跳数与比例模型下成交率应保持不变;若发生变化,说明成交判定被滑点逻辑误改。
  • 冲击模型需明确“放弃成交”阈值,并记录被放弃的笔数,否则成交率下降无法归因。
  • 以基准 gross_pnl 为分母计算衰减率,避免不同参数下分母漂移导致误判。
  • 脆弱区间判定同时看成交率降幅与收益衰减,任一超阈值即标记为需关注参数点。