IV

IV:A 股业务知识

量化开发(QD)面试里的业务题:Level-2 快照、逐笔委托、逐笔成交各记了什么,沪深两市哪里不一样,序号、主动方、集合竞价,写因子时要和研究侧对齐的几处,三个常用 Level-2 因子落到 C++ 时的状态和坑,流合并、时序对齐、增量计算和 pandas 对不上的地方,以及怎么用逐笔重建盘口、和快照十档对拍。

本页目录

QD 岗位问业务,要的不是交易所协议的每个字段,而是两件事:你真的处理过这些数据,以及你知道哪些地方会让研究侧和实盘算出来不一样。下面按这个深度整理,先是 Level-2 行情本身,再是用它算的几个因子。

1. 三类数据

数据记了什么量级
快照约每 3 秒一张:十档买卖价量、最新价、高开低收、累计成交笔数、量、金额,委买委卖总量;买一卖一前 50 笔挂单(委托队列)全市场一天两千多万条
逐笔委托每一笔挂单(上海还有撤单)一天上亿条
逐笔成交每一次撮合(深圳还有撤单)一天上亿条

快照是结果,逐笔是过程:快照只告诉你 3 秒后盘口变成什么样,谁主动成交、大单什么时候挂上又撤掉,只有逐笔看得到。Level-1 只有五档快照,没有逐笔。

2. 沪深两市的差别

深圳上海
逐笔委托记什么交易所收到的每一笔新委托,原始申报数量;类型分限价、市价、本方最优进入订单簿的部分:立即全部成交的不发,部分成交的只发剩余量;类型分新增(A)、删除(D)
撤单在哪逐笔成交里,一条标着“撤销”的成交记录,价格写 0逐笔委托里,一条“删除”(D)记录
主动方没有标志,比较买卖双方委托序号,大的(后到的)是主动方成交带内外盘标志:B 主动买、S 主动卖、N 未知
序号频道内消息记录号,委托和成交共用通道内业务序号,委托和成交共用

一句话记:深圳的逐笔委托记“报了什么”,上海记“挂上了什么”。所以同一个“大单委托”因子,两市要分开写。

撤单放在哪,也是从这个区别来的。上海的逐笔委托记的是订单簿上挂单的变化:一笔单挂上去,发一条“新增”(A);被撤下来,发一条“删除”(D)。撤单自然就在逐笔委托里。深圳的逐笔委托只记新收到的委托,撤单不是新委托,放不进去;深圳的逐笔成交记的其实是委托的执行结果,结果有两种,成交了,或者撤销了。所以深圳的一笔撤单,是逐笔成交里的一条记录,只是它的类别标成“撤销”而不是“成交”(字段叫成交类别 ExecType,撤销写 4,成交写 F)。撤单没有成交价,这条记录的价格写 0;数量是撤掉的量;买卖双方的委托序号只有被撤那一边不是 0,用它查回原委托,才知道撤的是哪个价位。

所以在深圳,用逐笔成交算任何东西,第一步都是先把撤销记录滤掉:不滤的话,撤掉的量会被当成成交量,价格 0 会把均价拉低(第 5 节)。反过来,算撤单因子时,深圳要读逐笔成交,上海要读逐笔委托,只读一路就漏掉一个市场的撤单。

3. 序号、回查委托、主动方

第 2 节讲的是两市各发了哪些记录。真拿这些记录写程序(重建盘口、算因子),马上会碰到三个问题:消息按什么顺序处理;一条成交或撤单,对应的是哪笔委托;一笔成交是买方主动还是卖方主动。三个问题靠的都是记录里的编号:序号定先后,也用来发现丢包;委托编号把成交和撤单连回原委托;比较两个委托编号的大小,定主动方。

下面用一段深圳的逐笔把这三件事走一遍。四条消息在同一个通道里,时间戳都是 09:30:00.120:

序号哪一路内容
1001逐笔委托买入 500 股,价格 10.00
1002逐笔委托卖出 300 股,价格 10.00
1003逐笔成交成交 300 股,价格 10.00,买方是 1001,卖方是 1002
1004逐笔成交撤销 200 股,价格 0,买方是 1001,卖方空着

这一节的四件事都在这张表里。

3.1 先后只认序号

