简介:AutoTrader 恒生自动交易系统是一套面向量化交易与程序化交易场景的开源代码工程,主要服务证券、期货领域需要对接恒生接口的开发者、金融IT人员及对自动交易程序感兴趣的研究者。资源包共含84个文件,主体为C++源码:32个.h头文件负责类与接口声明,22个.cpp文件实现交易业务流程与策略逻辑;同时附带VS工程文件便于直接加载编译,SQL脚本用于初始化相关数据表,动态库与界面资源文件则为运行提供必要依赖与展示素材,压缩包整体约69.94MB。目前已有1826人次浏览或学习,具有一定参考热度。通过阅读这套工程,读者可以梳理自动交易系统的模块边界、数据流转和接口适配模式,理解恒生相关业务在C++项目中的落地方式;开源授权也便于后续二次开发、模块替换或构建自有量化交易框架,适合作为金融IT方向中高级开发者的实践参考。
1. 恒生自动交易系统到底在自动化什么:一张委托单的完整闭环
每天开盘前,我都会跟团队确认同一件事:恒生自动交易系统今天要完成的,不只是把策略信号变成一条报单指令,而是把「信号受理 → 风控校验 → 柜台报单 → 回报拼接 → 状态更新 → 异常重试 → 盘后对账」这条链路完整跑通。很多团队第一次上自动交易时,以为核心工作是写下单接口,结果上线后发现真正难的是回报处理和状态机——柜台返回的一笔成交回报,先后顺序可能乱,可能重复,甚至可能在你断线重连期间悄悄成交了。这篇文章面向正在对接恒生柜台的量化团队、私募IT和自营开发,把做一套能扛住实盘的 AutoTrader 需要想清楚的事讲透:架构怎么拆、状态机怎么建模、哪些参数必须调、上线前哪些环节最容易翻车。
2. 架构先行:恒生柜台对接方式与四个模块的职责边界
写自动交易最忌讳的,是把策略逻辑、下单逻辑、风控逻辑全塞进同一个回调函数里。早期我见过不少半自动脚本,行情回调里直接调报单接口,风控判断散落在各处,最后改一个参数都要全局排查。我的习惯是先把系统拆成四个模块:策略引擎、执行器、风控、监控。策略引擎只负责在行情或定时任务触发时输出目标仓位和订单意图,不碰柜台;执行器把订单意图转成具体报单,并维护本地订单状态;风控在订单进柜台之前做价格偏离、持仓上限、频率限制等检查;监控模块负责延迟统计、异常告警、回报日志落盘。拆清楚之后,每一块的错误都只在块内消化,不会互相污染。
2.1 对接恒生柜台的两种常见方式:进程内直连与服务化中间件
对接恒生柜台,常见做法有两种。第一种是进程内直连:在策略进程里直接调用柜台 API(通常是 C++ 动态库,也常见通过 Java 或 Python 封装调用),报单延迟最低,部署最简单,适合单策略、单账户的团队。缺点是柜台升级、接口字段变更时,策略进程要跟着重启,而且多策略共用账户时权限和频率控制容易乱。第二种是服务化中间件:把报单、查单、撤单封装成一个独立的交易服务,策略层通过内部队列或 HTTP 接口向它发订单意图,交易服务统一持有柜台会话。延迟会比直连高几毫秒,但换来的是多策略复用、账户权限隔离、以及柜台重连逻辑只在一个进程里维护。我的建议是:账户少、策略少,先直连;账户多、策略多,或者要接多个柜台,直接上中间件,后续扩展不用推翻重来。
| 对比项 | 进程内直连 | 服务化中间件 |
|---|---|---|
| 报单延迟 | 最低 | 增加进程间传输与排队 |
| 部署复杂度 | 低,单进程 | 高,需要守护与降级方案 |
| 多策略复用 | 需自行加锁隔离 | 天然隔离,按账户分发 |
| 柜台断线重连 | 每个策略进程各管一次 | 集中管理,恢复逻辑可控 |
| 适用阶段 | 单账户小团队 | 多账户、多策略、正式产品化 |
2.2 模块职责划分:数据和错误处理到底归谁管
四模块的边界,最容易模糊的是「错误处理归谁」。我定的规矩是:策略引擎不感知柜台错误码,它只拿到一个结果——订单被受理、订单被拒绝、或状态未知;执行器负责把柜台的错误码映射成本地状态,并决定是否触发查单或撤单;风控只做前置判断,不做报错兜底;监控模块只读所有模块的状态,不反向修改。这样一来,报单被拒时,策略层不会看到一堆底层错误码,执行器内部已经把「废单」状态写进本地表,并决定是否通知策略重新决策。数据归属同理:行情数据归策略引擎消费,订单数据归执行器持有,风控快照归风控缓存,三者之间用明确的接口传递,不共用可变全局变量。
2.3 最小可运行的骨架代码:一个带状态表的订单管理器
下面是一个极简但结构完整的订单管理器骨架,覆盖了报单、回报更新、状态迁移的核心逻辑。实际生产里,我会在此基础上加持久化和重连同步,但骨架就是长这样的。
from dataclasses import dataclass from enum import Enum import time, itertools class OrderStatus(str, Enum): NEW = "已报" PARTIAL_FILLED = "部成" FILLED = "全成" PARTIAL_CANCELED = "部撤" CANCELED = "已撤" REJECTED = "废单" @dataclass class Order: client_order_id: str # 本地唯一键,用于幂等 symbol: str side: str # BUY / SELL price: float quantity: int status: OrderStatus = OrderStatus.NEW filled_quantity: int = 0 last_report_seq: int = 0 # 用于回报去重的序列号 class OrderManager: def __init__(self, gateway): self.gateway = gateway self.orders: dict[str, Order] = {} self._seq = itertools.count(1) gateway.on_order_report = self._handle_report def submit(self, symbol: str, side: str, price: float, quantity: int) -> str: client_order_id = f"{int(time.time() * 1000)}-{next(self._seq)}" self.orders[client_order_id] = Order( client_order_id=client_order_id, symbol=symbol, side=side, price=price, quantity=quantity ) self.gateway.send_order(client_order_id, symbol, side, price, quantity) return client_order_id def _handle_report(self, report): order = self.orders.get(report.client_order_id) if not order: # 柜台返回了本地不存在的单,说明状态同步丢失,必须全量查单 self.gateway.query_order(report.client_order_id) return if report.seq <= order.last_report_seq: return # 重复回报,直接丢弃 order.last_report_seq = report.seq # 只有合法的状态迁移才更新,详见下一章 self._apply_transition(order, report)这段代码里最重要的是两个设计:client_order_id由本地生成,保证每一笔订单在全链路有唯一业务键,柜台回报时靠它关联回本地订单;last_report_seq记录最近一次已处理的回报序号,这是对付重复回报的第一道防线。submit方法返回订单号后,策略层不再关心后续,所有状态变化都由_handle_report驱动,这比策略层轮询查单要干净得多。
3. 订单状态机与回报处理:自动交易比手动下单难在哪
手动交易时,交易员眼睛盯着终端,回报乱一点、慢一点都能容忍,人可以自行脑补订单的真实状态。自动交易不行,程序必须对每一笔订单的状态有唯一、确定的判断,否则撤单、重试、对账全都失去依据。恒生柜台的订单从受理到结束,状态大体会经历「已报 → 部分成交 → 全部成交 / 部分撤单 / 已撤 / 废单」这条路径。难点在于:回报事件是异步到达的,同一笔订单的多个回报可能乱序,可能重复,可能在你重连期间丢失。所以状态机不只是画一张流转图,而是要定义清楚「哪些迁移合法、哪些迁移是非法的、非法时程序该做什么」。
3.1 恒生柜台的委托状态与本地状态映射
本地状态不能照抄柜台的每一个中间标志,否则策略层要理解柜台的业务语义,耦合太深。我一般把本地状态收敛成六种:已报、部成、全成、部撤、已撤、废单。其中「已报」代表订单已受理但尚无成交;「部成」代表部分数量已成交,剩余仍在市场;「全成」是终态;「部撤」是部分成交后剩余部分被撤掉;「已撤」是未成交且已撤销;「废单」是报单被柜台拒绝,例如价格超出涨跌停幅度、持仓不足、非交易时段。
| 本地状态 | 触发条件 | 是否终态 |
|---|---|---|
| 已报(NEW) | 报单被柜台受理 | 否 |
| 部成(PARTIAL_FILLED) | 成交回报数量 < 委托数量 | 否 |
| 全成(FILLED) | 累计成交数量 == 委托数量 | 是 |
| 部撤(PARTIAL_CANCELED) | 部分成交后撤单成功 | 是 |
| 已撤(CANCELED) | 无成交,撤单成功 | 是 |
| 废单(REJECTED) | 柜台拒绝报单 | 是 |
这里我特别想强调一点:「已报」不等于订单一定在市场上。柜台可能先回报受理,随后立刻回报废单,或者柜台内部排队超时后静默丢弃。所以本地状态机必须支持从「已报」到「废单」的迁移;反过来,任何终态都不允许再迁移成非终态。实际开发中我会把状态迁移表写成一个显式的映射,而不是在回调里写一堆 if,避免状态组合爆炸。
VALID_TRANSITIONS = { OrderStatus.NEW: { OrderStatus.NEW, # 重复回报或状态刷新 OrderStatus.PARTIAL_FILLED, OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.REJECTED, }, OrderStatus.PARTIAL_FILLED: { OrderStatus.PARTIAL_FILLED, # 新的部分成交 OrderStatus.FILLED, OrderStatus.PARTIAL_CANCELED, OrderStatus.CANCELED, OrderStatus.REJECTED, # 部分成交后剩余被拒,少见但存在 }, # 终态 FILLED / CANCELED / REJECTED / PARTIAL_CANCELED 不允许再迁移 } def _apply_transition(self, order, report): target = self._to_local_status(report) if order.status in (OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.REJECTED, OrderStatus.PARTIAL_CANCELED): # 理论上的非法迁移,记录日志并告警,不更新状态 log_and_alert(f"非法状态迁移: {order.client_order_id} {order.status} -> {target}") return if target not in VALID_TRANSITIONS.get(order.status, set()): log_and_alert(f"未定义迁移: {order.client_order_id} {order.status} -> {target}") return order.status = target if report.filled_quantity is not None: order.filled_quantity = report.filled_quantity这段代码把迁移规则集中在一张表里,回调里只做查表和告警,逻辑一目了然。注意最后一行:成交回报里的累计成交量,要以柜台回报的累计值为准,不能用"本次回报成交量"自己累加,因为回报乱序时,自己累加会把最终总量算错。
3.2 回报乱序、重复包与幂等处理
乱序是自动交易最隐蔽的坑。场景是这样:一笔订单先成交了 100 股,又成交了 50 股,但柜台先推送了「累计成交 150 股」的回报,再推送「累计成交 100 股」的回报。如果程序在每次回报时都用filled_quantity += report.this_time累加,就会把总量算成 250 股。解决方法是只在回报里取累计成交量,并且配合回报序号做去重。去重不能只靠累计成交量判断,因为「累计成交 100 股」可能因为网络重传出现两次,所以要用柜台回报自带的序号或时间戳作为幂等键。
幂等处理的关键设计是:处理成功后才记录last_report_seq,处理中崩溃则不记录,下次重放时还要能覆盖。常见做法是本地落一条report_log表,字段包括client_order_id、report_seq、report_body、handled_flag。回报来了先查表,已处理就跳过,未处理才执行状态迁移,然后更新handled_flag。这样即使进程在迁移后崩溃,重启后也能从磁盘恢复幂等判断,不会重复统计成交。
3.3 查单与撤单的重试语义:撤不掉不等于没撤
撤单是自动交易里最容易被低估的操作。很多新手写撤单就是发一条撤单指令,然后等回报;如果撤单回报迟迟不来,就反复重发。结果是:柜台收到重复撤单指令,第二次撤单报错,系统以为撤单失败,实际上第一次已经撤成功了。我处理撤单的原则是:撤单指令发出后,只发一次;超时后去查单,根据查单结果决定状态,而不是盲目重发撤单。
def cancel(self, client_order_id: str) -> None: order = self.orders[client_order_id] if order.status in (OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.REJECTED, OrderStatus.PARTIAL_CANCELED): return # 终态单不允许撤 self.gateway.send_cancel(client_order_id) # 启动一个超时任务,默认 5 秒后查单 self._schedule_query(client_order_id, delay=5.0) def _on_query_result(self, order, query_result): # 撤单超时后,以查单结果更新本地状态 if query_result.status == OrderStatus.FILLED: order.status = OrderStatus.FILLED # 其实已经全成了 elif query_result.status == OrderStatus.CANCELED: order.status = OrderStatus.CANCELED else: # 还在市场或部成,再等一个周期 self._schedule_query(order.client_order_id, delay=2.0)这里的核心是:撤单失败不重发撤单,而是转查单。查单本身也要有最大重试次数,我一般设 3 次,超过后把订单状态置为「状态未知」,人工介入。宁可把订单标记为未知让人看,也不能让程序自作主张重发指令,这是自动交易避免本金损失的一条底线。
4. 延迟与参数:把恒生自动交易跑稳的五个必调项
系统跑通了,接下来就是性能和稳定性。恒生柜台本身是一套成熟的低延迟柜台,但 AutoTrader 这端的参数没调好,再快的柜台也救不了你。这一章不是讲如何把延迟压到微秒级——那需要专门的低延迟硬件和内核调优,而是讲普通服务器环境下,五个直接影响下单链路稳定性的参数怎么设、为什么这么设。
4.1 请求串行与批量下单的取舍
很多新手把报单实现成多线程并发往柜台发,以为并发越高效率越高。实际踩下来,大部分柜台 API 的内部会话是有序的,高频并发报单反而会触发柜台端排队或限流。我一般做两级设计:报单请求先进入一个内存队列,执行器侧用单线程消费,保证应用层到柜台的报单是串行的;风控检查放在入队之前并发执行,风控不过的直接丢弃。这样既保证了柜台侧的有序性,又不会因为串行让风控变成瓶颈。只有确定柜台会话支持并发报单,并且策略类型是拆单/算法单时,我才会开一个受控的并发上报,且并发数控制在 2~4 之间,绝不盲目加线程。
4.2 五个必调参数:超时、重连、队列深度、限流与行情节流
| 参数 | 常见默认值 | 调整依据 | 踩过的坑 |
|---|---|---|---|
| 报单响应超时 | 3 秒 | 生产环境成交变快,超时报废单;太短则误判 | 设成 1 秒后,正常报单被本地误判超时,触发多余查单 |
| 断线重连次数 | 无限 | 交易时段内必须持续重连;非时段要退避 | 午休时段无限重连,把柜台会话塞满 |
| 回报队列深度 | 1 万条 | 高峰行情下,来不及处理的回报先积压 | 队列满了直接丢回报,本地状态和柜台全对不上 |
| 报单令牌桶速率 | 5 笔/秒 | 拆单策略会瞬时大量报单 | 桶太小把正常拆单也限了,桶太大触发柜台限流 |
| 行情快照刷新 | 100 毫秒 | 用于风控的现价快照,不必逐笔刷新 | 用逐笔行情刷风控价,CPU 开销过大还抢回报线程 |
超时参数最容易出问题。报单响应超时指的是"报单请求发出到收到柜台受理回报"的最大等待时间,不是整个订单的存活时间。我见过团队把下单超时设成 30 秒,结果一笔订单在柜台卡了 30 秒才报错,策略早就以为超时重发了。合理做法是:响应超时设短(3 秒以内),超时后先查单,查不到再决定重发;订单的"生命周期超时"是另一个概念,由策略层控制,不交给执行器。
重连和队列深度要一起调。断线重连时,柜台侧可能已经堆积了若干回报,恢复后要先做一次全量状态同步,再做后续报单。回报队列深度设得浅,是为了防止异常时内存撑爆;但设得太浅又会导致正常行情下丢回报。我的做法是把回报队列做成磁盘落盘的环形缓冲,内存里只放索引,这样队列即使几万条也不会撑爆内存,重启后还能从磁盘恢复。
4.3 时钟:用本地时间戳还是柜台时间戳,这是一道玄学题
延迟统计和盘后对账,最容易出现"时间对不上"的玄学问题。原因是本地服务器时钟和柜台服务器时钟不在同一个时间基准上。我的规矩是:事件排布、延迟计算用本地时间戳;成交流水、费用计算、对账基准一律用柜台回报里的时间戳。本地时间先自行开启 NTP 同步,误差能控制在几十毫秒内,但盘后对账时仍以柜台时间为准。本地时间戳只承担一个职责:衡量「从发出到收到回报」的链路耗时,这个场景里两端都在本地进程内,时间一致,结论可信。
5. 生产避坑:恒生自动交易上线初期最容易翻车的五个场景
这一章是从真实排查经验里沉淀下来的五条高频事故,每条都按「现象 → 原因 → 解决」展开。这些场景我在不同团队里见过不止一次,几乎每个新上线的自动交易系统都会撞上其中两三条。
5.1 断线重连后,本地还显示"已报",柜台其实已经全成
现象:网络闪断后自动恢复,系统按本地订单状态继续管理,结果发现有一笔订单在本地状态是"已报",但柜台实际已经全部成交,系统基于错误的本地状态,又补了一张相反的平仓单。原因:断线期间的回报全部丢失,本地状态没有同步柜台。解决:所有重连流程的第一步永远不是继续报单,而是先做全量状态同步。具体做法是拉取当日全部委托记录,逐笔和本地订单表对照,以柜台状态为准修正本地状态,修正完再开放报单入口。同步期间新来的策略信号先进缓冲队列,不同步完不允许出队。
5.2 报单返回"废单",本地却当成网络失败重试
现象:一笔限价单因为超出涨跌停价格被柜台拒绝,回报是废单,但执行器把废单误判成网络异常,自动重发了一次,又收到一张废单,如此循环。原因:代码里只捕获了网络异常,没有把"废单"这种业务拒绝和系统故障分开处理。解决:在做错误码映射时,明确区分三类结果——成功、业务拒绝(废单)、系统故障(超时/连接断开)。业务拒绝是终态,系统故障才允许重试。我的习惯是在订单状态机里给废单一个单独的出口,废单到达后立刻把订单标记为终态,同时把拒单原因原样落盘,策略层可以看原因决定是否改价重下,但执行器绝不自动重发。
5.3 盘后对账脚本只拉了分页的第一页,大量成交对不上
现象:盘后对账时,发现本地统计的成交量比柜台少,但对账单又找不到差异在哪。原因:柜台接口的当日委托/成交查询接口是分页返回的,对账脚本只取了第一页,默认最大页数设成 1,后面的数据全漏掉了。这种情况最隐蔽,因为每天漏的数量还不一样,时多时少。解决:分页拉取必须用循环,并且以"本次返回条数 < 每页条数"作为终止条件;同时要显式设置最大页数保护,防止柜台返回异常导致死循环。对账脚本跑完后,加一个总量校验:本地订单数 + 本地成交总量,必须和柜台端查询到的汇总数一致,不一致直接告警,而不是只对比明细。
5.4 仿真环境能通过,一上生产就开始废单
现象:在仿真柜台里测试好好的,切到生产后第一批订单全部废单,系统日志里没有任何规律。原因:仿真环境的风控价格、涨跌停价、最小变动价位和生产的真实行情不一致。最常见的是用昨收算出的涨跌停价在仿真环境里范围很宽,生产里恰好有只票涨停,限价单价格超出可交易区间。解决:上线前做一次环境参数核对,列出「合约代码 → 生产涨跌停价 → 仿真涨跌停价 → 最小变动价位」对照表,确认风控模块用的是生产行情源。还有一个习惯:正式实盘前,用最小手数跑前 30 分钟,观察废单率和拒单原因分布,确认没问题再加量。
5.5 结算切日与节假日:当日委托查询接口的边界
现象:周一开盘时,系统里还能查到周五的委托记录,导致对账重复;或者反过来,周五晚间结算后策略还在发单,被判为非交易时段。原因:柜台的"当日"可能按交易日而非自然日切换,结算切日的时间点不同接口表现不同。解决:所有策略信号进入执行器之前,先过一次交易日历校验,确认当前时间属于哪个交易日、是否处于可交易时段。交易日历不要硬编码在代码里,而是从柜台基础数据接口拉取,每天开市前存一次。对账脚本同理,明确按柜台返回的交易日字段聚合,绝不按自然日 group by。
6. 上线前夜与盘后:用仿真回放和逐笔对账替自己把关
经过了前面的状态机和参数调校,最后一步是把这套系统放到接近真实的环境中验一遍。我个人的习惯是上线前夜做一次「历史行情回放 + 仿真柜台」:取最近一个完整交易日的行情快照,按时间顺序灌给策略引擎,同时把柜台替换成一套模拟回报的假网关。假网关不需要真的连柜台,只需按照订单的品种、价格、数量,基于真实成交行情判断订单能否成交、在哪个时间点成交。这样能完整走查一遍状态机:乱序回报有没有被正确处理,撤单超时有没有转查单,废单有没有误重试。
-- 盘后对账核心 SQL:以柜台成交明细为基准,对比本地订单状态 SELECT local.client_order_id, local.symbol, local.side, local.quantity AS local_qty, local.filled_quantity AS local_filled, counter.filled_quantity AS counter_filled, CASE WHEN local.filled_quantity != counter.filled_quantity THEN '成交数量不一致' WHEN local.status = '已报' AND counter.filled_quantity > 0 THEN '本地状态未更新' ELSE 'OK' END AS diff_type FROM local_orders local LEFT JOIN counter_orders counter ON local.client_order_id = counter.client_order_id WHERE local.trade_date = CURRENT_DATE AND diff_type != 'OK';盘后对账这条 SQL 是我每次上线前都会准备好的,逻辑不复杂,但查出来的问题往往都是状态机里没考虑到的边界。跑完对账后,我会再看一眼回报日志里有没有「非法状态迁移」的告警——有的话说明状态表还漏了场景,必须修完再扩容下单量。这几年下来,我保留了一个习惯:每一笔订单的所有回报,都用业务键加回报序号的格式,逐行落盘留档。这个习惯救过我好几次,当柜台和技术支持两边都在互相推诿时,只有本地这份原始回报日志能一锤定音。希望帮到你。
本文还有配套的精品资源,点击获取