第一次认真读高频交易的岗位描述,是在一个很普通的下午。满屏的 C++17、lock-free、kernel bypass,我关掉页面去泡了杯茶。那时候我用 Python 做数据处理,自认为还算熟练,但那份 JD 让我产生了一个很具体的怀疑:手上的工具是不是只能做原型。
这个怀疑持续了很久。后来我慢慢意识到,它之所以难回答,是因为问题本身把语言当成了一个有绝对高低的东西。语言没有绝对的能力上限,只有和场景的匹配度。把 Python 放到纳秒级的下单链路上,它当然是玩具;把 C++ 放到需要一天改八次的回测脚本里,它同样难用。
真正值得讨论的不是哪门语言更强,而是这段代码跑在哪个时间尺度上。
两套时钟
如果只看代码行数,量化团队里大部分逻辑确实是 Python 写的。策略想法、数据清洗、回测、参数搜索,都在这一侧。这一侧的核心指标不是延迟,是从一个想法到看见回测曲线要多久。
另一侧是执行。订单从策略发出到交易所,中间要经过风控、网关、序列化、网卡。这一侧的核心指标是确定性和尾部延迟,平均值意义不大。一次偶发的几百微秒抖动,可能就吃掉一整天的价差。
两套时钟对「正确」的定义也不一样。研究阶段的正确是结果可复现、改起来快;执行阶段的正确是同样的输入,每次运行的耗时都落在同一个窄区间里。前者的敌人是写得太绕,后者的敌人是不确定性。
研究代码可以慢,但不能难改;执行代码可以难写,但不能不确定。
这解释了为什么同一个人在两边的面试里会得到完全不同的评价。一个把回测框架写得很好用的人,可能完全说不出内存分配在关键路径上的代价;一个能手写无锁队列的人,可能写出来的研究代码三个月后自己都读不懂。两件事考的是不同的能力,只是恰好都叫「会写代码」。
所以投错赛道比技术不够更致命。用研究员的准备去面核心工程岗,会被追问指针和缓存行;用底层优化的思路去面研究岗,会被认为不关心迭代速度。这不是水平问题,是坐标问题。
我也不太认同「先用 Python 写,再翻译成 C++」这条线性流程。它听起来合理,实际做起来会遇到一个老问题:两份代码会分叉。研究侧改了一个边界条件,执行侧没跟上,回测和实盘的差异就变成了一场没有答案的排查。
我见过的处理方式大概两类。一类是把核心计算收敛到一个库里,Python 和执行侧都调它,绑定层只做类型转换;另一类是接受分叉,但用同一份历史数据卡住两边的输出,每次改动都跑一遍对账。前者干净,代价是核心库的接口要设计得很克制,不能为了研究方便随便加参数;后者灵活,代价是多了道工序,而且这道工序很容易在赶进度时被跳过。
这两种我都不觉得完美。第一种在策略探索阶段会拖慢速度,第二种在团队换人之后会慢慢失效。选哪个更多取决于团队规模,而不是技术偏好。
有一个很朴素的判断标准:如果一天要改十次参数,任何需要编译的语言都会变成负担;如果一天只发一次信号,编译那几十秒完全无所谓。频率决定了工具,不是反过来。
这条标准也有失效的时候。参数搜索是研究动作,但它可能要跑好几个小时,那部分工作其实更适合扔到编译型语言的批处理里。所以边界不是按人分的,是按任务的时长和调用频率分的,同一个人一天里可能在三层之间来回。
Python 的难点不在语法
面试里问 Python,很少问语法。问的是对运行时和内存的理解,因为这些东西决定了代码在生产里的行为。
- 第一次写回测,我用了逐行遍历,跑了一晚上没出结果。换成向量化之后几分钟就跑完。这件事的教训不是「别用循环」,而是要知道解释器在每一行上做了什么。
- GIL 的问题类似。多线程对 CPU 密集任务没有帮助,这件事很多人知道;但能说清楚为什么 NumPy 在底层可以释放 GIL、而多进程又要付序列化代价的人不多。
- 视图和副本是另一个坑。链式索引拿到的可能是副本,内存突然翻倍;那个常见的警告其实是同一个问题换了个出口。
- 百万级的小对象可以考虑限制实例属性,省掉每个对象背后的字典。这类优化谈不上优雅,但内存吃紧的时候很实在。
这些点的共同处是:它们都不属于「会不会写」,而属于「知不知道代价在哪」。
向量化也有代价,只是不那么直观。一次广播操作可能在内存里复制出几倍大的临时数组,数据量到一定规模就撞上内存墙,最后靠分块处理绕过去。所以「不要写循环」这句话只说了一半,另一半是「注意中间结果」。
我更看重后面这一类的回答。一个人能背出 GIL 的定义,和能解释为什么某段代码在多核机器上仍然只跑满一个核,是两个层次。前者可以在面试前一天准备好,后者通常来自被坑过的经历。
所以如果让我评估一个 Python 背景的候选人,我会更关心他怎么描述自己踩过的坑,而不是他能列举多少特性。描述里有没有代价,有没有「当时我以为是这样,后来发现不是」,这些细节很难伪装。
边界画在哪里
系统设计层面,Python 和执行侧之间通常有三种连接方式,代价依次递减,复杂度依次递增。
绑定是最直接的一种。C++ 用 pybind11,Rust 用 PyO3,研究员在 notebook 里就能调到底层的订单簿实现。好处是回测和实盘共用一套逻辑;坏处是 Python 的垃圾回收和 GIL 会给延迟带来抖动,所以它更适合回测和非关键路径。这个「非关键」的界线要提前划清楚,否则很容易一步步把重活挪进绑定层。
共享内存加环形缓冲是另一种。信号生成和执行拆成两个进程,中间用一块共享内存传结构体。好处是隔离:研究进程崩了,执行网关还在,还能撤单。代价是两边要约定内存布局,字段一改就得同步改两边,而且这块布局一旦上线就很难动。
隔离带来的其实不只是容错,还有一个可以吹哨的位置。策略进程出问题的时候,网关可以独立执行全撤,不需要等上层恢复。这个动作必须在设计阶段就留好,事后加不进去。
序列化上,定长的二进制编码比通用协议省掉一层解析,代价是 schema 变动的自由度。这个取舍在字段稳定的时候很划算,在协议还在频繁调整的阶段就很折磨。我吃过一次亏:为了让结构更整齐,把一个可选字段改成定长,结果后面每次加字段都要改两边的解析代码。
还有一种更省事的做法,就是干脆不让两侧直接对话,用文件或者数据库做交换。延迟很差,但排查问题极其容易。低频策略里这个选择经常被低估,大家更愿意讨论架构,不愿意承认自己一周才跑一次信号。
跨边界的监控也是容易被省略的部分。两侧各自有日志,但真正有用的是端到端的那一条:信号在哪一刻生成,订单在哪一刻发出,中间的差值是排队、计算还是网络。没有这条链路,所有性能讨论都是猜的。
对账是这套结构里最不性感的部分,也是最不能省的。回测和实盘用同一份行情跑一遍,逐笔比对成交价和成交时间,差异超过阈值就停下来查。这件事听起来像测试,其实是模型的一部分——没有它,前面所有的架构选择都无法验证。
Rust 的位置
我对 Rust 的判断比较简单:它适合逻辑相对独立的新模块。风控网关、行情解析这类东西,边界清楚,编译期能挡掉一类问题,工具链也干净。新写的模块没有历史包袱,用它的成本最低。
但存量系统是另一回事。几百万行 C++,加上只提供 C 接口的厂商 SDK,重写的收益很难算得过来。Rust 的 FFI 能做,可是跨语言边界本身就是新的故障面,出问题的时候两边都不好查。而且这类系统通常有一批只熟悉原有工具链的人,迁移的组织成本往往比技术成本更高。
还有一个经常被忽略的因素是人。招一个愿意写 Rust 的人容易,招一个既懂低延迟又愿意长期维护这套代码的人不容易。语言的选择会作用于招聘漏斗,这件事在立项时很少被算进去。
学习成本也要算。所有权和生命周期在写并发代码时是优势,在写业务胶水时是负担。一个只做行情解析的服务,收益很明显;一个要频繁改字段的服务,光是处理借用就会耗掉不少时间。
所以「Rust 会不会取代 C++」这个问题,我暂时没有答案。我能确定的是,在一个已经有十年积累的执行链路上,语言的新旧不是决策变量,迁移的风险才是。
至于「用 Rust 写策略」这种说法,我觉得多半是把两件事混在了一起。策略的研究效率和执行的确定性没有关系,用什么语言写研究代码,取决于团队里谁会维护它。
如果一定要给一个倾向:新模块用 Rust,老链路上继续用 C++,中间用一层薄薄的 FFI 隔开,谁也不要试图说服谁。这个方案不好看,但它能活很久。
回到最初那个问题。我现在不太愿意回答「Python 是不是玩具」,因为它把讨论引向了语言之争,而语言之争没有输赢。
更实际的问法是两个数字:这条链路上一次错误的代价是多少,一次修改的代价是多少。前者决定要不要上 C++,后者决定能不能不上。两个数字都很小的时候,选什么语言都行;两个数字都很大的时候,任何选择都是妥协。
这两个数字会随业务变化。延迟要求往下压的时候,我的判断也会跟着变。所以上面这些不算结论,只是我目前用来做取舍的一套依据。
© Lu’s Blog · 版权所有
评论