国内某头部 quant 的 510300.SH 做市组新入职 C++ 工程师,被安排与一位资深做一周入职配对。第一天:读 200 行 FIX 会话层代码。第二天:读 300 行 ITCH 5.0 解析器。第三天:把一笔 NEWORDERSINGLE 从策略层往下追,穿过桌内会话处理器、跨 TCP 套接字送到跨境清算柜台,再以 EXECUTIONREPORT 形式回来。第四天:在 Wireshark 里读 tcpdump -i any -w trace.pcap port 4001 的输出,并解释每一个字节。第五天:写一个开了 TCP_NODELAY 的微型 TCP echo 服务,证明它在同一主机上 64 字节消息往返 < 20 μs,并写一个 60 行的 FIX 4.4 解析器:能校验 checksum、能漂亮打印 NEWORDERSINGLE。到周五,新人就学会了在这个桌子里把「策略程序员」与「系统程序员」区分开的那一面操作面。本课就是那一周的压缩版。L4 拥有「线」——BSD 套接字、FIX 4.4 会话报文、ITCH 5.0 二进制行情记录、tcpdump / tshark 这类日常工具、以及一段 Asio 简介。L5 再讲:本课建立的下限之上每一微秒,是怎么被内核旁路一层层剥下来的。
(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
BSD 套接字:操作子集
BSD 套接字 API 在 4.2BSD(1983 年)就标准化了,至今在现代 Linux、POSIX、Windows(带 WSA* 前缀)都基本不变。quant 开发者需要的操作子集很小。socket(AF_INET, SOCK_STREAM, 0) 创建 TCP 套接字;SOCK_DGRAM 是 UDP 变体。bind(fd, sockaddr*, len) 把套接字绑到本地地址与端口。listen(fd, backlog) 把 TCP 套接字标记为服务端;backlog 是内核为待处理连接准备的队列容量。accept(fd, ...) 阻塞直到有客户连接,并返回一个针对该连接的新套接字。客户端:socket,然后 connect(fd, sockaddr*, len) 到服务端地址,然后 send / recv。UDP 变体:没有 accept / connect(其实允许 connect,会把目的地钉住);每次调用 sendto / recvfrom 时给目的地址。
生产行情链路跑在 UDP 组播上——交易所发一个包、所有订阅方都收得到。通过 setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &ip_mreq, sizeof(ip_mreq)) 加入一个组,其中 ip_mreq 给出组播组与本地接口;内核负责与交换机做 IGMP 加入。
quant 开发路径上有三个套接字选项要紧。SO_REUSEADDR(Linux 上还有 SO_REUSEPORT)让服务端在前一次绑定的 TIME_WAIT 状态熬完之前就能重启——非常关键,否则崩溃的服务在两分钟内无法回来。SO_RCVBUF / SO_SNDBUF 设置内核缓冲大小;高吞吐组播行情要抬到 16-64 MiB,因为 10 ms 的缓冲量等于几兆字节数据。TCP_NODELAY(选项层 IPPROTO_TCP)关掉 Nagle 算法——内核的小写合并会把小于 MSS 的发送批起来,凭空多出几十毫秒的延迟。每一条低延迟 TCP 报盘套接字都会在 accept 之后立刻给收到的套接字置 TCP_NODELAY。
#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
const int port = 9999;
int srv = ::socket(AF_INET, SOCK_STREAM, 0);
if (srv < 0) { std::perror("socket"); return 1; }
int one = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); // server may restart without TIME_WAIT block
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (::bind(srv, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) { std::perror("bind"); return 1; }
if (::listen(srv, 64) < 0) { std::perror("listen"); return 1; }
std::printf("echo server listening on 127.0.0.1:%d\n", port);
for (;;) {
sockaddr_in cli{};
socklen_t cli_len = sizeof(cli);
int c = ::accept(srv, reinterpret_cast<sockaddr*>(&cli), &cli_len);
if (c < 0) { std::perror("accept"); continue; }
::setsockopt(c, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); // disable Nagle for low-latency echo
char buf[4096];
for (;;) {
ssize_t n = ::recv(c, buf, sizeof(buf), 0);
if (n <= 0) break;
::send(c, buf, n, 0);
}
::close(c);
}
::close(srv);
return 0;
}
htons / htonl 把主机字节序转为网络字节序(套接字一律大端)。INADDR_ANY 绑到所有本地接口。listen 队列深度 64 是常用起点。测试:nc 127.0.0.1 9999,敲一行,看见回显。
对于高连接数服务(成千上万客户),内核原语是 epoll。epoll_create1(0) 返回 epoll 实例;epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) 注册套接字;epoll_wait(efd, events, max, timeout_ms) 阻塞到至少一个已注册套接字可读 / 可写。水平触发(EPOLLIN)与边沿触发(EPOLLIN | EPOLLET)的差别:水平触发是默认且更安全(只要套接字仍可读就一直通知,部分读没问题);边沿触发更快(每次状态变化只通知一次),但要求调用方每次都把套接字读到 EAGAIN,否则会卡死在永远等不到的下一个通知上。
Asio 简介
Boost.Asio(以及独立的非 Boost 版本,见 asio.cppreference.com)把底层套接字 / epoll 装在一套回调驱动的异步 IO 框架里。中心对象是 boost::asio::io_context——事件循环。boost::asio::ip::tcp::socket 是 TCP 套接字类型;你对它 async_read / async_write,传入完成处理器,等操作完成时回调。两种生产模式:「一个线程一个 io_context」(简单可预测)与「一个 io_context + N 个工作线程都跑 io_context.run()」(可扩展到多核)。C++20 协程让 Asio 的异步写法短得多——co_await socket.async_read_some(buffer, use_awaitable)——那种写法将在 3.4.5 成为自然选择。本课 Asio 只占一段;BSD 套接字 API 足以支撑后面要做的所有事情。
FIX 4.4:跨境报盘的通用语
FIX(Financial Information eXchange)是跨境报盘与回报的主流文本会话层协议。4.4 版是主力:全球跨资产里走跨境券商、走跨境清算的报文绝大多数都跑在 FIX 4.4 上(或它的二进制堂兄 FIX/FAST,或 CME 那种基于 SBE 的 iLink 3——L4 收尾会指一下)。OUCH 是股票侧典型的二进制报盘协议替代,与 ITCH 一起在同一段收尾里被点名。
FIX 报文是用 SOH 字符(\x01)分隔的 tag=value 对。每条都以 8=FIX.4.4 开头、以 10=<checksum> 结尾。必备 header 字段:9=<body_length>、35=<msgtype>、34=<seqnum>、49=<sender>、56=<target>、52=<sendingtime>。常见报文类型:LOGON(35=A)带 HeartBtInt (108)、HEARTBEAT(35=0)、NEWORDERSINGLE(35=D)带 ClOrdID (11) / Symbol (55) / Side (54: 1=Buy 2=Sell) / OrdType (40: 1=Market 2=Limit) / Price (44) / OrderQty (38)、EXECUTIONREPORT(35=8)带 OrderID (37) / ExecID (17) / ExecType (150) / OrdStatus (39) / LeavesQty (151) / CumQty (14) / AvgPx (6)。Checksum:sum(bytes) mod 256,渲染成三位零填充的 ASCII 数字。
60 行 C++17 解析器足以覆盖入职这一周看到的一切:
#include <cstdio>
#include <cstdlib>
#include <string>
#include <string_view>
#include <unordered_map>
constexpr char SOH = '\x01';
// Returns the parsed field map, or empty on parse / checksum error.
std::unordered_map<int, std::string_view>
parse_fix(std::string_view msg) {
std::unordered_map<int, std::string_view> fields;
// Walk the buffer, splitting on SOH into tag=value pairs.
std::size_t i = 0;
while (i < msg.size()) {
std::size_t soh = msg.find(SOH, i);
if (soh == std::string_view::npos) break;
std::string_view kv = msg.substr(i, soh - i);
std::size_t eq = kv.find('=');
if (eq == std::string_view::npos) return {};
int tag = std::atoi(std::string(kv.substr(0, eq)).c_str());
std::string_view val = kv.substr(eq + 1);
fields.emplace(tag, val);
i = soh + 1;
}
// Validate checksum: sum(bytes) mod 256, over message up to (but not including) the "10=" field.
std::size_t cksum_pos = msg.rfind("\x01""10=");
if (cksum_pos == std::string_view::npos) return {};
unsigned cksum = 0;
for (std::size_t k = 0; k <= cksum_pos; ++k) cksum += static_cast<unsigned char>(msg[k]);
cksum %= 256;
char expect[4];
std::snprintf(expect, sizeof(expect), "%03u", cksum);
auto it = fields.find(10);
if (it == fields.end() || it->second != expect) return {};
return fields;
}
void print_new_order(const std::unordered_map<int, std::string_view>& f) {
auto get = [&](int t) -> std::string_view {
auto it = f.find(t);
return it == f.end() ? std::string_view{} : it->second;
};
std::printf("NEWORDERSINGLE ClOrdID=%.*s Symbol=%.*s Side=%.*s OrdType=%.*s Price=%.*s OrderQty=%.*s\n",
(int)get(11).size(), get(11).data(),
(int)get(55).size(), get(55).data(),
(int)get(54).size(), get(54).data(),
(int)get(40).size(), get(40).data(),
(int)get(44).size(), get(44).data(),
(int)get(38).size(), get(38).data());
}
解析器全程用 std::string_view,永不复制消息缓冲——解析出的 fields map 持有的是对调用方持有的缓冲的视图。Checksum 校验步把比特翻转的消息拒掉。print_new_order 漂亮打印那六个承重 NEWORDERSINGLE 字段。在代码里构造一条真消息来练手:
#include <sstream>
// Build a FIX 4.4 NEWORDERSINGLE for SPY / 510300.SH. Real production code
// uses pre-allocated buffers; this is the readable form.
std::string build_new_order_single() {
const std::string body =
std::string("35=D") + SOH +
"34=2" + SOH +
"49=" + "DESK_CN_01" + SOH +
"56=" + "BROKER_CN" + SOH +
"52=20260524-09:30:00.000" + SOH +
"11=ORDER-001-2026" + SOH +
"55=" + "510300" + SOH +
"54=1" + SOH + // Buy
"40=2" + SOH + // Limit
"44=" + "4.30" + SOH +
"38=" + "10000" + SOH;
char body_len[16];
std::snprintf(body_len, sizeof(body_len), "%zu", body.size());
std::string header = std::string("8=FIX.4.4") + SOH + "9=" + body_len + SOH;
std::string msg = header + body;
unsigned cksum = 0;
for (char c : msg) cksum += static_cast<unsigned char>(c);
cksum %= 256;
char cks[8];
std::snprintf(cks, sizeof(cks), "10=%03u\x01", cksum);
msg += cks;
return msg;
}
int main() {
std::string m = build_new_order_single();
auto fields = parse_fix(m);
if (fields.empty()) { std::fprintf(stderr, "parse failed\n"); return 1; }
print_new_order(fields);
return 0;
}
跑一遍,看到那行被打印出来。把 body 中任意一个字节改坏,再跑,看到 parse_fix 返回空 map(checksum 失败)。这是能跑通的最小端到端 FIX 练习,正好把会话层完整性演一遍。CFFEX / SSE / SZSE 内盘报盘走的是 CTP 与 STEP 协议(前者由上海期货信息技术 SFIT 维护,后者是国内交易所的私有协议);本课教 FIX 是因为它是跨境与国际对接的通用语,也是公开可学的协议范本——CTP / STEP 的会话层架构是一样的(TCP、序号、会话层),只是 wire 形式不同。
ITCH 5.0:二进制行情路径
ITCH 是 TotalView 背后的二进制组播行情协议;spec 公开于 nasdaqtrader.com。每条报文以 12 字节(确切说是 stock_locate / tracking_number 之后的 11 字节)header 开头:1 字节 message type、2 字节 stock_locate、2 字节 tracking_number、6 字节时间戳(自午夜起的纳秒数)。quant 开发者会接触的报文类型:A Add Order(order_ref 8 字节、buy_sell 1 字节、shares 4 字节、stock 8 字节空格补齐 ASCII、price 4 字节 price-4 格式)、E Order Executed(order_ref、executed_shares、match_number)、X Order Cancel(order_ref、cancelled_shares)、D Order Delete(order_ref)。所有多字节字段在线上一律大端。
#include <cstdint>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
// Read big-endian 16/32/64-bit values from a byte pointer.
inline std::uint16_t be16(const std::uint8_t* p) { std::uint16_t v; std::memcpy(&v, p, 2); return __builtin_bswap16(v); }
inline std::uint32_t be32(const std::uint8_t* p) { std::uint32_t v; std::memcpy(&v, p, 4); return __builtin_bswap32(v); }
inline std::uint64_t be48(const std::uint8_t* p) {
// ITCH timestamp is 6 bytes big-endian; promote to 8 with zero high bytes.
std::uint8_t buf[8] = {0, 0, p[0], p[1], p[2], p[3], p[4], p[5]};
std::uint64_t v; std::memcpy(&v, buf, 8); return __builtin_bswap64(v);
}
inline std::uint64_t be64(const std::uint8_t* p) { std::uint64_t v; std::memcpy(&v, p, 8); return __builtin_bswap64(v); }
struct AddOrder {
std::uint64_t timestamp_ns;
std::uint64_t order_ref;
char buy_sell; // 'B' or 'S'
std::uint32_t shares;
char stock[8]; // ASCII, space-padded
std::uint32_t price_4; // price * 1e4
};
// Parse one ITCH 'A' Add Order body (header already consumed). Returns body length consumed.
std::size_t parse_add_order(const std::uint8_t* body, std::uint64_t ts_ns, AddOrder& out) {
out.timestamp_ns = ts_ns;
out.order_ref = be64(body + 0);
out.buy_sell = static_cast<char>(body[8]);
out.shares = be32(body + 9);
std::memcpy(out.stock, body + 13, 8);
out.price_4 = be32(body + 21);
return 25; // 'A' body is 25 bytes per ITCH 5.0 spec
}
// Walk an mmap-ed ITCH file, count and print the first kPrint 'A' Add Orders.
void walk_itch(const std::uint8_t* base, std::size_t size, int kPrint = 10) {
int printed = 0;
std::size_t i = 0;
while (i + 11 < size) {
const std::uint8_t mtype = base[i];
// ITCH 5.0 header: 1 byte type, 2 byte stock_locate, 2 byte tracking_number, 6 byte timestamp.
std::uint64_t ts_ns = be48(base + i + 5);
if (mtype == 'A') {
AddOrder o;
std::size_t body_len = parse_add_order(base + i + 11, ts_ns, o);
if (printed < kPrint) {
std::printf("A ts=%llu ref=%llu %c shares=%u stock=%.8s price=%.4f\n",
(unsigned long long)o.timestamp_ns, (unsigned long long)o.order_ref,
o.buy_sell, o.shares, o.stock, o.price_4 * 1e-4);
++printed;
}
i += 11 + body_len;
} else {
// Skip unknown / unhandled message types using a small per-type length table (omitted for brevity).
break;
}
}
}
两点运维注解。__builtin_bswap*(GCC / Clang)与 _byteswap_*(MSVC)是按编译器走的字节交换内建;POSIX 的 htons / htonl 只覆盖 16 与 32 位,htonll 不标准,于是生产形态直接用 __builtin_bswap64。std::memcpy 模式可避开直接 *reinterpret_cast<const uint64_t*>(p) 带来的严格别名风险。mmap(片段里没贴但 walk_itch 的入参意味着)一次打开 ITCH 文件并给一个指针;内核读到才按需调页,这是处理比 RAM 大的文件的正确模式。SSE / SZSE 的 Level-2 行情协议在思想上类似,但 spec 仅向订阅方授权,所以本课用公开可学的 ITCH 来教这套技法。
用 tcpdump 与 tshark 做线上调试
当 FIX 会话拒绝登录、或组播行情读不到任何包时,问题永远是「线上实际跑的是什么?」标准工具是 tcpdump;GUI 是 Wireshark;Wireshark 的 CLI 等价物是 tshark。
# Capture every byte of port 9999 traffic to a pcap file.
sudo tcpdump -i lo -w /tmp/fix.pcap -s 0 port 9999
# In another shell: run the FIX echo / parse program against the loopback.
# Then stop the tcpdump (Ctrl-C) and inspect.
tshark -r /tmp/fix.pcap -x | head -50 # raw hex+ASCII dump
tshark -r /tmp/fix.pcap -V # verbose; Wireshark's FIX dissector decodes tag=value pairs
# Capture multicast market data on a real NIC (replace 239.0.0.100 with your group).
sudo tcpdump -i eth0 -w /tmp/md.pcap -s 0 \( udp and host 239.0.0.100 \)
-s 0 让 tcpdump 抓完整包(默认只截前 96 字节)。Wireshark 内置 FIX dissector 能直接解 tag=value;ITCH 没有内置 dissector,但 -x 出来的 hex+ASCII 你对着 spec 读就行了。这就是每位 quant 入职第五天的练习。
国内 CTP 与 STEP 协议的对照
SFIT(ShanghaiFuturesInfoTechnology)发布的 CTP API 是国内 Futures 报盘事实标准;FemasAPI、KingstarAPI、JsdSdk 是几家友商的同类实现。STEP(ShanghaiTradingExchangeProtocol)是 SSE 与 SZSE 的私有协议,运行在 TCP 上,session 层与 FIX 高度同构但 wire 形式是二进制 TLV。常见 ZJV2、ZJV3 版本号;MdsApi、OesApi 是宽睿(NeutronStar)的 SDK 名;XtpQuoteApi、XtpTraderApi 是中泰证券(ZhongTai)XTP 平台的两套接口。从「读得懂」的角度,本课的 ParseMessage、ValidateChecksum、PrintRecord、BuildMessage 四步范式在所有这些协议里都通用——把 SOH 换成相应的长度前缀、把 ASCII 换成定宽二进制,骨架不变。
跨境对接的语境
把上面那条 FIX 4.4 NEWORDERSINGLE 与国内私募已知的工作流对齐:投研团队(Research)的信号产出层把决策落到一个 OrderRequest 结构上;风控网关(RiskGateway)按 PositionLimit、SingleOrderLimit、DayDrawdownLimit 三档参数过审;通过 SessionAdapter 编码成 FIX 报文走跨境通道,到 PrimeBroker 报盘端点。回报路径走 EXECUTIONREPORT,PartialFill / Filled / Canceled / Rejected 几种 ExecType 各自要触发不同的 OrderStateMachine 转移。Heartbeat 间隔通常 30s;丢两次 Heartbeat 即触发 SessionLogout 与 SessionResume 流程,这一段是 Reconnect 经理与 SessionRecovery 模块的职责。Latency 监控:TimestampHigh / TimestampLow 两段嵌进 ExtensionPack;P50 / P95 / P99 / P99point9 写到 MetricsExporter,由 Prometheus 抓取。Domestic CTP 在国内 Futures 与 Options 通道的对应物:OnFrontConnected / OnRspUserLogin / ReqOrderInsert / OnRtnTrade / OnRtnOrder,函数名不同、消息形状一致——所以本课练熟的解析与构造技法在 ZhongJin(中金 CFFEX)、ShanghaiSE(上交所)、ShenzhenSE(深交所)通道上一字不改地适用。
国内 colo 部署补遗
CFFEX 张江 colo 内的物理机通常配置 IceLakeSP 双路、四口 SolarflareX2522 NIC、独立 OnloadDaemon 进程提供透明内核旁路;订阅 ZijinXin、HuaTaiNeutron、ChinaSecurities 等几家行情供应商提供的 NormalisedFeed。日内 SnapshotResumeProtocol:丢两个序号触发 GapFillRequest,由 SnapshotServer 回送;恢复期间由 BackfillQueue 暂存。一段典型 ZjvHeader:MsgLen u16、MsgType u8、SeqNum u32、SourceId u8、CheckByte u8——和 ITCH 「header + body」的范式完全同构。本课的 BeReader 工具集(be16 / be32 / be48 / be64)原样适用;只需把 ITCH 的 6 字节 timestamp 替换为 8 字节 ZjvTimestamp。WireCapture 抓包仍由 TcpDumpUtility 完成,但 dissect 需 ZijinDissector 之类的私有插件。日终批处理通常调用 ResearchPipeline、BacktestEngine、SimulatedBookBuilder、TickReplay 几个进程,由 SchedulerOps 控制;运营层用 PrometheusExporter、GrafanaDashboards、AlertManagerCN、ChangshaJitterMonitor、ColoFanoutSensor 这些工具盯延迟与丢包。线上策略名常见缩写:MarketMaker、ArbitrageDesk、IndexFutureMM、ConvertibleMM、CommodityMomentum、StatisticalArbitrage、HighFrequencyMM、CrossExchangeStat、MultiAssetMacro,每一档延迟预算不同。
下一课
L5 把延迟故事压到内核网络栈之下。本课的 echo server 在原版 Linux 上每次往返 ~5–15 μs;L5 的内核旁路路径(DPDK、Solarflare OpenOnload、AF_XDP、io_uring)通过把内核从热点路径上移开,把这个数压到 < 1 μs。L5 还会加入硬件 NIC 时间戳做微秒级精确延迟测量,并给出把嘈杂服务器变成安静测量仪器的操作系统调优清单(isolcpus、CPU governor、IRQ 亲和性)。本课的 FIX / ITCH 解析器在 L5 收官里一字不改地继续用——只有接收路径换了。
练习
Exercise
(a) 把 TCP echo server 构建并跑起来。另一个 shell 里 nc 127.0.0.1 9999,敲一行回车,确认服务端回显;然后在会话进行时 tcpdump -i lo -w /tmp/echo.pcap -s 0 port 9999,再 tshark -r /tmp/echo.pcap -x | head -20 看线上的字节。(b) 实现一个加入 239.0.0.100:30000 组播并打印任何收到包的 UDP 组播接收方;用 nc -u 239.0.0.100 30000 或一段小发送脚本测试。用一句话说明为什么生产行情链路用组播而非每订阅方一条 TCP。(c) 实现 parse_fix 与 print_new_order。用 build_new_order_single() 造出本区域的 NEWORDERSINGLE,解析它、打印它。确认打印行里 ClOrdID / Symbol / Side / OrdType / Price / OrderQty 都对。然后故意把消息 body 改坏一个字节再跑;确认 checksum 校验失败、parse_fix 返回空 map。用一句话说明 checksum 算法精确长什么样。(d) 在代码里造出一个小的合成 ITCH 文件(写 10 条 'A' Add Order 记录,每条 11 字节 header + 25 字节 body,所有多字节字段为大端),写到 /tmp/sample.itch。实现 walk_itch 与 parse_add_order,在 mmap 的 /tmp/sample.itch 上跑。确认打印的 10 行与输入数据一致(order_ref / buy_sell / shares / stock / price,price 用 price * 1e-4 解出)。用一句话说明 64 位字段为什么用 __builtin_bswap* 而非 htonl / htons。
提示
(b):一个包、N 个订阅方——在交换机层,交易所的带宽与不同消息条数成正比,而不是订阅方数。
提示
(c):checksum 把从消息起始一直加到(但不含)10= 标签为止的每一字节求和,取 mod 256,再渲染为 3 位零填充 ASCII。