yuqi-zheng

TSE 板寄せ拍卖定价:五条件算法详解


东京证券交易所(TSE)每个交易日的开盘和收盘都使用拍卖机制——而非连续竞价。这种拍卖机制称为板寄せ(Itayose),收集所有委托后确定一个清盘价格,在最大化成交量的同时遵循精确的五条件判定序列。

大多数交易所在开盘和收盘时都有某种形式的拍卖,但 TSE 的五条件规范是独一无二且完全确定性的。理解它对构建 TSE 行情系统至关重要,算法本身也是金融市场微观结构如何平衡竞争目标的精彩案例研究。

本文基于 TSE 官方规范,完整讲解 Itayose 算法,并附带一个从 pcap 文件处理真实 FLEX Full MBO 历史数据的生产级 C++ 实现。


板寄せ vs. 连续竞价

在连续竞价(Zaraba,ザラバ)中,订单穿越买卖价差时立即成交,价格逐笔确定。

在板寄せ中,交易所先收集所有委托,然后计算唯一的理论拍卖价格(IAP, Indicative Auction Price)理论拍卖成交量(IAV, Indicative Auction Volume)。所有能在 IAP 成交的订单同时撮合。

TSE 在三种场景使用板寄せ:

  • 开盘拍卖(9:00 AM)
  • 收盘拍卖(3:30 PM)
  • 盘中暂停时的特别报价(熔断、波动中断等)

FLEX 行情通过 O(Order Status)标签的 pricing_method = 1 标识板寄せ模式。


FLEX 协议:构建订单簿

在计算板寄せ价格之前,我们需要从原始网络包重建订单簿。

数据包结构

TSE FLEX Full MBO 数据通过 UDP 组播传输。每个数据包包含 FlexPacketHeader 和 TLV(Type-Length-Value)编码的标签:

┌─────────────────────────────────────────┐
│ FlexPacketHeader (25 字节)              │
├─────────────────────────────────────────┤
│ 标签 1: [长度][类型][数据...]           │
│ 标签 2: [长度][类型][数据...]           │
│ ...                                     │
└─────────────────────────────────────────┘

头部字段:

字段大小字节序用途
multicast_group1 字节通道标识
sys_reboots1 字节重启计数(用于去重)
seq_num4 字节大端序列号
issue_code12 字节ASCII证券代码,左填充空格
msg_count1 字节数据包中的标签数量

标签类型

标签名称操作
A新增委托插入订单簿(限价或市价)
D删除委托移除(撤单、到期或减量)
E成交部分成交
C带价成交指定价格成交
O委托状态交易模式(板寄せ/ザラバ)+ 参考价格
T时间时间戳
R重置清空所有订单簿
L通信控制通知信息

MBO 订单簿

FLEX Full 行情提供**逐笔委托(MBO, Market By Order)**数据——每个订单单独可见,而非仅显示聚合的价格档位。我们维护 unordered_map<orderId, Order>

struct Order {
    uint64_t order_id;
    uint64_t price;
    uint64_t qty;
    char     side;       // 'B' 或 'S'
    uint32_t time_usec;  // 优先级时间戳
};

市价单使用哨兵价格 0xFFFFFFFFFFFFFFFF——它们参与每个价格档位的成交量计算。

修改处理

TSE 将订单修改编码为 Delete + Add 配对,带 modification_flag = 1

  1. D(orderId, flag=1) —— 保存原订单的 time_usecPendingMod 结构
  2. A(orderId, flag=1) —— 继承保存的 time_usec,保留队列优先级

这很关键:部分减量保持原始时间优先级,而价格变更或完全撤销则不保留。

数据包去重

FLEX 数据包可能被重传。我们使用复合键跟踪已见数据包:

struct PacketKey {
    uint8_t  group;    // multicast_group
    uint8_t  reboots;  // sys_reboots
    uint32_t seq;      // 序列号
};

VLAN 支持

某些抓包环境会插入 802.1Q VLAN 标签(以太网头部多 4 字节)。通过检查偏移量 12 处的 EtherType 检测:

uint16_t ether_type = ntohs(*(const uint16_t*)(packet + 12));
if (ether_type == 0x8100) return 18;  // 带 VLAN 标签
return 14;                             // 标准以太网

价格精度:整数 × 10000