四条消息的时间戳一样,光看时间分不出先后。但交易所是一条一条处理的:先收到 1001,挂上去;1002 来了,和它撮合,产生 1003;最后 1001 剩下的 200 股被撤,就是 1004。序号记的就是这个处理顺序。深圳的委托和成交共用一套序号(消息记录号 ApplSeqNum),上海也一样(业务序号 BizIndex)。所以要把两路按序号排好、合成一条流,才是盘口真实的变化顺序。 按时间戳排,同一毫秒里成交可能排到引发它的委托前面,重建盘口时就会去成交一笔还不存在的委托。

序号只在一个通道内连续。交易所把股票分到多个通道,各自编号,不同通道的 1001 没有先后关系,只能各排各的。

3.2 丢包和重复

同一通道里,序号一个挨一个。收到 1001、1002、1004,就知道 1003 丢了。这时 1001 那笔买单实际已经成交了 300 股,你的盘口却还以为它挂着 500 股,后面的计算全会偏。所以丢了要补,补不到就把这段标成不可信,不能当没发生。 反过来,断线重连后数据商常把最近一段重发一遍,已经处理过的序号直接丢掉,否则同一笔成交会被算两次。

3.3 回查委托:成交和撤单要找回原委托

成交记录里只写了买卖双方是哪两笔委托,不写委托本身的信息。想知道这笔成交的买方原本报了多少股、一笔撤单撤的是哪个价位,都要拿编号去查委托。

深圳的成交里,买方、卖方委托序号(BidApplSeqNum、OfferApplSeqNum)就是那两笔委托自己的序号:表里 1003 的买方 1001、卖方 1002,直接对得上。撤单 1004 的价格是 0,而且只有一边有编号:卖方空着(写 0),买方是 1001。查回 1001,才知道撤的是 10.00 那一档的买单。

上海不一样,有两点。第一,成交里的买卖方订单号(BuyOrderNo、SellOrderNo)对的是委托的原始订单号(OrderNo),不是业务序号,查表时要用订单号做键。第二,上海的逐笔委托不发立即全部成交的单。上面那笔 300 股的卖单一来就全部成交了,上海只发出那条成交,逐笔委托里没有它,拿卖方订单号去查是查不到的。所以上海的回查总会有一部分落空,这是正常的,不是丢包。

3.4 主动方:谁去吃了谁

主动方是后到、主动去和已有挂单成交的那一方。表里的卖单 1002 来了,吃掉了已经挂着的买单 1001,所以 1003 是一笔主动卖。

上海的成交直接带内外盘标志:B 是主动买,S 是主动卖。深圳没有这个标志,要自己比:买卖双方的委托序号谁大,谁就是后到的,也就是主动方。1002 比 1001 大,所以卖方主动。

集合竞价是例外。9:25 那一次撮合,是把之前 10 分钟收到的单一起配对,没有谁先挂、谁去吃,成交没有主动方。上海标 N;深圳照样能比出一个更大的序号,但这个结果没有意义。所以算主动买卖量时,集合竞价的成交要剔掉或单独处理,否则深圳会凭空多出一批“主动”成交。

4. 集合竞价

A 股一天里有两种撮合方式。连续竞价是单子一到,就和对面挂着的单子比价,能成交就立刻成交,所以成交是一笔一笔陆续出来的。集合竞价是一段时间里只收单、不成交,到点后把收到的单子放在一起,算出一个让成交量最大的价格,所有能成交的单子都按这一个价格一次成交。开盘和收盘用集合竞价,中间用连续竞价:

时间阶段能做什么行情里看到什么
9:15–9:20开盘集合竞价能下单,能撤单只有委托,没有成交;快照上是“如果现在撮合”的参考价和量
9:20–9:25开盘集合竞价能下单,不能撤单同上
9:25开盘撮合—一批成交,价格相同、时间相同
9:25–9:30等待能下单,交易所先收着,9:30 才处理没有新成交
9:30–11:30,13:00–14:57连续竞价正常交易成交陆续出来
14:57–15:00收盘集合竞价能下单,不能撤单只有委托
15:00收盘撮合—一批成交,价格就是收盘价

9:20 以后不让撤单,是为了防止有人挂大单把参考价抬上去,快到点再撤掉,误导别人。集合竞价只收限价单,所以深圳逐笔委托里的市价单和本方最优委托,只会出现在 9:30–14:57 之间。

