4. 交易成本数据采集

佣金与滑点成本建模的质量,首先取决于输入数据的质量。很多回测结果失真,并不是模型公式写错了,而是数据在采集阶段就混入了错误费率、缺失价差或异常成交量。本节围绕“如何拿到数据、如何清洗、如何整理成可直接喂给模型的数据集”展开,重点讲清楚每一类数据的来源、字段含义、常见脏数据形态以及处理方式。

一、建模需要哪几类数据

交易成本建模通常需要三类基础数据,它们的时间粒度和更新频率并不相同,采集时必须分开处理:

  • 佣金费率数据:包括券商佣金率、最低佣金、印花税、过户费、交易所规费等。这类数据变化频率低,但存在“按市场、按账户、按时间段”的差异,必须带生效日期。
  • 买卖价差数据:最优买价(bid)、最优卖价(ask)、盘口深度。用于估计即时成交的价差成本,通常来自逐笔或快照行情。
  • 成交量与成交价数据:用于估计冲击成本与滑点,需要成交价、成交量、成交笔数,以及对应的委托量信息。

三类数据的对齐粒度决定了后续建模口径。若用日频成交量估计冲击成本,却用分钟级价差估计价差成本,两者混用会导致成本量纲不一致。实践中建议先确定建模的最小时间粒度(例如分钟或日),再按该粒度统一采集。

二、佣金费率的采集与结构化

佣金费率不是单一数字,而是一组分段规则。以A股为例,一笔卖出交易的成本通常包含:券商佣金(含最低5元限制)、印花税(卖出单边)、过户费(按成交金额比例)。采集时应把每一项拆成独立字段,而不是只记录一个“综合费率”。

建议的费率表结构如下(示例参数为合理假设,非实测):

字段含义示例值
market市场标识SH / SZ
side买卖方向buy / sell
commission_rate券商佣金率0.00025
min_commission最低佣金(元)5.00
stamp_tax_rate印花税率0.0005(仅卖出)
transfer_fee_rate过户费率0.00001
effective_from生效起始日2023-08-28
effective_to生效截止日2024-12-31

关键点是带生效区间。费率会随政策调整(例如印花税下调),若只存一份“当前费率”并回填到全部历史,回测会系统性高估或低估成本。清洗时应检查区间是否连续、有无重叠,重叠区间必须人工确认优先级。

三、价差与成交数据的采集要点

价差数据的核心字段是时间戳、bid、ask、bid_size、ask_size。采集时最常见的三个问题:

  1. 时间戳时区不统一:不同数据源可能用UTC、交易所本地时间或带毫秒偏移,合并前必须统一到同一时区并明确标注。
  2. 盘口交叉或倒挂:出现 bid > ask 的记录,通常是数据源撮合快照不同步所致,属于脏数据,应剔除或标记。
  3. 零值与空值:停牌、集合竞价前后常出现 bid=0 或 ask 缺失,直接参与计算会得到无穷大或负价差。

成交量数据则要关注“成交量单位”和“复权口径”。同一只股票,不同源可能用股、手(100股)或千股为单位;价格是否复权也直接影响成交金额计算。采集阶段就应在元数据中写清单位与复权方式,避免下游误用。

四、数据清洗与预处理流程

下图展示了一条从原始数据到可建模数据集的清洗链路,重点标出了每一步的输入、处理动作与输出判断:

交易成本数据清洗链路 原始数据源 费率表 / 盘口快照 / 成交明细 ① 时间对齐 统一时区与时间粒度 对齐到分钟/日频 ② 异常剔除 盘口倒挂 / 零值 / 空值 标记而非直接删除 ③ 口径统一 单位/复权 元数据标注 ④ 成本特征计算 相对价差 = (ask-bid)/mid 佣金 = max(费率×额, 最低) ⑤ 缺失值处理 停牌段前向填充 长缺口标记为不可用 ⑥ 输出 建模数据集 + 质量报告 质量校验(建模前必做) 价差非负 · 费率区间无重叠 · 成交额与量价一致 · 缺失率低于阈值 校验不通过 → 回到对应步骤修正,而非直接进入建模

清洗过程中有两个原则值得强调。第一,能标记就不要直接删。异常记录被删除后,下游无法判断某段时间是“无数据”还是“数据被丢弃”,会给缺失值处理带来歧义。建议增加一个 quality_flag 字段,取值如 ok、crossed、zero_bid、stale,建模时按需过滤。第二,缺失值处理要区分场景:停牌期间没有盘口是正常现象,可用前值填充并标记;而连续多日无成交则可能意味着流动性枯竭,此时填充会掩盖真实成本,应标记为不可用样本。

五、一个可执行的预处理片段

下面用伪代码展示价差字段的清洗与特征计算逻辑,参数为示例假设:

# 输入: df 含 timestamp, bid, ask, bid_size, ask_size
# 1) 剔除盘口倒挂与零值
df["quality_flag"] = "ok"
df.loc[(df.bid <= 0) | (df.ask <= 0), "quality_flag"] = "zero_quote"
df.loc[df.bid > df.ask, "quality_flag"] = "crossed"

# 2) 计算中间价与相对价差(仅对有效记录)
valid = df[df.quality_flag == "ok"].copy()
valid["mid"] = (valid.bid + valid.ask) / 2
valid["rel_spread"] = (valid.ask - valid.bid) / valid.mid

# 3) 按分钟聚合,缺失分钟标记
minute = valid.set_index("timestamp").resample("1min").agg(
    {"rel_spread": "mean", "mid": "last"}
)
minute["is_missing"] = minute.rel_spread.isna()

# 4) 输出质量摘要
print("倒挂占比:", (df.quality_flag == "crossed").mean())
print("零值占比:", (df.quality_flag == "zero_quote").mean())
print("分钟缺失率:", minute.is_missing.mean())

运行后应重点看三个比例:倒挂占比、零值占比、分钟缺失率。若倒挂占比超过千分之一,通常说明数据源撮合快照存在系统性问题,需要换源或调整对齐逻辑;若缺失率在某只股票上异常高,应检查是否长期停牌或数据覆盖不全。这些判断依据比单纯“跑通代码”更重要。

六、交付一份可建模数据集

最终交付物不应只是一张宽表,而应包含数据表与质量报告两部分。数据表按“标的—时间—方向”组织,至少包含:时间戳、标的代码、bid、ask、mid、rel_spread、成交量、成交额、佣金率、最低佣金、印花税率、quality_flag。质量报告则记录各字段缺失率、异常占比、费率区间覆盖情况以及已知问题清单。

建议在数据集版本号中体现清洗规则版本,例如 cost_dataset_v3_rule2024Q1。当费率政策或清洗阈值调整时,版本号随之变化,回测结果才可追溯、可复现。

做到这一步,数据才真正具备“可建模”的资格:字段含义明确、口径统一、异常可识别、缺失有交代。后续无论是估计价差成本还是拟合冲击成本函数,都能在此基础上稳定推进,而不会因为底层数据反复返工。