TSE 线路格式将价格表示为缩放 10000 倍的 64 位大端整数。这完全消除了浮点误差:

线路值实际价格
17770000¥1,777.0000
34565000¥3,456.5000
6746000¥674.6000

Itayose 算法中的所有运算都在缩放整数上进行。仅在输出时转换为小数。

市价单哨兵值

市价单的 price = 0xFFFFFFFFFFFFFFFFuint64_t 最大值)。它们参与每个价格档位的成交量计算,但本身永远不会被选为 IAP。


TSE 最小价格变动单位表

TSE 根据证券分类有两个最小价格变动(tick size)表:

表 3:TOPIX500 成分股(更细 tick)

价格区间(¥)Tick(¥)
≤ 1,0000.1
≤ 3,0000.5
≤ 10,0001.0
≤ 30,0005.0
≤ 100,00010.0
≤ 300,00050.0
≤ 1,000,000100.0
≤ 3,000,000500.0
≤ 10,000,0001,000.0
≤ 30,000,0005,000.0
> 30,000,00010,000.0

表 1:其他证券(更粗 tick)

价格区间(¥)Tick(¥)
≤ 3,0001.0
≤ 5,0005.0
≤ 30,00010.0
≤ 50,00050.0
≤ 300,000100.0
≤ 500,000500.0
≤ 3,000,0001,000.0
≤ 5,000,0005,000.0
≤ 30,000,00010,000.0
≤ 50,000,00050,000.0
> 50,000,000100,000.0

关键含义:tick 大小是价格相关的。表 3 下 ¥1,000 的股票 tick 为 ¥0.1,但 ¥3,000 的股票 tick 为 ¥0.5。Itayose 算法必须在每个候选价格档位查找正确的 tick 大小。

来源:TSE Tick Size Schedule


五条件算法详解

现在进入核心算法。给定当前订单簿,Itayose 严格按照五个条件的顺序确定 IAP。每个条件逐步缩小候选价格集,直到只剩一个价格。

条件 1:有效价格区间

IAP 必须在最高委托价格加 1 个 tick 到最低委托价格减 1 个 tick 的范围内。

这定义了至少能发生部分交叉的价格区间:

max_valid = highest_order_price + tick_at(highest_order_price)
min_valid = lowest_order_price  - tick_at(lowest_order_price)

“委托价格”包括买方和卖方。市价单不计入区间计算(它们没有限价)。

特殊情况:如果只存在市价单(没有任何限价单),则没有价格能满足条件 1,因此不会产生成交。

条件 2:最大成交量

在有效区间内的价格中,选择可成交数量最大的价格。

在任意价格 P:

  • 可成交卖量 = 市价卖量 + 所有价格 ≤ P 的限价卖量
  • 可成交买量 = 市价买量 + 所有价格 ≥ P 的限价买量
  • 可成交数量 = min(卖量, 买量)

使用前缀和高效计算:

// 升序遍历:cum_ask_le[i] = 市价卖量 + 价格 ≤ price_levels[i] 的限价卖量
std::vector<uint64_t> cum_ask_le(N);
{
    uint64_t running = mkt_ask_qty;
    auto it = asks.begin();
    for (size_t i = 0; i < N; ++i) {
        while (it != asks.end() && it->first <= price_levels[i]) {
            running += it->second;
            ++it;
        }
        cum_ask_le[i] = running;
    }
}

// 降序遍历:cum_bid_ge[i] = 市价买量 + 价格 ≥ price_levels[i] 的限价买量
std::vector<uint64_t> cum_bid_ge(N);
{
    uint64_t running = mkt_bid_qty;
    auto it = bids.rbegin();
    for (int i = (int)N - 1; i >= 0; --i) {
        while (it != bids.rend() && it->first >= price_levels[i]) {
            running += it->second;
            ++it;
        }
        cum_bid_ge[i] = running;
    }
}

两趟遍历都是 O(N + M),其中 N = 价格档位数,M = 不同委托价格数。远优于朴素 O(N × M) 方法。

条件 3:最小不平衡量(剩余量)

在最大成交量的候选价格中,选择剩余量(surplus)最小的。

价格 P 处的剩余量:

surplus = |可成交卖量 - 可成交买量|

同时跟踪大小和方向:

int dir = (bid_vol > ask_vol) ? 1 :    // 买方偏重
          (ask_vol > bid_vol) ? -1 :    // 卖方偏重
          0;                             // 完全平衡
uint64_t imb = abs_diff(ask_vol, bid_vol);

条件 4:不平衡方向

如果所有剩余候选的不平衡方向相同,则:

  • 全部卖方偏重 → 选最低候选价格
  • 全部买方偏重 → 选最高候选价格

直觉:如果每个候选都是供大于求(卖方偏重),选最低价格以最大化买方参与;如果都是求大于供(买方偏重),选最高价格以最大化卖方参与。

bool all_buy_imb  = std::all_of(c3_cands.begin(), c3_cands.end(),
                    [](const LevelStat& s){ return s.imbalance_dir ==  1; });
bool all_sell_imb = std::all_of(c3_cands.begin(), c3_cands.end(),
                    [](const LevelStat& s){ return s.imbalance_dir == -1; });

if (all_sell_imb) return {lowest_price, matched_vol};
if (all_buy_imb)  return {highest_price, matched_vol};

条件 5:参考价格兜底

当不平衡方向混合(部分买方偏重、部分卖方偏重)时,缩小区间并使用参考价格(基准价格)。

首先缩小候选区间:

narrowed_low  = 最低卖方偏重价格
narrowed_high = 最高买方偏重价格

然后应用参考价格(base_price):

情况规则
base_price > narrowed_highnarrowed_high
base_price < narrowed_lownarrowed_low
base_price 在区间内base_price

参考价格初始值为前收盘价(来自 venue JSON),但会在交易所广播 O 标签且 isItayose() == true 时实时更新:

void processOrderStatus(const uint8_t* tag_ptr, ..., const std::string& symbol) {
    const TradingStatus* ts = reinterpret_cast<const TradingStatus*>(tag_ptr);
    if (ts->isItayose()) {
        uint64_t bcp = ts->getBookCenterPrice();
        if (bcp != 0) market[symbol].setBasePrice(bcp);
    }
}

这对盘中暂停至关重要:熔断后参考价格会变化,交易所通过此字段广播新值。


候选价格构建

一个微妙但重要的细节:Itayose 算法不仅要评估有订单的价格,还必须评估边界价格和间距大于 1 tick 的中间价格。

// 起点:min_valid、所有买方价格、所有卖方价格、max_valid
std::vector<uint64_t> price_levels;
price_levels.push_back(min_valid);
for (const auto& [px, _] : bids) price_levels.push_back(px);
for (const auto& [px, _] : asks) price_levels.push_back(px);
price_levels.push_back(max_valid);

// 填充间距大于 1 tick 的空隙
for (size_t i = 0; i + 1 < n; ++i) {
    uint64_t lo = price_levels[i];
    uint64_t hi = price_levels[i + 1];
    uint64_t gap_tick = getTickSizeForPrice(lo, tick_table);
    if (hi > lo + gap_tick) {
        extras.push_back(lo + gap_tick);   // 下边界 + 1 tick
        uint64_t hi_tick = getTickSizeForPrice(hi, tick_table);
        if (hi >= hi_tick)
            extras.push_back(hi - hi_tick); // 上边界 - 1 tick
    }
}

不填充空隙可能会错过位于两个委托价格之间的最优 Itayose 价格。


成交量取整

IAV 必须是合约乘数(unitOfTrading)的整数倍:

uint64_t matched_vol = (raw_matched_volume / lot_size) * lot_size;

向下截断——不能执行零碎手数。


完整示例

考虑这个简化订单簿:

方向价格(¥)数量
市价200
1,000300
990500
市价100
1,010400
1,020300

条件 1:有效区间 = [1,000 + 1 tick, 1,010 - 1 tick] = [1,001, 1,009]

条件 2:在各候选价格计算可成交数量:

在价格 1,010:

  • 累计卖量(价格 ≤ 1,010)= 100(市价)+ 400 = 500
  • 累计买量(价格 ≥ 1,010)= 200(市价)+ 300 = 500
  • 成交量 = 500,剩余量 = 0

在价格 1,000:

  • 累计卖量(价格 ≤ 1,000)= 100(市价)= 100
  • 累计买量(价格 ≥ 1,000)= 200(市价)+ 300 + 500 = 1,000
  • 成交量 = 100,剩余量 = 900