这两次撮合出来的成交,和连续竞价的成交不是一回事,写因子时有四处要单独处理:

  • 没有主动方。所有单子是一起配对的,没有谁先挂、谁去吃(第 3.4 节),算主动买卖量必须把这些成交剔掉。
  • 量很集中。9:25 一次就成交一大批,时间戳全是 9:25:00。如果把它并进 9:30 开始的第一根 1 分钟 bar,这根 bar 的成交量会远大于后面几根,按量算的因子在开盘这一根会失真。15:00 那批同理。
  • 盘口是虚拟的。集合竞价期间快照上的买卖档是虚拟撮合的结果,不是真能成交的挂单,OBI 这类盘口因子要剔掉这段(第 6.1 节)。
  • 委托要从 9:15 收起。9:15–9:25 挂的单子,没成交的部分会留到连续竞价里继续挂着,之后被撤或成交时要查回它,所以程序不能从 9:30 才开始收(第 6.3 节)。

所以集合竞价的成交要单独标出来:剔掉、单独成一根 bar,还是并进第一根和最后一根,得和研究侧约定。研究侧拿分钟 bar 数据时,数据商往往已经按某种方式并好了,C++ 自己切 bar 要照着同一种来。

5. 写因子时要和研究侧对齐的地方

地方会怎么对不上
累计量相减快照的量、额、笔数是开盘起的累计值。当天第一张,pandas diff() 给 NaN,C++ 上一张从 0 起算会得出开盘以来的全部量
快照不是每 3 秒都有没变化通常不推,切 bar 时空格子用前值补还是留空要约定
价格和金额价格是放大过的整数,倍数按数据商文档并用真实数据核;有的数据商成交金额是去掉角分的整数元,零股、科创板 201 股这类会少几毛,要准就用价×量
深圳撤单混在成交里直接用逐笔成交算均价,价格 0、数量非 0 的撤单行会把均价拉低
时间交易所时间还是本机收到时间;bar 左闭右开还是右闭;集合竞价和午休怎么切
快照和逐笔对齐两者节拍和时间精度不同,按时间戳把逐笔加到快照时刻不一定等于快照的累计量;严格对齐要用逐笔重建盘口再比

6. 三个常用的 Level-2 因子

QD 面试不考因子为什么有效,那是研究员的事;考的是研究员给你一个 pandas 写的因子,你能不能把它增量地写成 C++,并且和研究侧算得一样。所以每个因子准备四件事:公式,用哪类数据,每来一条消息 C++ 要维护什么状态,和 pandas 会在哪里算得不一样。下面三个各用一类数据,正好覆盖快照、逐笔成交、逐笔委托。

6.1 加权盘口不均衡(OBI):快照

买卖十档挂单量的差,离盘口越近权重越大(远端挂单撤得多,对价格影响小),$w_i$ 常取 $1/i$ 或指数递减:

$$\text{OBI}=\frac{\sum_{i=1}^{10} w_i\,(\text{bid}_i-\text{ask}_i)}{\sum_{i=1}^{10} w_i\,(\text{bid}_i+\text{ask}_i)}$$

每张快照自己就能算出来,C++ 不需要跨消息的状态,难点全在边界。涨停时卖盘全空,OBI 恒为 1,跌停恒为 −1,这段要不要用得和研究侧约定;两边都空(停牌、开盘前)分母为 0,pandas 除以 0 得到 inf 或 NaN 不报错,C++ 用整数算会直接崩,用浮点得到 nan 继续往下传,所以填什么值要事先说好。集合竞价期间快照上的盘口是虚拟撮合的结果,不是连续竞价的挂单,一般剔掉。快照缺失时用前值补还是留空,见第 5 节。快照只有 3 秒的节拍,要逐笔级的 OBI 得用逐笔重建盘口。

6.2 主动大单净流入:逐笔成交

大单的主动买入额减去主动卖出额,除以同期总成交额。大单要按委托分,不能按单笔成交分:一张大买单吃掉卖一到卖五,会拆成很多笔小成交,按单笔金额分档,它就被算成一堆小单。所以要先按主动方的委托把成交累加起来:上海看 B/S 标志,B 取买方订单号、S 取卖方订单号;深圳比较买卖双方的委托序号,取大的那个。

