GSAPify/ohlcv-validator
GitHub: GSAPify/ohlcv-validator
该项目是一个按照 HFT 工程标准构建的低延迟 C++20 市场数据验证与订单簿处理框架,专注于零分配热路径上的极速行情验证与订单簿恢复。
Stars: 3 | Forks: 0
# ohlcv-validator
[](https://github.com/GSAPify/ohlcv-validator/actions/workflows/ci.yml)





一个按照 HFT 工程标准构建的低延迟 C++20 市场数据 pipeline —— 而不是一个
数据质量脚本。它摄入实时和交易所风格的行情流,在零分配的热路径上进行验证,
从冗余的 multicast feed 中重建订单簿,并在上层搭载了一个实在的 ML 层。**每一个关于正确性的声明都有测试支撑;
每一个关于性能的声明都有实测结果。**
```
validate ~6 ns / record zero allocations, proven by a test (not asserted)
throughput ~1.1 B rec/s (24c) ~168 M/s single core
latency p50 20 ns · p99 30 ns · p99.9 40 ns (x86, rdtscp)
hardening 147 tests · ASan/UBSan/TSan clean · 1.9 M fuzzed parser inputs, 0 crashes
```
### 快速开始
```
brew install cmake ninja boost simdjson spdlog nlohmann-json googletest
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build
ctest --test-dir build # the C++ test suite
./build/gen_dataset data/replay.bin 1000000 && ./build/replay_bench data/replay.bin 100
```
### 架构
```
flowchart LR
LIVE["Alpaca IEX (WebSocket)"] --> P["simdjson parse"]
LA["multicast line A"] --> ARB["FeedArbitrator
dedup · in-order · gap detect"] LB["multicast line B"] --> ARB GEN["gen_dataset / live capture"] --> BIN[("binary replay .bin
fixed 88B records")] P --> W["WireRecord"] ARB --> W BIN --> W W --> V["Validator
zero-alloc · ~6 ns/record"] ARB -. "gap" .-> BOOK["L2 order book
snapshot recovery"] V --> OUT["violations + live report"] BIN --> RING["lock-free SPSC ring"] BIN --> BENCH["replay_bench
168M/s · 1.1B/s · p50 20ns"] BIN --> ML["ml/ — features → robust-z → autoencoder
+ RL sandbox"] ``` ## 目标 在**热路径**上验证实时 OHLCV 市场数据 —— 零堆分配, 单核约 6 ns 处理一条记录 —— 而不是研究级的数据质量脚本。 我对它的要求是:每一个关于正确性的声明都有测试支撑,每一个 关于性能的声明都有可复现的实测结果,并且表述严谨诚实。 ## 为什么会有这个项目 大多数“市场数据验证器”都是使用 FastAPI 查询每日 K 线的服务。这只解决了另一个问题(用于研究的数据质量),而使用的技术栈并不能平移到低延迟工程面试中。这个项目有针对性地指向了 HFT 和做市商公司所招聘的*工程*技能:感知缓存的设计、无分配的热路径以及经过实测的吞吐量。 ## 工程决策 这些决策比功能列表更重要: - **在实测路径上使用二进制而非 JSON。** 项目包含了一个实时的 JSON websocket(Alpaca IEX)作为“在真实数据上运行”的演示 —— 但在 JSON 上测量从 socket 到验证的时间,测定的是解析器和网络,而不是市场数据处理。真实的交易所发送的是固定布局的二进制消息(ITCH/SBE 风格),因此基准测试直接通过 cast 从 `mmap` 的文件中读取固定步长的记录:没有解析,没有拷贝,没有分配。 - **诚实表述的吞吐量。** 单核下约 167 M 条记录/秒,从 1M 到 10¹⁰ 次验证保持稳定。这里刻意*没有*将其报告为延迟分布:单条记录的成本低于 Apple Silicon 约 41 ns 的时钟下限,因此在此环境下的 p50/p99 将会是计时器噪声。延迟分布本身是在 x86 上使用 `rdtscp` 测量的(见基准测试)—— 在 Ryzen 9 7900X3D 上 p50 为 20 ns / p99 为 30 ns。 - **声明是被证明的,而不是断言的。** “热路径上零分配”是一个测试(`tests/test_alloc_guard.cpp` 重载了全局 `operator new`,并在发生任何分配时失败),而不是 README 中的一句话。 - **使用容差进行重建,而不是 `==`。** 跨记录检查 —— 一根 K 线的组成交易能否重建其成交量、VWAP 和 OHLC?—— 使用了相对容差,因为真实的 feed 会将价格四舍五入到一个 tick,而精确相等会导致每一根实时 K 线上都出现假阳性。 ## 状态 `v0.1.0` —— 实时 JSON 摄入 + 在二进制 回放路径上测量的零分配验证器。 ``` line A ┐ exchange-style feed: line B ┴► FeedArbitrator ──► WireRecord ──► Validator A/B arbitration, in-order (seq'd packets, dedup, in-order release, release, gap detection redundant) gap detection (zero-alloc) ← "real feeds" path Alpaca IEX ──ws──► parse ──► [→ Wire] ──► Validator ──► live violation report ← runs on real ticks (validates, not just parses) binary file ──mmap──► WireRecord ──► Validator ──► throughput (fixed-layout POD, no parse, zero alloc ~167M rec/s ITCH/SBE-shaped) no copy after ctor) ← the measured path binary file ──► [decode thread] ──push──► SPSC ring ──pop──► [validate thread] (lock-free, ← pipeline cache-line aware) (x86) binary file ──► ml/ (Python) ──► features ──► robust-z baseline ──► scores (same .bin, NumPy reader per-symbol (rung 2) ← ML layer zero-copy) mirrors wire.h bar features ──► autoencoder ──► scores (offline) (rung 3, torch) ``` C++ 部分是快速数据平面;`ml/` 是一个离线的 Python 层,它从*相同*的 二进制文件中学习(没有第二次序列化路径)。它将交易和 K 线读取 到 NumPy 中,并运行一个 **model ladder** —— 一个稳健 z 分数的基线(第 2 阶)和 一个 autoencoder(第 3 阶),只有当某一阶击败其下一阶时,该阶梯才被证明是合理的。 该 autoencoder 被展示为可以捕捉到**规则不可见的**异常(一种 收益↔成交量 相关性断裂),而验证器和基线都漏掉了这一点 —— 这是一个机制 演示,而不是对实际生产性能的声明。一个单独的 **position-taking RL sandbox** (`ml/rl_env.py`)运行在相同的特征流上,并带有一个无前瞻证明 (作弊策略能获利;仅依赖 obs 的策略不能)。RL 侧并没有采用训练好的 agent, 而是通过一个 **walk-forward backtest** 进行了加固 —— 启发式规则仅 在具有 **transaction cost swept** 的样本外测试尾部进行评分(仅依赖 obs 的 优势净值为 ~0 并在约 1 bp 时消亡)—— 外加一个风险可调的 reward。异常检测阶梯和 回测的评估都是**无泄漏的**:一个按时间顺序的 fit→calibrate→test 划分(没有对时间序列进行随机打乱),并使用了**校准过的、数据驱动的 阈值**,而不是教科书上的 σ —— 而这种严谨性揭示了随机划分的 AUC (0.997) 是被前瞻偏差放大了(在诚实划分下约为 ~0.83–0.98)。请参阅 [`ml/README.md`](ml/README.md) 了解详细对比,以及为什么订单簿 深度学习文献 (Sirignano 2016) 不适用于此行情流。 ### Feed handler — A/B 仲裁 + 间隙检测 真实的交易所行情流并不是单一的 WebSocket:交易所通过 UDP multicast 冗余地在一个序列号化的二进制流上发布到两条线路(A 和 B), 并且接收方必须重建真实的流。`src/feed/` 就是承担此任务的 核心:`FeedArbitrator` (`feed/arbitrator.h`) 消费来自两条 线路的带序列号的 packet,并且**严格按顺序且恰好一次地交付每一个序列**, 优先选择先到达的那条线路,同时**检测真正的间隙**(在*两条*线路上 丢失的序列)。它把简单的 handler 容易搞砸的两件事做对了:它不会 仅仅因为乱序就声明出现间隙(它缓冲一个乱序窗口并释放 连续的前缀),并且其基于序列号的二次幂环形队列在每个槽位存储并验证 序列号,因此跳跃很远的消息无法与活跃消息产生别名冲突。packet 路径上零分配 (经过 alloc-guard 测试),payload 是相同的 `WireRecord`。这是 仲裁 + 间隙*检测*,而不是恢复 —— 检测到的间隙是真实系统 请求重传或重放快照的钩子;该间隙信号正是 订单簿构建器(见下文)所需要的,因为在通过快照 恢复之前订单簿是无效的,而交易流则可以容忍跳过。 **multicast 传输** 将其连接到真实的 socket(`feed/udp_multicast.h`): `feed_publisher` 就像一个“交易所”,将一个序列化的流冗余地发布到 两个 multicast 组(具有针对每条线路可配置的丢包率),而 `feed_handler` 加入 这两个组,单线程排空它们,进行仲裁,并验证重建后的 流。这一点已通过确定性的环回集成测试得到证明 (`tests/test_feed_multicast.cpp`,单条线路的丢包由另一条线路覆盖; 两条线路同时丢包就是那唯一的间隙)。该测试已经构建,但被排除在 CI 之外(multicast 在 runner 上无法保证)—— 在本地运行它。实时演示的*吞吐量*是 环境依赖的:在 macOS multicast 回环上的高速突发流量显示出 非确定性的 socket 级丢包(通过 `netstat -s -p udp` 确认:“dropped due to full socket buffers”),因此 publisher 进行了定速;在 Linux 上则是可靠的。 ### 订单簿 — 带有快照恢复的 L2 构建器 `src/book/` 根据序列化后的增量流构建一个聚合的 (L2) 限价订单簿 —— 这是真实策略(以及订单簿 ML 文献)操作的 结构,也是 OHLCV/top-of-quote 无法提供的东西。`OrderBook` 保留了买卖双方的最优价位 (零分配,最优在前);`BookBuilder` 是最关键的部分:一个 `Recovering ↔ Live` 状态机,使得订单簿定义中最艰难的 需求变得明确 —— **一次丢失的更新会使整个订单簿无效,直到一个快照 重建它**(交易流可以容忍跳过的序列;订单簿不能,因为 每一个 delta 都在改变后续 delta 所依赖的状态)。这就是上文 #1a 精确的间隙 检测发挥关键作用的地方。 巧妙且起关键作用的部分是**恢复竞态**:快照是异步生成的, 而此时 delta 仍在不断流入,因此它是“在 seq N 时准确的”,而你 可能已经缓冲了 N+1…N+5。正确的恢复会丢弃 seq ≤ N 的缓冲 delta (已经包含在快照中),并且只重放 seq > N 的部分 —— 重新应用一次 会导致重复计算,丢弃一个则会留下漏洞 —— 并且拒绝过旧的快照 因为它无法填补缺口,此时会等待更新的快照,而不是构建一个损坏的订单簿。 证明不仅仅是个标签:一个测试通过快照恢复重建了带有间隙的流, 并断言其结果与从头开始构建相同 delta 的结果**在字节上完全一致**。(这里是 L2;L3 的 per-order 是后续工作。构建订单簿是控制平面, 因此层级记账的复杂度是 O(levels),而不是纳秒级的热路径 —— 这是有意为之的。) ## 基准测试 解码+验证,单核,1M 条记录的数据集,200 轮(`replay_bench`), 在 Apple Silicon (M-series) 上。 ``` throughput: ~167 M records / sec mean: ~6 ns / record (allocation-free hot path) ``` (运行在每一笔交易上的每笔交易重建累加器,就是导致它从仅边界验证器约 2 ns/record 的基础上慢下来的原因; 它仍然是零分配的,只是现在做的是真实的工作。) 那个 M-series 的数据是**吞吐量**(按轮次批处理)—— 单条记录的成本低于 Apple Silicon 约 41 ns 的时钟下限,因此在那里无法测量单条记录的 *尾部*延迟。为此,基准测试在 x86 上运行。 ### 延迟分布 — x86 (`rdtscp`) Ryzen 9 7900X3D (4.4 GHz 不变 TSC),单核使用 `taskset` 绑定,每次 解码+验证使用一对 `rdtscp` 计时(计时器开销已测量并 扣除),5M 样本(`replay_bench_rdtsc`): ``` p50 20 ns · p99 30 ns · p99.9 40 ns · p99.99 ~200 ns · mean ~17 ns ``` 跨多次运行稳定至 p99.9。这是每个事件的、`lfence` 序列化的延迟 (point-in-time 指标)—— 高于上文提及的摊销吞吐量,因为 序列化破坏了流水线操作。 较远的尾部(p99.99 约 200 ns,最大值为数十微秒)是 **WSL2 虚拟化层造成的, 而不是正常任务的抢占** —— 而且我对此进行了测量,而不是猜测: 绑定核心并使用 `SCHED_FIFO` 实时调度(`chrt -f`)后,p99.99 和最大值 *保持不变*,因此提高调度优先级没有帮助。这个尾部是 Hyper-V 宿主机取消了虚拟机 vCPU 的调度。要消除它,需要在裸金属上有一个真正隔离的核心 (`isolcpus`/`nohz_full`),而这是 WSL2 无法完全提供的。验证器能 实际控制的核心分布(p50–p99.9)是非常紧凑的。 ### 多核扩展 — x86,24 线程 在 7900X3D 的 24 个线程上进行按 symbol 分片(`replay_bench_mt`): ``` 1c 93 M/s 2c 2.2× 4c 3.9× 8c 6.4× 10c 9.5× 14c (dip) 24c ~12× → ~1.1 B records/sec ``` 在约 10 个核心前接近线性扩展,随后由于内存带宽以及芯片的双 CCD / 3D V-cache 的不对称性,扩展性开始减弱(在 14 个线程处出现可复现的性能下降,因为工作负载溢出到了 两个 chiplet 上)。峰值 ≈ **1.1 billion records/sec**,单台机器 (已再次确认)。在未隔离的 WSL2 宿主机上,单核数据和加速比幅度在每次运行时会有所波动;但趋势形状(早期接近线性、出现 chiplet 性能坑、约 ~1 B 的峰值)是 稳定的。 ### Pipeline — 无锁 SPSC 环形队列 (x86) 一个单生产者/单消费者环形队列(`src/con/spsc_ring.h`)将解码 线程与验证线程解耦 —— 这是每个真实的 feed handler 都具备的 结构。在 IEX 的成交量下,一个线程足以应对;这不是核心负载。它在这里是为了使线程间 的交接变得可测量,并用数据而非传言来解决关于 cache-line 的问题。 首先是正确性:多线程的 100 万项严格 FIFO 测试 (`tests/test_spsc_ring.cpp`)在 ThreadSanitizer(`-DSANITIZER=thread`)下通过, 因此 acquire/release 配对被*证明*没有竞态 —— 没有丢失、重复或 乱序 —— 而不仅仅是口头论证。 受限于环形队列的吞吐量(消费者不执行任何工作,因此游标是瓶颈), 5 次运行的中位数,生产者和消费者绑定在同一个 CCD 的两个核心上: ``` payload cursors on separate lines cursors packed on one line 64B record ~33 M ops/s ~44 M ops/s (packed ~+22%, steady) 8B word ~185 M ops/s ~205-245 M ops/s (packed faster, +8-26%) ``` 这些是在没有核心隔离的 WSL2 宿主机上测得的数据,因此数值在每次运行时 都会波动 —— 64B 的差异稳定在 +22% 左右,8B 的差异则在约 +8% 到 +26% 之间摇摆。在每次运行中都保持稳健的是**符号**:打包获胜。 (使用 `isolcpus` 的裸金属环境会使数值更稳定;但结论方向不会改变。) 令人意外的是:**将两个游标打包到一个 cache line 上会更快** —— 而 教科书上的规则是将它们*分开*填充以避免 false sharing。我对该环形队列为何 反转了这一规律的解释是:它每次迭代都会重新读取*两个*游标 —— 生产者检查 `head` 以查看队列是否已满,消费者检查 `tail` 以查看队列是否为空 —— 因此这些游标是*真正*共享的,而不是虚假共享。在一行 cache line 上,一次跨核 传输就能携带两个更新;而分成两行的话,两条 cache line 每次处理 项目时都会发生弹跳。“填充你的游标”的建议是假设存在生产级优化的 —— 每一方都 *缓存*了远端的游标,并且仅当其缓存显示已满/空时才重新读取它。 因此我构建了它(`CacheFarCursor` 变体),并测量它是否会改变 结果。它起到了**一半**的作用,这是一个诚实且更有趣的结局: ``` 64B record (median of 5, 4 separate runs) separate packed packed wins by naive ring (re-reads both cursors) ~33 M/s ~44 M/s ~23% (stable) cached ring (caches the far cursor) ~43 M/s ~47 M/s ~9% (stable) ``` 缓存远端游标**将吞吐量提高了约 30%**(分行的 64B,从约 ~33 提升至 43 M ops/s)—— 正如预测的那样,消除了大部分跨核游标读取。并且它**缩小了** 打包带来的领先优势,从约 ~23% 缩小到 ~9%,这正是 true-sharing 理论所预测的方向(更少的游标通信 → 游标放置位置的重要性降低)。 但它**没有翻转**正负号: 打包依然获胜。因此,即使应用了教科书上的优化,填充游标对于这种设计来说*依然*是错误的抉择 —— true-sharing 机制是故事的 一部分,而不是全部。(在此 WSL 宿主机上,8B-word 的运行噪声太大,无法给出具体差异值,但在每次运行中打包都获胜了。)我宁愿发布这些数据支持的结果,也不愿发布一个看似完美却缺乏数据支持的故事。 真实的 pipeline(消费者会验证每一条记录):生产者执行的是 64 字节的 `mmap` 拷贝,因此它的运行速度超过了验证器,环形队列的占用率约为 ~95%。端到端 吞吐量受限于验证速度(约 ~19 M rec/s),因此 enqueue→dequeue 的“延迟” 就是*队列滞留时间*(队列深度 × 消费时间 ≈ 50 µs),而不是环形队列的 同步成本 —— 基准测试会打印平均队列占用率,这样你就能明确知道系统处于哪种状态, 绝不是靠暗示。 热路径是无分配的,这一点是*被证明的*,而不是断言的: `tests/test_alloc_guard.cpp` 重载了全局 `operator new`,并要求在流式验证 10 万条记录的过程中 零堆分配。 该验证器能够捕捉非正数价格、倒置的 OHLC 价格带、超出范围的 VWAP、成交量/交易笔数不一致、单个 symbol 的 timestamp 倒退、 因消息丢失导致的序列号跳变、因其组成交易无法重建其 成交量/交易笔数/VWAP/OHLC 而异常的 K 线(即跨记录检查)、quote 异常 (交叉/锁定的订单簿,非正数或数量为零的买卖盘),以及单个 symbol 的 价格带异常点:偏离单个 symbol EWMA 参考值超过 5% 的交易或 quote 中值会被标记; 异常点会被排除在 EWMA 计算之外,因此一个糟糕的 tick 无法 改变后续记录的带宽。 ### 实时验证 同一个验证器现在可以**直接在实时的 Alpaca 行情流上运行**,而不仅仅是 二进制回放:`alpaca_ingest` 会适配每一个解析后的 trade、**quote** 和 bar 到 线路传输类型(`src/ingest/to_wire.h`),并在数据到达时进行验证,在行内打印 单个 symbol 的标记,并在退出时输出违规汇总。因此,“在真实数据上运行”现在意味着它能够*验证* 真实数据,而不仅仅是解析它。 它是作为一个工具运行的,而不仅仅是一个演示:symbols 是命令行参数 (`alpaca_ingest AAPL MSFT NVDA`;输入单个 symbol 会打印每一条记录,输入多个 symbols 则会进入静默模式,仅 显示违规 + 摘要),并且设置 `OHLCV_VIOLATIONS_LOG=path` 会将每一条被标记的记录作为结构化的 **JSONL** 行写入(`src/ingest/violation_log.h`) —— 对下游工具链(以及最终的特征流)来说是机器可读的。有两个 schema 的选择值得一提:它仅记录*被暴露出来的* 检查项(被抑制的检查项不会记录,这与实时的 `!!` 输出保持一致),并且 64 位纳秒级 timestamp 作为 JSON **字符串**发出,这样 `jq`/JS 中的 double 类型就不会悄无声息地将其截断。 设置 `OHLCV_CAPTURE=path` 会将验证过的完整数据流记录为**二进制 回放格式**(`src/replay/capture_writer.h`)—— 这与 `gen_dataset` 合成的 固定步长布局相同。这就形成了 README 开篇提到的闭环:基准测试 和验证器现在可以在*捕获的真实市场数据*上运行,而不仅仅是合成的 数据集,并且同一个文件也是最终 ML 训练集的原始素材。 (它存储了真实的 `ts_ns`,但却是合成的单个 symbol 的 `seq`,因此它是一个回放 产物,而不是忠实的原始行情归档;如果在正常关闭前发生强制终止, 会导致 header 计数未能被更新修补。) Quotes 包含了 trades 和 bars 无法表达的订单簿检查 —— **交叉** (bid > ask)和**锁定**(bid == ask)的订单簿,非正数或数量为零的买卖盘,以及 针对单个 symbol 的 EWMA 参考值的 **quote-mid 异常点** —— 并且它们到达的频率大约比 trades 高出一个数量级。它们增加的是**覆盖率,而不是噪声**: 这些检查现在运行在真实数据上,而这正是订单簿异常*会*显现的地方。它们 不会让数据流变得喧闹 —— IEX 是一个*单一交易场所*,一个撮合引擎 不会对自己发布交叉或锁定的订单簿(交叉/锁定实际上是一种 跨交易场所的 NBBO 现象),因此在干净的 IEX 数据上,quote 检查和其他检查一样保持 安静。真正的价值在于这些检查被执行了,而不是指望它会报警。 客观的注意事项,因为数据源的形态决定了哪些指标是有意义的: - **针对干净的供应商数据进行的正确性验证器理应保持安静。** Alpaca 不会发送负数价格、倒置的价格带或(单一场所的)交叉订单簿,因此 在健康的流中,数值检查基本都会通过;出现一个 `!!` 标记意味着确实存在 一个奇怪的 tick。安静是预期的结果 —— 而且这个捕获逻辑在 离线的畸形数据上已被*证明*是有效的(`tests/test_live_validation.cpp` 驱动真实的 Parser→adapt→Validator 链,不需要网络或密钥)。我尚未进行过 实盘运行(此处的声明均来自离线测试,而非观察到的真实 连线会话)。 - **重建和序列间隙检测在 IEX 样本中不适用。** IEX 仅占综合成交量的百分之几,因此我们的 trades 无法重建 Alpaca 的 全市场 bars;并且 JSON 不携带可用于比对的 per-feed 序列号( 实时路径分配的是一个单 symbol 的单调 `seq`,这使得间隙检测 在结构上处于无效状态,而不是虚假的干净状态)。 - **Timestamp 回归检查现在是正确的,但在实时模式下仍然被抑制 —— 原因更加具体了。** 验证器追踪的是一个 **per-stream 的 `last_ts`**(trade / quote / bar 各有一个),因此以前出现的跨流错误标记 —— 一个 quote 推进了 一个共享时钟,随后导致紧跟其后的 trade 触发报警 —— 的问题已经修复;单调性检查 是在它应该在的*单个流内部*进行的。(这使得该检查对于*任何*多流数据集都是正确的;当前的 二进制生成器恰好会发送跨类型单调的 timestamps,因此观察不到二进制路径上的变化 —— 它仍然像以前一样,每次运行标记 756 个注入的回归。 回报是下文提到的实时环境下的解除抑制。)该检查在*实时*报告中保持 抑制状态,剩下的原因是 它在真实投递中尚未得到验证:IEX 事件 timestamps 是通过 websocket 到达的,没有单调投递保证,并且在同一个流中出现相同 timestamp 或 亚微秒级乱序重排的 ticks 可能会标记出无害的回归。 解除抑制取决于一次真实测量(计算它在真实数据帧上实际触发的频率), 而不是更多的推理论证。 下一步:进行那次真实测量 —— 捕获真实数据帧并观察 within-stream timestamp 回归是否会在 解除抑制之前对无害的抖动产生报警。 **韧性。** 第一次真实的实盘运行暴露出了一个具体的缺陷:在空闲的行情流上,读取操作会 永久阻塞且无法被打断 —— Beast 客户端的默认设置是 没有空闲超时且没有 keep-alive pings,因此对于一个安静或被静默半开的对端, `read()` 会被无限期挂起。客户端现在设置了有限的 `idle_timeout` + keep-alive pings(死掉的对端会以超时的形式显现),并且在任何断开连接时,**会透明地进行重连** —— 重连 → 重新认证 → 重放存储的订阅,并带有 指数退避(`src/util/backoff.h`,其上限经过了单元测试;在此之上还添加了抖动)。验证循环原封不动: 它只是再次看到了重连后的 ack 帧。重连经过了 集成测试,而不仅仅是断言: `tests/test_reconnect.cpp` 启动了一个本地的 TLS websocket 服务器,在传输过程中断开 连接,并证明了*未经修改的*客户端可以重新建立连接 (重连 → 重新认证 → 重新订阅)并恢复 —— 不需要 Alpaca,也不需要处于交易时段 (该测试通过 `SSL_CERT_FILE` 信任服务器的自签名证书,因此客户端 不需要测试缝)。退避上限已单独进行了单元测试。 剩余的注意事项:立即中断一个*活跃但空闲的* feed 仍然需要 延迟进行的异步重构(keep-alive pings 会让活跃连接的 read 操作保持阻塞, 因此在 `read_frame` 中检查的 stop flag 永远无法触及 —— 已在实盘环境中确认)。这是一个 低严重性、非交易时段才会出现的烦恼;修复方法是使用 `asio::signal_set` 在收到信号时取消 正在进行的 read。 ## 构建 ``` cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build ./build/ohlcv_validator ``` ## 测试 ``` ctest --test-dir build --output-on-failure ``` ## 运行手册 请参阅 [`docs/runbook.md`](docs/runbook.md) 获取所有构建/运行/测试命令以及活动日志。 ## 平台说明 本项目在 Apple Silicon (M-series) 上开发,其用户空间周期计数器被虚拟化为 24 MHz(~41 ns)—— 适合测量吞吐量,但对于测量单条记录的延迟尾部来说太粗糙了。因此,延迟分布是在 Linux x86_64 宿主机上(Ryzen 9 7900X3D, WSL2)通过 `rdtscp` 读取不变的 TSC 来测量的。Mac 是开发环境;x86 主机是测量环境。
dedup · in-order · gap detect"] LB["multicast line B"] --> ARB GEN["gen_dataset / live capture"] --> BIN[("binary replay .bin
fixed 88B records")] P --> W["WireRecord"] ARB --> W BIN --> W W --> V["Validator
zero-alloc · ~6 ns/record"] ARB -. "gap" .-> BOOK["L2 order book
snapshot recovery"] V --> OUT["violations + live report"] BIN --> RING["lock-free SPSC ring"] BIN --> BENCH["replay_bench
168M/s · 1.1B/s · p50 20ns"] BIN --> ML["ml/ — features → robust-z → autoencoder
+ RL sandbox"] ``` ## 目标 在**热路径**上验证实时 OHLCV 市场数据 —— 零堆分配, 单核约 6 ns 处理一条记录 —— 而不是研究级的数据质量脚本。 我对它的要求是:每一个关于正确性的声明都有测试支撑,每一个 关于性能的声明都有可复现的实测结果,并且表述严谨诚实。 ## 为什么会有这个项目 大多数“市场数据验证器”都是使用 FastAPI 查询每日 K 线的服务。这只解决了另一个问题(用于研究的数据质量),而使用的技术栈并不能平移到低延迟工程面试中。这个项目有针对性地指向了 HFT 和做市商公司所招聘的*工程*技能:感知缓存的设计、无分配的热路径以及经过实测的吞吐量。 ## 工程决策 这些决策比功能列表更重要: - **在实测路径上使用二进制而非 JSON。** 项目包含了一个实时的 JSON websocket(Alpaca IEX)作为“在真实数据上运行”的演示 —— 但在 JSON 上测量从 socket 到验证的时间,测定的是解析器和网络,而不是市场数据处理。真实的交易所发送的是固定布局的二进制消息(ITCH/SBE 风格),因此基准测试直接通过 cast 从 `mmap` 的文件中读取固定步长的记录:没有解析,没有拷贝,没有分配。 - **诚实表述的吞吐量。** 单核下约 167 M 条记录/秒,从 1M 到 10¹⁰ 次验证保持稳定。这里刻意*没有*将其报告为延迟分布:单条记录的成本低于 Apple Silicon 约 41 ns 的时钟下限,因此在此环境下的 p50/p99 将会是计时器噪声。延迟分布本身是在 x86 上使用 `rdtscp` 测量的(见基准测试)—— 在 Ryzen 9 7900X3D 上 p50 为 20 ns / p99 为 30 ns。 - **声明是被证明的,而不是断言的。** “热路径上零分配”是一个测试(`tests/test_alloc_guard.cpp` 重载了全局 `operator new`,并在发生任何分配时失败),而不是 README 中的一句话。 - **使用容差进行重建,而不是 `==`。** 跨记录检查 —— 一根 K 线的组成交易能否重建其成交量、VWAP 和 OHLC?—— 使用了相对容差,因为真实的 feed 会将价格四舍五入到一个 tick,而精确相等会导致每一根实时 K 线上都出现假阳性。 ## 状态 `v0.1.0` —— 实时 JSON 摄入 + 在二进制 回放路径上测量的零分配验证器。 ``` line A ┐ exchange-style feed: line B ┴► FeedArbitrator ──► WireRecord ──► Validator A/B arbitration, in-order (seq'd packets, dedup, in-order release, release, gap detection redundant) gap detection (zero-alloc) ← "real feeds" path Alpaca IEX ──ws──► parse ──► [→ Wire] ──► Validator ──► live violation report ← runs on real ticks (validates, not just parses) binary file ──mmap──► WireRecord ──► Validator ──► throughput (fixed-layout POD, no parse, zero alloc ~167M rec/s ITCH/SBE-shaped) no copy after ctor) ← the measured path binary file ──► [decode thread] ──push──► SPSC ring ──pop──► [validate thread] (lock-free, ← pipeline cache-line aware) (x86) binary file ──► ml/ (Python) ──► features ──► robust-z baseline ──► scores (same .bin, NumPy reader per-symbol (rung 2) ← ML layer zero-copy) mirrors wire.h bar features ──► autoencoder ──► scores (offline) (rung 3, torch) ``` C++ 部分是快速数据平面;`ml/` 是一个离线的 Python 层,它从*相同*的 二进制文件中学习(没有第二次序列化路径)。它将交易和 K 线读取 到 NumPy 中,并运行一个 **model ladder** —— 一个稳健 z 分数的基线(第 2 阶)和 一个 autoencoder(第 3 阶),只有当某一阶击败其下一阶时,该阶梯才被证明是合理的。 该 autoencoder 被展示为可以捕捉到**规则不可见的**异常(一种 收益↔成交量 相关性断裂),而验证器和基线都漏掉了这一点 —— 这是一个机制 演示,而不是对实际生产性能的声明。一个单独的 **position-taking RL sandbox** (`ml/rl_env.py`)运行在相同的特征流上,并带有一个无前瞻证明 (作弊策略能获利;仅依赖 obs 的策略不能)。RL 侧并没有采用训练好的 agent, 而是通过一个 **walk-forward backtest** 进行了加固 —— 启发式规则仅 在具有 **transaction cost swept** 的样本外测试尾部进行评分(仅依赖 obs 的 优势净值为 ~0 并在约 1 bp 时消亡)—— 外加一个风险可调的 reward。异常检测阶梯和 回测的评估都是**无泄漏的**:一个按时间顺序的 fit→calibrate→test 划分(没有对时间序列进行随机打乱),并使用了**校准过的、数据驱动的 阈值**,而不是教科书上的 σ —— 而这种严谨性揭示了随机划分的 AUC (0.997) 是被前瞻偏差放大了(在诚实划分下约为 ~0.83–0.98)。请参阅 [`ml/README.md`](ml/README.md) 了解详细对比,以及为什么订单簿 深度学习文献 (Sirignano 2016) 不适用于此行情流。 ### Feed handler — A/B 仲裁 + 间隙检测 真实的交易所行情流并不是单一的 WebSocket:交易所通过 UDP multicast 冗余地在一个序列号化的二进制流上发布到两条线路(A 和 B), 并且接收方必须重建真实的流。`src/feed/` 就是承担此任务的 核心:`FeedArbitrator` (`feed/arbitrator.h`) 消费来自两条 线路的带序列号的 packet,并且**严格按顺序且恰好一次地交付每一个序列**, 优先选择先到达的那条线路,同时**检测真正的间隙**(在*两条*线路上 丢失的序列)。它把简单的 handler 容易搞砸的两件事做对了:它不会 仅仅因为乱序就声明出现间隙(它缓冲一个乱序窗口并释放 连续的前缀),并且其基于序列号的二次幂环形队列在每个槽位存储并验证 序列号,因此跳跃很远的消息无法与活跃消息产生别名冲突。packet 路径上零分配 (经过 alloc-guard 测试),payload 是相同的 `WireRecord`。这是 仲裁 + 间隙*检测*,而不是恢复 —— 检测到的间隙是真实系统 请求重传或重放快照的钩子;该间隙信号正是 订单簿构建器(见下文)所需要的,因为在通过快照 恢复之前订单簿是无效的,而交易流则可以容忍跳过。 **multicast 传输** 将其连接到真实的 socket(`feed/udp_multicast.h`): `feed_publisher` 就像一个“交易所”,将一个序列化的流冗余地发布到 两个 multicast 组(具有针对每条线路可配置的丢包率),而 `feed_handler` 加入 这两个组,单线程排空它们,进行仲裁,并验证重建后的 流。这一点已通过确定性的环回集成测试得到证明 (`tests/test_feed_multicast.cpp`,单条线路的丢包由另一条线路覆盖; 两条线路同时丢包就是那唯一的间隙)。该测试已经构建,但被排除在 CI 之外(multicast 在 runner 上无法保证)—— 在本地运行它。实时演示的*吞吐量*是 环境依赖的:在 macOS multicast 回环上的高速突发流量显示出 非确定性的 socket 级丢包(通过 `netstat -s -p udp` 确认:“dropped due to full socket buffers”),因此 publisher 进行了定速;在 Linux 上则是可靠的。 ### 订单簿 — 带有快照恢复的 L2 构建器 `src/book/` 根据序列化后的增量流构建一个聚合的 (L2) 限价订单簿 —— 这是真实策略(以及订单簿 ML 文献)操作的 结构,也是 OHLCV/top-of-quote 无法提供的东西。`OrderBook` 保留了买卖双方的最优价位 (零分配,最优在前);`BookBuilder` 是最关键的部分:一个 `Recovering ↔ Live` 状态机,使得订单簿定义中最艰难的 需求变得明确 —— **一次丢失的更新会使整个订单簿无效,直到一个快照 重建它**(交易流可以容忍跳过的序列;订单簿不能,因为 每一个 delta 都在改变后续 delta 所依赖的状态)。这就是上文 #1a 精确的间隙 检测发挥关键作用的地方。 巧妙且起关键作用的部分是**恢复竞态**:快照是异步生成的, 而此时 delta 仍在不断流入,因此它是“在 seq N 时准确的”,而你 可能已经缓冲了 N+1…N+5。正确的恢复会丢弃 seq ≤ N 的缓冲 delta (已经包含在快照中),并且只重放 seq > N 的部分 —— 重新应用一次 会导致重复计算,丢弃一个则会留下漏洞 —— 并且拒绝过旧的快照 因为它无法填补缺口,此时会等待更新的快照,而不是构建一个损坏的订单簿。 证明不仅仅是个标签:一个测试通过快照恢复重建了带有间隙的流, 并断言其结果与从头开始构建相同 delta 的结果**在字节上完全一致**。(这里是 L2;L3 的 per-order 是后续工作。构建订单簿是控制平面, 因此层级记账的复杂度是 O(levels),而不是纳秒级的热路径 —— 这是有意为之的。) ## 基准测试 解码+验证,单核,1M 条记录的数据集,200 轮(`replay_bench`), 在 Apple Silicon (M-series) 上。 ``` throughput: ~167 M records / sec mean: ~6 ns / record (allocation-free hot path) ``` (运行在每一笔交易上的每笔交易重建累加器,就是导致它从仅边界验证器约 2 ns/record 的基础上慢下来的原因; 它仍然是零分配的,只是现在做的是真实的工作。) 那个 M-series 的数据是**吞吐量**(按轮次批处理)—— 单条记录的成本低于 Apple Silicon 约 41 ns 的时钟下限,因此在那里无法测量单条记录的 *尾部*延迟。为此,基准测试在 x86 上运行。 ### 延迟分布 — x86 (`rdtscp`) Ryzen 9 7900X3D (4.4 GHz 不变 TSC),单核使用 `taskset` 绑定,每次 解码+验证使用一对 `rdtscp` 计时(计时器开销已测量并 扣除),5M 样本(`replay_bench_rdtsc`): ``` p50 20 ns · p99 30 ns · p99.9 40 ns · p99.99 ~200 ns · mean ~17 ns ``` 跨多次运行稳定至 p99.9。这是每个事件的、`lfence` 序列化的延迟 (point-in-time 指标)—— 高于上文提及的摊销吞吐量,因为 序列化破坏了流水线操作。 较远的尾部(p99.99 约 200 ns,最大值为数十微秒)是 **WSL2 虚拟化层造成的, 而不是正常任务的抢占** —— 而且我对此进行了测量,而不是猜测: 绑定核心并使用 `SCHED_FIFO` 实时调度(`chrt -f`)后,p99.99 和最大值 *保持不变*,因此提高调度优先级没有帮助。这个尾部是 Hyper-V 宿主机取消了虚拟机 vCPU 的调度。要消除它,需要在裸金属上有一个真正隔离的核心 (`isolcpus`/`nohz_full`),而这是 WSL2 无法完全提供的。验证器能 实际控制的核心分布(p50–p99.9)是非常紧凑的。 ### 多核扩展 — x86,24 线程 在 7900X3D 的 24 个线程上进行按 symbol 分片(`replay_bench_mt`): ``` 1c 93 M/s 2c 2.2× 4c 3.9× 8c 6.4× 10c 9.5× 14c (dip) 24c ~12× → ~1.1 B records/sec ``` 在约 10 个核心前接近线性扩展,随后由于内存带宽以及芯片的双 CCD / 3D V-cache 的不对称性,扩展性开始减弱(在 14 个线程处出现可复现的性能下降,因为工作负载溢出到了 两个 chiplet 上)。峰值 ≈ **1.1 billion records/sec**,单台机器 (已再次确认)。在未隔离的 WSL2 宿主机上,单核数据和加速比幅度在每次运行时会有所波动;但趋势形状(早期接近线性、出现 chiplet 性能坑、约 ~1 B 的峰值)是 稳定的。 ### Pipeline — 无锁 SPSC 环形队列 (x86) 一个单生产者/单消费者环形队列(`src/con/spsc_ring.h`)将解码 线程与验证线程解耦 —— 这是每个真实的 feed handler 都具备的 结构。在 IEX 的成交量下,一个线程足以应对;这不是核心负载。它在这里是为了使线程间 的交接变得可测量,并用数据而非传言来解决关于 cache-line 的问题。 首先是正确性:多线程的 100 万项严格 FIFO 测试 (`tests/test_spsc_ring.cpp`)在 ThreadSanitizer(`-DSANITIZER=thread`)下通过, 因此 acquire/release 配对被*证明*没有竞态 —— 没有丢失、重复或 乱序 —— 而不仅仅是口头论证。 受限于环形队列的吞吐量(消费者不执行任何工作,因此游标是瓶颈), 5 次运行的中位数,生产者和消费者绑定在同一个 CCD 的两个核心上: ``` payload cursors on separate lines cursors packed on one line 64B record ~33 M ops/s ~44 M ops/s (packed ~+22%, steady) 8B word ~185 M ops/s ~205-245 M ops/s (packed faster, +8-26%) ``` 这些是在没有核心隔离的 WSL2 宿主机上测得的数据,因此数值在每次运行时 都会波动 —— 64B 的差异稳定在 +22% 左右,8B 的差异则在约 +8% 到 +26% 之间摇摆。在每次运行中都保持稳健的是**符号**:打包获胜。 (使用 `isolcpus` 的裸金属环境会使数值更稳定;但结论方向不会改变。) 令人意外的是:**将两个游标打包到一个 cache line 上会更快** —— 而 教科书上的规则是将它们*分开*填充以避免 false sharing。我对该环形队列为何 反转了这一规律的解释是:它每次迭代都会重新读取*两个*游标 —— 生产者检查 `head` 以查看队列是否已满,消费者检查 `tail` 以查看队列是否为空 —— 因此这些游标是*真正*共享的,而不是虚假共享。在一行 cache line 上,一次跨核 传输就能携带两个更新;而分成两行的话,两条 cache line 每次处理 项目时都会发生弹跳。“填充你的游标”的建议是假设存在生产级优化的 —— 每一方都 *缓存*了远端的游标,并且仅当其缓存显示已满/空时才重新读取它。 因此我构建了它(`CacheFarCursor` 变体),并测量它是否会改变 结果。它起到了**一半**的作用,这是一个诚实且更有趣的结局: ``` 64B record (median of 5, 4 separate runs) separate packed packed wins by naive ring (re-reads both cursors) ~33 M/s ~44 M/s ~23% (stable) cached ring (caches the far cursor) ~43 M/s ~47 M/s ~9% (stable) ``` 缓存远端游标**将吞吐量提高了约 30%**(分行的 64B,从约 ~33 提升至 43 M ops/s)—— 正如预测的那样,消除了大部分跨核游标读取。并且它**缩小了** 打包带来的领先优势,从约 ~23% 缩小到 ~9%,这正是 true-sharing 理论所预测的方向(更少的游标通信 → 游标放置位置的重要性降低)。 但它**没有翻转**正负号: 打包依然获胜。因此,即使应用了教科书上的优化,填充游标对于这种设计来说*依然*是错误的抉择 —— true-sharing 机制是故事的 一部分,而不是全部。(在此 WSL 宿主机上,8B-word 的运行噪声太大,无法给出具体差异值,但在每次运行中打包都获胜了。)我宁愿发布这些数据支持的结果,也不愿发布一个看似完美却缺乏数据支持的故事。 真实的 pipeline(消费者会验证每一条记录):生产者执行的是 64 字节的 `mmap` 拷贝,因此它的运行速度超过了验证器,环形队列的占用率约为 ~95%。端到端 吞吐量受限于验证速度(约 ~19 M rec/s),因此 enqueue→dequeue 的“延迟” 就是*队列滞留时间*(队列深度 × 消费时间 ≈ 50 µs),而不是环形队列的 同步成本 —— 基准测试会打印平均队列占用率,这样你就能明确知道系统处于哪种状态, 绝不是靠暗示。 热路径是无分配的,这一点是*被证明的*,而不是断言的: `tests/test_alloc_guard.cpp` 重载了全局 `operator new`,并要求在流式验证 10 万条记录的过程中 零堆分配。 该验证器能够捕捉非正数价格、倒置的 OHLC 价格带、超出范围的 VWAP、成交量/交易笔数不一致、单个 symbol 的 timestamp 倒退、 因消息丢失导致的序列号跳变、因其组成交易无法重建其 成交量/交易笔数/VWAP/OHLC 而异常的 K 线(即跨记录检查)、quote 异常 (交叉/锁定的订单簿,非正数或数量为零的买卖盘),以及单个 symbol 的 价格带异常点:偏离单个 symbol EWMA 参考值超过 5% 的交易或 quote 中值会被标记; 异常点会被排除在 EWMA 计算之外,因此一个糟糕的 tick 无法 改变后续记录的带宽。 ### 实时验证 同一个验证器现在可以**直接在实时的 Alpaca 行情流上运行**,而不仅仅是 二进制回放:`alpaca_ingest` 会适配每一个解析后的 trade、**quote** 和 bar 到 线路传输类型(`src/ingest/to_wire.h`),并在数据到达时进行验证,在行内打印 单个 symbol 的标记,并在退出时输出违规汇总。因此,“在真实数据上运行”现在意味着它能够*验证* 真实数据,而不仅仅是解析它。 它是作为一个工具运行的,而不仅仅是一个演示:symbols 是命令行参数 (`alpaca_ingest AAPL MSFT NVDA`;输入单个 symbol 会打印每一条记录,输入多个 symbols 则会进入静默模式,仅 显示违规 + 摘要),并且设置 `OHLCV_VIOLATIONS_LOG=path` 会将每一条被标记的记录作为结构化的 **JSONL** 行写入(`src/ingest/violation_log.h`) —— 对下游工具链(以及最终的特征流)来说是机器可读的。有两个 schema 的选择值得一提:它仅记录*被暴露出来的* 检查项(被抑制的检查项不会记录,这与实时的 `!!` 输出保持一致),并且 64 位纳秒级 timestamp 作为 JSON **字符串**发出,这样 `jq`/JS 中的 double 类型就不会悄无声息地将其截断。 设置 `OHLCV_CAPTURE=path` 会将验证过的完整数据流记录为**二进制 回放格式**(`src/replay/capture_writer.h`)—— 这与 `gen_dataset` 合成的 固定步长布局相同。这就形成了 README 开篇提到的闭环:基准测试 和验证器现在可以在*捕获的真实市场数据*上运行,而不仅仅是合成的 数据集,并且同一个文件也是最终 ML 训练集的原始素材。 (它存储了真实的 `ts_ns`,但却是合成的单个 symbol 的 `seq`,因此它是一个回放 产物,而不是忠实的原始行情归档;如果在正常关闭前发生强制终止, 会导致 header 计数未能被更新修补。) Quotes 包含了 trades 和 bars 无法表达的订单簿检查 —— **交叉** (bid > ask)和**锁定**(bid == ask)的订单簿,非正数或数量为零的买卖盘,以及 针对单个 symbol 的 EWMA 参考值的 **quote-mid 异常点** —— 并且它们到达的频率大约比 trades 高出一个数量级。它们增加的是**覆盖率,而不是噪声**: 这些检查现在运行在真实数据上,而这正是订单簿异常*会*显现的地方。它们 不会让数据流变得喧闹 —— IEX 是一个*单一交易场所*,一个撮合引擎 不会对自己发布交叉或锁定的订单簿(交叉/锁定实际上是一种 跨交易场所的 NBBO 现象),因此在干净的 IEX 数据上,quote 检查和其他检查一样保持 安静。真正的价值在于这些检查被执行了,而不是指望它会报警。 客观的注意事项,因为数据源的形态决定了哪些指标是有意义的: - **针对干净的供应商数据进行的正确性验证器理应保持安静。** Alpaca 不会发送负数价格、倒置的价格带或(单一场所的)交叉订单簿,因此 在健康的流中,数值检查基本都会通过;出现一个 `!!` 标记意味着确实存在 一个奇怪的 tick。安静是预期的结果 —— 而且这个捕获逻辑在 离线的畸形数据上已被*证明*是有效的(`tests/test_live_validation.cpp` 驱动真实的 Parser→adapt→Validator 链,不需要网络或密钥)。我尚未进行过 实盘运行(此处的声明均来自离线测试,而非观察到的真实 连线会话)。 - **重建和序列间隙检测在 IEX 样本中不适用。** IEX 仅占综合成交量的百分之几,因此我们的 trades 无法重建 Alpaca 的 全市场 bars;并且 JSON 不携带可用于比对的 per-feed 序列号( 实时路径分配的是一个单 symbol 的单调 `seq`,这使得间隙检测 在结构上处于无效状态,而不是虚假的干净状态)。 - **Timestamp 回归检查现在是正确的,但在实时模式下仍然被抑制 —— 原因更加具体了。** 验证器追踪的是一个 **per-stream 的 `last_ts`**(trade / quote / bar 各有一个),因此以前出现的跨流错误标记 —— 一个 quote 推进了 一个共享时钟,随后导致紧跟其后的 trade 触发报警 —— 的问题已经修复;单调性检查 是在它应该在的*单个流内部*进行的。(这使得该检查对于*任何*多流数据集都是正确的;当前的 二进制生成器恰好会发送跨类型单调的 timestamps,因此观察不到二进制路径上的变化 —— 它仍然像以前一样,每次运行标记 756 个注入的回归。 回报是下文提到的实时环境下的解除抑制。)该检查在*实时*报告中保持 抑制状态,剩下的原因是 它在真实投递中尚未得到验证:IEX 事件 timestamps 是通过 websocket 到达的,没有单调投递保证,并且在同一个流中出现相同 timestamp 或 亚微秒级乱序重排的 ticks 可能会标记出无害的回归。 解除抑制取决于一次真实测量(计算它在真实数据帧上实际触发的频率), 而不是更多的推理论证。 下一步:进行那次真实测量 —— 捕获真实数据帧并观察 within-stream timestamp 回归是否会在 解除抑制之前对无害的抖动产生报警。 **韧性。** 第一次真实的实盘运行暴露出了一个具体的缺陷:在空闲的行情流上,读取操作会 永久阻塞且无法被打断 —— Beast 客户端的默认设置是 没有空闲超时且没有 keep-alive pings,因此对于一个安静或被静默半开的对端, `read()` 会被无限期挂起。客户端现在设置了有限的 `idle_timeout` + keep-alive pings(死掉的对端会以超时的形式显现),并且在任何断开连接时,**会透明地进行重连** —— 重连 → 重新认证 → 重放存储的订阅,并带有 指数退避(`src/util/backoff.h`,其上限经过了单元测试;在此之上还添加了抖动)。验证循环原封不动: 它只是再次看到了重连后的 ack 帧。重连经过了 集成测试,而不仅仅是断言: `tests/test_reconnect.cpp` 启动了一个本地的 TLS websocket 服务器,在传输过程中断开 连接,并证明了*未经修改的*客户端可以重新建立连接 (重连 → 重新认证 → 重新订阅)并恢复 —— 不需要 Alpaca,也不需要处于交易时段 (该测试通过 `SSL_CERT_FILE` 信任服务器的自签名证书,因此客户端 不需要测试缝)。退避上限已单独进行了单元测试。 剩余的注意事项:立即中断一个*活跃但空闲的* feed 仍然需要 延迟进行的异步重构(keep-alive pings 会让活跃连接的 read 操作保持阻塞, 因此在 `read_frame` 中检查的 stop flag 永远无法触及 —— 已在实盘环境中确认)。这是一个 低严重性、非交易时段才会出现的烦恼;修复方法是使用 `asio::signal_set` 在收到信号时取消 正在进行的 read。 ## 构建 ``` cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build ./build/ohlcv_validator ``` ## 测试 ``` ctest --test-dir build --output-on-failure ``` ## 运行手册 请参阅 [`docs/runbook.md`](docs/runbook.md) 获取所有构建/运行/测试命令以及活动日志。 ## 平台说明 本项目在 Apple Silicon (M-series) 上开发,其用户空间周期计数器被虚拟化为 24 MHz(~41 ns)—— 适合测量吞吐量,但对于测量单条记录的延迟尾部来说太粗糙了。因此,延迟分布是在 Linux x86_64 宿主机上(Ryzen 9 7900X3D, WSL2)通过 `rdtscp` 读取不变的 TSC 来测量的。Mac 是开发环境;x86 主机是测量环境。
标签:Bash脚本, C++20, 低延迟, 凭据扫描, 数据校验, 组播, 行情数据处理, 订单簿, 逆向工具, 高频交易