最大成交量为 500,在价格 1,010,剩余量为零。条件 3 直接解决:IAP = ¥1,010,IAV = 500。


板寄せ vs. A 股集合竞价

TSE 的板寄せ与 A 股集合竞价有重要区别:

特征TSE 板寄せA 股集合竞价
条件 1基于 tick 的委托极值区间同一原则
条件 2最大可成交量相同
条件 3最小剩余量相同
条件 4方向判定有特定规则类似但平局规则不同
条件 5参考价格三种情况取前收盘价
Tick 大小价格相关,两张表按证券类别固定
市价单显式哨兵价格集合竞价不支持
MBO 数据完整逐笔委托可见集合竞价期间仅 MBP

TSE 最独特的特征是价格相关的 tick 大小结合显式市价单处理——A 股集合竞价中两者都不存在。


完整处理流程

整合在一起:

1. 加载 venue JSON → 证券信息(base_price, tick_table, lot_size)
2. 对每个 pcap 文件:
   a. 解析 Ethernet → IP → UDP → FLEX 载荷
   b. 按 (group, reboots, seq_num) 去重
   c. 对数据包中的每个标签:
      A → addOrder()
      D → deleteOrder()
      E/C → executeOrder()
      O → 若板寄せ模式则 updateBasePrice()
      R → reset() 所有订单簿
3. 对每个证券的订单簿:
   a. calculateIndicativeMatch() → (IAP, IAV)
4. 输出 indicative_match.csv

常见陷阱

1. 边界处的价格相关 Tick

max_valid / min_valid 边界使用固定 tick 是错误的。表 3 下 ¥1,000 和 ¥1,001 的 tick 不同。必须在具体价格档位调用 getTickSizeForPrice()

2. 市价单的成交量计算

市价单必须同时加入卖方累计和(升序)和买方累计和(降序)。常见错误是只加一次。

3. 价格档位间的空隙填充

如果买价在 ¥1,000、卖价在 ¥1,050,tick 为 ¥1,则中间有 49 个候选价格需要评估。遗漏它们可能产生错误的 IAP。

4. 成交量截断

IAV 必须向下取整到 lot_size 的倍数,而非四舍五入。向上取整意味着执行了不可匹配的份额。

5. 盘中暂停时的基准价格更新

参考价格不是静态的——交易所在板寄せ模式下广播 O 标签时会更新。仅使用初始 venue JSON 的 base_price 会导致盘中拍卖结果错误。

6. 大端序字段

FLEX 数据包中所有多字节字段都是大端序。在小端机器上忘记 __builtin_bswap32 / __builtin_bswap64 会静默产生错误数据。


输出

应用程序生成 indicative_match.csv,每个证券一行:

symbol, iap, iav
6954, 4165.0000, 332200
6857, 8779.0000, 294200
6486, 2010.0000, 1900
1382, 1743.0000, 300

其中:

  • iap:理论拍卖价格,单位日元(4 位小数)
  • iav:理论拍卖成交量,单位股(lot_size 的整数倍)

价格为 0.0000 表示该证券在处理的 pcap 数据中没有板寄せ时段(例如仅连续交易)。


总结

TSE 板寄せ算法是五级级联筛选:

  1. 条件 1 缩小到有效价格区间
  2. 条件 2 选择最大可成交量的价格
  3. 条件 3 选择最小剩余量的价格
  4. 条件 4 使用不平衡方向(全买偏 → 最高,全卖偏 → 最低)
  5. 条件 5 在缩小区间内回退到参考价格

每个条件充当过滤器,一旦只剩一个价格算法就终止。价格相关 tick 大小、市价单处理和参考价格机制的组合,使 TSE 的板寄せ比典型的交易所集合竞价更加精细。

对于在 TSE 运营的交易公司,正确实现板寄せ不是可选项——理论拍卖价格直接影响订单路由、盘前分析和盘后合规。条件 5 中一个 tick 的错误可能在开盘拍卖中导致数百万日元的执行偏差。


参考

  • TSE 板寄せ条件与定价示例(官方规范文档)
  • TSE Tick Size Schedule
  • TSE FLEX Full MBO 历史数据格式规范