C++ 的状态因此很省。一笔主动委托到达后立即撮合,它引发的成交在序号上是连续的,所以看到主动方的委托号变了,就可以结算上一笔:累计金额过门槛就记大单,再加进窗口里的主动买或主动卖累加和。如果研究侧连被动方也按委托大小分档,就得再维护一张“委托号 → 累计成交额”的哈希表,委托全部成交或撤单后删掉,否则表一天下来只增不减。

和研究侧对不上的地方有三处。第一,深圳的逐笔成交里混着撤单(价格 0),要先滤掉,否则撤单量会被当成交量累加。第二,“委托多大”有两种口径:按原始委托金额,还是按实际成交金额。深圳逐笔委托有原始数量,上海立即全部成交的单在逐笔委托里查不到,只能用成交金额,两市口径要统一。第三,集合竞价的成交没有主动方,要剔掉。

6.3 撤单金额占比:逐笔委托和逐笔成交

一段时间内被撤掉的委托金额,除以同期新增委托金额。分子在两市的来源不同。上海的删除记录在逐笔委托里,自带价格和被删的剩余量,直接乘。深圳的撤单在逐笔成交里,价格是 0,只有数量,要用非 0 的那个委托序号查回原委托才知道撤的是哪个价位、哪一边。所以 C++ 要维护一张“序号 → 价格、方向”的表,委托全部成交或撤单后删掉。研究侧通常是全天数据按序号 merge 一次,C++ 只能边收边查,于是还有一个坑:程序必须从 9:15 开盘集合竞价就开始收逐笔委托,从 9:30 才开始收的话,集合竞价里挂的单在 9:30 以后被撤,就查不到原委托了。

分母两市口径也不同。上海的逐笔委托不发立即全部成交的单,部分成交的只发剩余量,所以上海的“新增委托金额”偏小,同样的撤单行为,上海算出来的占比偏高。这个因子跨市场不能直接比,要么把上海的分母用成交补回来,要么沪深分开标准化,选哪种得和研究侧约定。

7. 流合并、时序对齐、增量计算

痛点:

  • 研究员的因子回测好好的,搬到 C++ 实盘,同一天的数据两边算出来对不上。
  • 根子是算法的形状不一样。研究侧是批处理:拿一整天的数据,先把几张表按时间拼好,再用 pandas 对整列一次算完。C++ 实盘是流式处理:数据一条一条到,每来一条就要给出当前的因子值。

从批处理改成流式,有三件事要重新做一遍:几路数据按什么顺序进来(流合并),不同节拍的数据怎么放到同一个时刻上(时序对齐),每来一条怎么更新、而不是把窗口重算一遍(增量计算)。三件事都会让 C++ 和 pandas 算得不一样,而且差在边界上,平时看不出来。

7.1 流合并:多路数据合成一条流

痛点:

  • 全市场因子(比如“过去 1 分钟全市场主动买入额”)要用沪深所有通道的成交。
  • 每个通道内部按序号有序,但通道之间没有共同的序号,谁先谁后没有人告诉你。
  • 合并的顺序不同,因子在某个时刻的值就不同。研究侧和实盘合并的方式不一样,两边就对不上。

先说什么时候不用合并。同一个通道里,委托和成交共用一套序号,按序号排就是交易所的处理顺序(第 3.1 节)。单只股票只在一个通道里,所以只用一只股票自己数据的因子,按序号处理就够了,用不着合并。

要合并的是跨通道、跨品种的数据:沪深两市、同一市场的多个通道、快照和逐笔、股指期货。它们之间没有共同的序号,只能按时间戳合并。做法是多路归并:每一路各自已经有序,用一个小顶堆每次取时间戳最小的那一条。

麻烦在于时间戳相同的时候。不同通道同一毫秒的两条消息,谁先处理没有标准答案。对有状态的因子(比如全市场主动买入额的滚动和),先后不同,中间结果就不同。所以要定一个固定的规则,比如时间戳相同时按市场、通道号、序号排,并且研究侧用同样的规则。

还有一个更根本的差别:用哪个时间。研究侧通常按交易所时间合并;实盘里,你只能按消息到达的先后处理。两个通道的消息走不同的线路,到达的先后和交易所时间的先后不一定一致。想严格按交易所时间排,就得攒一小段再排序,代价是延迟。一般的做法是实盘按到达顺序处理,回测时知道这里有一点差别。

