
简介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_idclient_order_id, symbolsymbol, sideside, priceprice, quantityquantity ) 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, delay5.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, delay2.0)这里的核心是撤单失败不重发撤单而是转查单。查单本身也要有最大重试次数我一般设 3 次超过后把订单状态置为「状态未知」人工介入。宁可把订单标记为未知让人看也不能让程序自作主张重发指令这是自动交易避免本金损失的一条底线。4. 延迟与参数把恒生自动交易跑稳的五个必调项系统跑通了接下来就是性能和稳定性。恒生柜台本身是一套成熟的低延迟柜台但 AutoTrader 这端的参数没调好再快的柜台也救不了你。这一章不是讲如何把延迟压到微秒级——那需要专门的低延迟硬件和内核调优而是讲普通服务器环境下五个直接影响下单链路稳定性的参数怎么设、为什么这么设。4.1 请求串行与批量下单的取舍很多新手把报单实现成多线程并发往柜台发以为并发越高效率越高。实际踩下来大部分柜台 API 的内部会话是有序的高频并发报单反而会触发柜台端排队或限流。我一般做两级设计报单请求先进入一个内存队列执行器侧用单线程消费保证应用层到柜台的报单是串行的风控检查放在入队之前并发执行风控不过的直接丢弃。这样既保证了柜台侧的有序性又不会因为串行让风控变成瓶颈。只有确定柜台会话支持并发报单并且策略类型是拆单/算法单时我才会开一个受控的并发上报且并发数控制在 24 之间绝不盲目加线程。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 是我每次上线前都会准备好的逻辑不复杂但查出来的问题往往都是状态机里没考虑到的边界。跑完对账后我会再看一眼回报日志里有没有「非法状态迁移」的告警——有的话说明状态表还漏了场景必须修完再扩容下单量。这几年下来我保留了一个习惯每一笔订单的所有回报都用业务键加回报序号的格式逐行落盘留档。这个习惯救过我好几次当柜台和技术支持两边都在互相推诿时只有本地这份原始回报日志能一锤定音。希望帮到你。本文还有配套的精品资源点击获取