7.2 时序对齐:不同节拍放到同一个时刻上

痛点:

  • 不同数据节拍不同:快照约 3 秒一张,逐笔随时来,股指期货的行情约 500 ms 一张,时间戳几乎从不相等。
  • 因子要用“股票快照时刻的期货价格”,就得决定在 9:31:02 这个时刻,期货价格取哪一张。
  • 研究侧一句 merge 就带过去了。方向取错就是用了未来的数据,回测很好看,实盘复现不了。

规则只有一条:每个时刻只能用那时已经知道的数据。所以取的是时间戳不晚于 9:31:02 的最近一条,pandas 里是 merge_asof(默认就是往前找)。用插值,或者取之后最近的一条,都是在用未来的数据,回测会好看,实盘做不到。

最常见的踩坑是 bar 的标签。把逐笔按 1 分钟切成 bar,pandas resample("1min") 默认左闭、标签取左端:标着 9:31 的那根 bar,装的是 9:31:00 到 9:32:00 的成交。如果策略在 9:31 就用这根 bar,等于提前一分钟看到了数据。很多数据商的分钟线反过来,按结束时间标,9:31 那根装的是 9:30 到 9:31。C++ 切 bar 时,开闭和标签都要和研究侧用的数据一致,集合竞价和午休怎么切见第 4、5 节。

另一个选择是采样方式。按时间采样(每 3 秒、每 1 分钟)最常见;也有按成交量或笔数采样的,比如每成交 10 万股算一根 bar,活跃的时候 bar 多,冷清的时候 bar 少。C++ 的因子通常每条消息都更新,但输出要按研究侧的采样点取值。

7.3 增量计算:每来一条,只算变化的部分

痛点:

  • 太慢:研究侧写一行 df["amount"].rolling("5min").sum(),pandas 对整列算一次;实盘每来一笔成交都要输出新值,5 分钟里可能有几千笔,每笔都重新加一遍来不及。
  • 对不上:改成增量写法以后,窗口边界、开头几条、数值精度,很容易和 pandas 的默认行为不一样,而且差别只在开盘和窗口边上冒出来。

增量的做法是:新的一条加进来,掉出窗口的从头上减掉,每条消息的工作量和窗口大小无关。

#include <cstdint>
#include <deque>
#include <utility>

// 最近 W 毫秒内的成交额之和,窗口是 (t - W, t],和 pandas rolling("5min") 默认的右闭一样
struct RollingSum {
    int64_t window_ms;
    std::deque<std::pair<int64_t, int64_t>> q;   // (时间戳, 金额)
    int64_t sum = 0;

    int64_t update(int64_t ts, int64_t amount) {
        q.emplace_back(ts, amount);
        sum += amount;
        while (q.front().first <= ts - window_ms) {   // 掉出窗口的,从头上减掉
            sum -= q.front().second;
            q.pop_front();
        }
        return sum;
    }
};

写法不难,和 pandas 对不上的地方在下面几处:

  • 窗口边界。按时间的窗口,pandas 默认是左开右闭 $(t-W,\,t]$:正好 5 分钟前的那一条不算。C++ 里 <= 写成 <,就多算一条。
  • 窗口开头。按条数的窗口 rolling(20),不满 20 条时 pandas 输出 NaN;C++ 很容易直接输出前几条的结果。开盘头几条输出什么,要约定。
  • 浮点误差。加一条、减一条,一天几百万次下来,浮点和会慢慢漂;pandas 的滚动求和内部做了误差补偿,两边会差在最后几位。价格和金额本来就是放大过的整数(第 5 节),用 int64_t 累加就完全精确。方差不要用“平方和减和的平方”,两个大数相减会丢精度,用 Welford 的增量公式。
  • 最大最小值。最大值不能减掉,掉出窗口的那条如果正好是最大值,就得知道第二大的是谁。做法是单调队列:队列里只留“比后来者都大”的元素,每条消息平均 O(1)。
  • 指数加权。ewm(alpha=a).mean() 默认 adjust=True,前面几条按归一化的权重算;C++ 常写的递推 $y_t = a\,x_t + (1-a)\,y_{t-1}$ 对应的是 adjust=False。两者开头差得多,越往后越接近。

所以把研究侧的因子改成增量版,第一件事是逐条对一遍:拿同一天的数据,C++ 每条输出和 pandas 那一列逐行比。差别集中在开头和边界上,看差在哪几行,基本就知道是上面哪一处。

8. 订单簿重建:用逐笔推出每一刻的盘口

  • 快照太粗:快照约 3 秒一张。6.1 的盘口不均衡这类因子想逐笔更新,就得自己维护一本盘口,每来一条逐笔改一次。
  • 错了没人告诉你:盘口重建错了,程序照样跑,因子照样出数。能拿来核对的只有交易所的快照,所以重建和对拍要一起做。

8.1 每类消息怎么改盘口

  • 盘口里存什么:每个价位一个总量,再按编号记每笔挂单的方向、价格、剩余量。成交和撤单都要按编号找到原来那笔挂单(3.3)。
  • 处理顺序:两路合成一条流,严格按序号处理(3.1),丢了就补,补不到就把这段标成不可信(3.2)。
  1. 深圳

    • 逐笔委托(限价):加一笔挂单,那个价位加上它的原始申报量。
    • 逐笔成交(F):买卖两边的委托都扣掉成交量,扣到 0 就从盘口删掉。两边都要扣,因为深圳的逐笔委托把主动方也发了,它已经被加进了盘口。
    • 撤单(逐笔成交里的 4):价格是 0,按不为 0 的那一边编号找到原委托,扣掉撤销量。
    • 市价单、本方最优单:委托里的价格字段不一定是它真正挂上去的价格。本方最优按当时己方的最优价挂,市价单剩下的部分各有各的处理规则。字段怎么填,以数据商的文档为准,再拿真实数据核一遍。
    • 中间会交叉:一笔能成交的限价单先加进盘口,紧跟着的几条成交才把它扣掉。这一串处理完,盘口就正常了,不要在一串的中间取盘口。
  2. 上海

    • 新增(A):加一笔挂单,量是进入订单簿的剩余量(第 2 节)。
    • 删除(D):按订单号找到这笔挂单,删掉。
    • 成交:只扣被动方。主动方要么立即全部成交,逐笔委托里根本没有它;要么只把剩余量用 A 发出来,成交掉的那部分本来就没进盘口。内外盘标志 B 扣卖方,S 扣买方。
    • 集合竞价的成交(N):两边都是竞价期间挂上去的,两边都扣。
  3. 两市的差别归成一句

    • 深圳的盘口先加后扣,上海的盘口只加剩下的。深圳逐笔委托记“报了什么”,主动方也得先加再扣;上海记“挂上了什么”,主动方从来不在盘口里,成交只扣被动方。

8.2 和快照十档对拍

  1. 对什么

    • 十档的价和量:自己的盘口取买卖各前十档,和快照逐档比价格和总量。
    • 累计成交量、成交额、成交笔数:把处理过的成交加起来,和快照里的累计值比。深圳先滤掉撤销记录(第 2 节)。这几个数一旦差了,差额会一直留着,很好认。
  2. 在哪个时刻比

    • 难点:快照只带时间戳,不带逐笔的序号,看不出它是在哪一条逐笔之后生成的。
    • 做法:先把时间戳不晚于快照时间的逐笔处理完,再比。对不上时,在快照时间前后一小段里逐条往前推,只要其中有一个时刻十档全对上,就算对上。
    • 从连续竞价开始比:集合竞价期间只挂单不撮合,买一可以高于卖一,快照上是虚拟撮合的结果(第 4 节),不能按连续竞价的办法比。

8.3 对不上说明什么

  • 只在快照时刻附近差一点,过一会儿自己对上:时间对齐的问题,盘口没错。
  • 某一档一直多出一个固定的量:漏扣了一次。最常见的是深圳的撤单在逐笔成交里没处理,或者回查委托落空,也可能是丢包(3.2)。
  • 买一高于卖一,而且一直不消失:成交没扣对。深圳常见于只扣了被动方、没扣主动方;把价格为 0 的市价卖单直接挂进盘口,也会出现一个价格为 0 的卖一。
  • 累计成交量对不上:丢了成交,重复处理了断线重连后重发的数据(3.2),或者深圳没滤撤单。
  • 对拍不过的那段时间,基于盘口的因子都不能用。和研究侧对因子之前,先看这段盘口有没有对上。