简介:面向程序化交易开发者的一份 C++ 券商接口对接示例工程,聚焦算法交易中的自动下单环节,帮助读者理解如何通过证券公司提供的 API 完成连接建立、身份认证与委托发送,适合具备 C++ 与网络编程基础、希望把策略思路推进到实盘落地的开发者参考。压缩包共 15 个文件,约 16KB,以 .h 头文件与 .cpp 源文件为主体,另含 axstock.lib 券商接口静态库、vcproj 工程配置、rc 与 ico 界面资源以及 ReadMe 说明文档,工程可在 Visual Studio 中直接打开编译,登录与下单逻辑分层清晰。目前已有 5773 人学习下载。代码涵盖登录对话框封装、登录请求发送与响应解析、预编译头组织等关键环节,配合静态库调用示例,可帮助读者梳理网络通信、订单类型构造、事件监听与异常处理的实现路径,是理解券商接口调用流程、搭建自研交易平台雏形的实用参考。
1. 从一次手工挂单的延迟说起:这份接口示例源码能替你做什么
行情在 14:56 突然拉了两个价位,你盯着盘口按下了买入,成交回报回来时已经滑掉三档——这种场景做过手动交易的人都不陌生。这份《程序化交易自动下单券商接口示例源代码》解决的就是这一段:把“策略给出信号”到“委托真正落到柜台”之间的手工环节,换成一条可以监控、可以重放、可以回滚的代码链路。它不是一个能直接赚钱的策略包,里面没有选股逻辑,也没有神奇的参数;它是执行层的一组骨架——会话建立与保活、委托参数组装、下单/撤单调用、回报解析与本地状态机、下单前的风控拦截,都给了可读的示例实现。适合两类人:手里已经有一版能出信号的策略、但卡在“怎么让它自动发出去”的开发者;以及想把每天几十笔手工单改成脚本执行、又不想一上来就依赖黑盒终端的从业者。它不适合对交易规则完全没概念、指望跑起来就稳定盈利的人。
2. 拆开代码包:三类接口形态与一次委托的完整链路
拿到一份接口示例,最先要判断的不是代码写得好不好,而是它对接的是哪一类通道。同样叫“券商接口”,HTTP 开放平台和柜台二进制协议在字段语义、回报节奏、失败重试策略上完全是两个世界,盲目照搬参数一定会翻车。
2.1 示例源码的分层:接入层、业务层、风控层
我一般把这类代码按三层去看,而不是从头文件顺序读。接入层负责和柜台打交道:建链、登录、心跳、断线重连、报文编解码,这一层写错了,上面写得再漂亮也白搭。业务层负责把“买 100 股某标的、限价 10.20”翻译成柜台认识的字段组合,并把回来的成交回报、委托回报、撤单回报归并到同一个订单对象上。风控层在业务层之前,做数量、金额、频率、价格偏离度的拦截。
示例源码里这三层的边界大致是:session目录是接入层,order目录是业务层,risk目录是前置校验,model里放着订单、成交、回报的公共结构。阅读顺序建议从model开始——先搞清一个订单对象有哪些字段、状态怎么流转,再回头看它是怎么被填进去的。反过来读,很容易陷在 socket 收发细节里出不来。
分层带来的实际好处是:换柜台时往往只动接入层。我见过把业务逻辑直接写死在报文拼装函数里的代码,换一家柜台等于重写整个项目,这种血泪经验没必要重复一遍。
2.2 三种常见接口形态:HTTP/JSON、FIX、二进制长连接
市面上能拿到手的程序化交易接口,大致落在三类里,选型和排错思路完全不同:
| 形态 | 典型特征 | 回报时效 | 常见坑 |
|---|---|---|---|
| HTTP/JSON 开放接口 | 请求-响应,有签名鉴权,文档齐全 | 秒级,需轮询或另接推送 | 频率限制严格、签名串拼错、幂等靠客户端自己保证 |
| FIX 协议 | 标签号字段(35=D 新单、35=F 撤单) | 毫秒到秒级 | 会话序号(MsgSeqNum)断档、重发请求处理不当 |
| 二进制长连接柜台 | 私有结构体、需要 .so/.dll,登录后有私有心跳 | 毫秒级 | 假死检测、字段偏移、版本不匹配 |
示例源码默认实现的是第二、三类共用的“长连接 + 请求/回报分离”模型:发出去的请求只回一个受理确认,真正的成交和撤单结果是异步推回来的。这一点必须先在脑子里立住——很多人按 HTTP 的思维写代码,place_order()拿到返回值就当作“已成交”,然后去更新持仓,账面立刻和柜台对不上。
HTTP 接口相对好上手,但要注意签名和频率;FIX 的会话层比业务层更容易出事,序号断了要么重发要么建新会话,示例里给了一个简化的序号管理和重发缓存;二进制柜台则要看清楚厂商给的版本号,结构体字段对不上时,收到的价格可能是乱码而不是报错。
提示:示例代码里的字段名是通用命名,实际对接时以柜台文档的字段顺序和类型为准,尤其是数量、金额这类字段是字符串还是整型、单位是股还是手,一定要逐项核对。
2.3 一次委托从函数调用到柜台回报的六跳
把链路摊开看,一次自动下单大概经过六跳:策略调用下单函数 → 风控前置校验 → 组装请求并生成客户端委托号 → 发送到接入层排队 → 柜台受理并回委托回报 → 成交回报异步推送回来更新本地订单。任何一跳出问题,最终表现都是“单子没出去”或者“出去了但本地不知道”。
关键在于第四跳到第五跳之间:很多柜台是“先回受理、再回成交”,受理成功不代表成交。示例里把这两个事件分开处理,on_insert_ack只更新委托状态,on_trade才累加成交量。如果只写一个回调,日志里就会出现“状态是已报但成交量是 100”的诡异记录。
另一个容易忽略的是第四跳的排队。示例用一个单线程发送队列保证同一账户的请求按生成顺序发出,避免同一标的的先卖后买被柜台判成废单。多线程直接并发调发送函数,短时间内在同一标的上交叉下单,废单率会明显上升。
3. 把示例跑起来:环境、配置与第一笔模拟委托
这一章按能照着敲的粒度写。示例默认连的是模拟环境,所有账号、经纪商编号都是占位值,跑通之后再换成柜台给的真实参数。建议第一次跑不要接任何策略,手工调一次下单和撤单,确认回报都能对上,再谈自动化。
3.1 依赖、目录与配置文件逐字段说明
环境用 Python 3.10 以上,依赖只有三四个,装完就能跑。目录结构大致如下:
| 路径 | 作用 | 是否需要改 |
|---|---|---|
config/gateway.yaml | 连接参数、风控阈值 | 必须改 |
src/session/ | 建链、登录、心跳、重连 | 一般不改 |
src/order/ | 下单、撤单、回报分发 | 改字段映射 |
src/risk/ | 前置风控规则 | 按账户改阈值 |
examples/ | 手工调用脚本 | 参考用 |
logs/ | 报文与业务日志 | 不用改 |
安装依赖用一条命令即可,注意虚拟环境隔离,这类项目经常要同时维护两三个柜台的版本,全局安装迟早互相打架:
python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt # 通常包含 pyyaml、websocket-client 或 socket 封装、pandas(可选)配置文件是整份代码里最该逐行看懂的地方,示例字段如下:
gateway: mode: sim # sim=模拟环境, live=实盘; 示例默认 sim, 切换前先跑通全部回报 endpoint: "tcp://127.0.0.1:9101" # 柜台前置地址, 模拟环境给的是本地网关 broker_id: "9999" # 经纪商编号, 柜台分配, 占位值 account: "SIM000001" # 资金账号 password_env: "GW_PASS" # 密码从环境变量读, 不落盘 heartbeat_sec: 15 # 心跳间隔 reconnect_backoff: [1, 2, 5, 10, 30] # 重连退避秒数 risk: max_order_qty: 1000 # 单笔最大数量(股) max_order_notional: 200000 # 单笔最大金额(元) max_orders_per_sec: 5 # 每秒最大委托笔数 price_deviation_pct: 0.02 # 价格偏离最新价上限 2%mode这一项建议在代码里做成硬开关:非sim时启动打印醒目日志,并且要求额外传一个命令行参数才允许继续,防止调试脚本半夜把实盘账户刷了。
password_env的方式比把密码写进 yaml 稳妥得多,启动前export GW_PASS=xxx即可。heartbeat_sec不是越小越好,柜台一般有最小间隔限制,设成 3 秒以内有些柜台会直接断你。
3.2 建立会话与心跳保活
建链和登录是整条链路里最“玄学”的一段:网络通、账号对,也可能因为前置机负载或者会话数超限登不上去。示例给的是带指数退避的重试,核心代码:
import os, time, logging from dataclasses import dataclass @dataclass class GatewayConfig: endpoint: str broker_id: str account: str heartbeat_sec: int = 15 backoff: tuple = (1, 2, 5, 10, 30) class Session: def __init__(self, cfg: GatewayConfig): self.cfg = cfg self.logged_in = False self.last_rx = 0.0 # 最后一次收到任意报文的时间, 用于假死检测 def connect(self) -> bool: for i, wait in enumerate(self.cfg.backoff): try: self._open_socket() # 建立底层连接 self._login(self.cfg.account, os.environ["GW_PASS"], # 密码只从环境变量取 self.cfg.broker_id) self.logged_in = True self.last_rx = time.time() logging.info("login ok, attempt=%s", i + 1) return True except Exception as e: logging.warning("connect failed: %s, retry in %ss", e, wait) time.sleep(wait) return False def keepalive(self): while self.logged_in: # 超过 3 个心跳周期没收到任何报文, 判定连接假死, 主动重连 if time.time() - self.last_rx > self.cfg.heartbeat_sec * 3: logging.error("heartbeat timeout, reconnecting") self._reconnect() continue self._send_heartbeat() time.sleep(self.cfg.heartbeat_sec)last_rx这个字段是整个保活逻辑的核心。只发心跳、不检查收包,是国内长连接柜台最典型的假死场景:TCP 层没断,但柜台侧会话已经被清掉,你发出去的单全部石沉大海,而发送函数不报错。判定阈值取心跳间隔的 3 倍是个经验值,太灵敏会在网络抖动时频繁重连,太迟钝则要等好几分钟才发现。
_reconnect()里必须做一件事:重连成功后重新登录、重新订阅、并且把本地所有“已报未成”的订单标记为“状态待确认”,主动发一次查询去对账。直接重连当无事发生,是后面重复下单的根源。
3.3 下单、撤单、回报的完整闭环
建好会话之后,先别急着接策略,用示例里的手工脚本走一遍完整闭环。下单函数长这样:
from enum import Enum class Side(str, Enum): BUY = "B" SELL = "S" class PriceType(str, Enum): LIMIT = "2" # 限价 MARKET = "1" # 市价, 部分柜台要求额外字段 BEST_5 = "5" # 最优五档即时成交剩余撤销 def place_order(session, symbol, side, price, qty, price_type=PriceType.LIMIT, cid=None): req = { "MsgType": "ORDER_INSERT", "ClientOrderId": cid, # 幂等键, 全局唯一, 由调用方生成 "Symbol": symbol, # 带市场后缀, 如 600000.SH "Side": side.value, "Price": f"{price:.2f}", # 按标的价位精度格式化, 不要传 float "Volume": int(qty), # 单位: 股, 按整手校验 "PriceType": price_type.value, "TimeInForce": "0", # 0=当日有效 } return session.request(req, timeout=3.0)几个参数值得单独说。ClientOrderId不能省,它是你在本地唯一能可靠定位一笔委托的钥匙,柜台返回的OrderId有时候要延迟一拍才给。Price用字符串而不是浮点,是因为10.2存成 float 再格式化可能变成10.199999,直接被柜台判成价格非法。Volume按股传、按整手校验,A 股一手 100 股,科创板等板块另有规则,这里不做展开,示例里留了校验钩子。
撤单只要原样带上OrderId(没有就带ClientOrderId),回报路径和新单一致。收到CANCELED之后本地状态才算终结,别在发出撤单请求的那一刻就把订单从活动列表里删掉——撤单也会失败,尤其是临近收盘撮合高峰。
回报分发建议按ClientOrderId和OrderId双索引存一份订单簿,两个键都能查到同一笔单。只存一个的后果是:委托回报先到、成交回报后到还好;成交回报先到时你查不到单,只能丢掉或者靠猜,日志里就会缺失一段。
4. 参数怎么设:价格类型、委托属性与回报状态机
接口跑通只是及格线,真正决定这套自动下单能不能用在生产的是字段语义和状态机。这一章把最容易填错、也最容易在事后复盘时说不清的部分拆开。
4.1 价格类型与委托数量:最容易填错的六个字段
同样是“限价买入”,不同柜台的字段组合差别不小。下面这张表是这类示例代码里最常见的六个字段,值得贴在显示器边上:
| 字段 | 常见取值 | 填错的后果 |
|---|---|---|
| 价格类型 | 限价 / 市价 / 五档即成剩撤 / 本方最优 | 用市价单打小盘股, 滑点失控 |
| 委托数量 | 股数, 整手 | 非整手直接废单 |
| 买卖方向 | B/S, 不是 buy/sell 字符串 | 报文字段不识别, 整单被拒 |
| 开平标志 | 股票一般不用, 期货必填 | 缺失时柜台拒单或默认值错误 |
| 有效期 | 当日有效 / 即时成交剩余撤销 | 收盘前挂单跨日, 次日变成废单 |
| 价格精度 | 按标的最小变动价位 | 小数位多一位就是价格非法 |
市价单要特别克制。示例里虽然实现了MARKET,但默认风控会拦截它,原因是市价单在流动性差的标的上成交价可能远离预期,程序化下单放大了这个风险。我一般只对流动性最好的几个标的开放市价,其余一律限价加追价逻辑。
数量字段还有一个隐蔽的坑:部分柜台的Volume单位是“手”而不是“股”,文档里写得含糊,示例配置里用volume_unit标出来。传错了就是 100 倍杠杆的错误,这种单子出去要么被资金校验拦下,要么直接捅出大窟窿。
4.2 回报状态机与幂等:别让一次重连重复下单
本地订单状态要收敛到一个明确的状态机,否则日志永远对不上账。示例里的状态流转是:PENDING(已发出未确认)→SUBMITTED(柜台已受理)→PARTIAL(部分成交)→FILLED/CANCELED/REJECTED。另外留一个UNKNOWN,用于重连后还没对账的中间态。
class OrderBook: def __init__(self): self.by_cid = {} # ClientOrderId -> order self.by_oid = {} # 柜台 OrderId -> order self.orphan = [] # 成交回报先于委托回报到达时暂存 def on_insert_ack(self, ack): o = self.by_cid.setdefault(ack["ClientOrderId"], {"cid": ack["ClientOrderId"], "filled": 0, "status": "PENDING"}) if ack.get("OrderId"): o["oid"] = ack["OrderId"] self.by_oid[ack["OrderId"]] = o o["status"] = "SUBMITTED" if ack.get("Accepted") else "REJECTED" self._drain_orphan(o) # 把之前暂存的成交回报补记上 def on_trade(self, t): o = self.by_oid.get(t["OrderId"]) or self.by_cid.get(t.get("ClientOrderId")) if o is None: self.orphan.append(t) # 查不到就先挂起, 不要丢 return o["filled"] += int(t["Volume"]) o["status"] = "FILLED" if o["filled"] >= o["qty"] else "PARTIAL"orphan这个暂存列表看着多余,实际能救回不少丢单。快速行情下成交回报跑在委托回报前面是常有的事,尤其是通过网关转发的时候。
幂等则靠ClientOrderId保证。生成规则我一般用“日期 + 策略编号 + 自增序号”,比如20240612S01000123,重启后从当天的最大序号继续,不要用随机数——随机数在重连补发时无法判断是不是同一笔。发送超时后要不要重发,判断依据就是这个 ID:先查本地订单簿,状态还是PENDING且超过超时阈值,才用同一个 CID 重发一次,柜台侧会按重复委托处理而不是新建一笔。
4.3 前置风控:限频、单笔上限与资金校验
风控放在策略和接口之间,作用是拦住“代码 bug 变成真金白银”的那一类事故。示例里给了四项:单笔数量上限、单笔金额上限、每秒委托笔数、价格偏离度。前两项是硬阈值,后两项防的是失控循环。
import time class PreTradeRisk: def __init__(self, cfg, latest_price_fn): self.cfg = cfg self.latest = latest_price_fn # 回调: 取最新价 self.window = [] # 最近 1 秒的委托时间戳 def check(self, req) -> str | None: qty, price = int(req["Volume"]), float(req["Price"]) if qty <= 0 or qty % 100 != 0: return "REJECT_LOT" if qty > self.cfg["max_order_qty"]: return "REJECT_QTY_LIMIT" if qty * price > self.cfg["max_order_notional"]: return "REJECT_NOTIONAL" ref = self.latest(req["Symbol"]) if ref and abs(price / ref - 1) > self.cfg["price_deviation_pct"]: return "REJECT_PRICE_DEVIATION" # 防手滑打错价格 now = time.time() self.window = [t for t in self.window if now - t < 1.0] if len(self.window) >= self.cfg["max_orders_per_sec"]: return "REJECT_RATE_LIMIT" self.window.append(now) return None限频这一段用的是滑动窗口,比固定计数器准确,代价是列表要定期裁剪。频率阈值别贴着柜台的上限设,留三成余量,因为你的计数和柜台的计数窗口不完全对齐。
价格偏离度这一项,是我认为性价比最高的规则。策略信号出错、价格变量取错、单位换算错,最终都表现为价格离谱,这一条能拦住绝大多数“手滑型”事故。
5. 避坑实录:接口对接最容易翻车的五个地方
下面五条都是我在对接阶段真实遇到过的,按“现象 → 原因 → 解决”写,能省掉不少半夜爬起来看日志的时间。
5.1 连接层:心跳正常,单子却发不出去
现象:日志里心跳一直在发,send()也不报错,但柜台没有任何回报,持仓和委托列表都不动。
原因:TCP 连接还在,柜台侧会话已经因为超时或前置切换被清掉了。发送缓冲区还能写,所以应用层感知不到。
解决:不要以“发送成功”作为连接健康的判据,改成以“收到报文”为准。示例里用last_rx做假死检测,超过三个心跳周期没有任何入站报文就主动重连。重连之后第一件事是查委托和查持仓,把本地状态和柜台对齐,而不是接着发新单。
还有一个变种:重连太频繁被柜台限流,登录返回“会话数超限”。退避策略里要加最大等待和上限次数,连续失败超过阈值就报警停手,别无限重试。
5.2 业务层:重连之后同一笔单下了两次
现象:网络抖了一下,恢复后发现同一个标的出现了两笔一模一样的委托,数量翻倍。
原因:发送超时后代码走了重发逻辑,但重发时用了新生成的ClientOrderId,柜台认为是两笔独立委托。
解决:幂等键必须在“生成请求”那一刻确定,并跟随这笔委托走完全部重试流程。超时重发前先查本地订单簿,如果状态已经不是PENDING,说明柜台其实收到了,不要再发。重连对账时也要按 CID 去重。
5.3 字段层:价格明明是合法价,却一直废单
现象:报单价格和盘口一致,但委托回报里状态是废单,错误码指向价格。
原因:价格精度或最小变动价位不对。有的标的价位是 0.01,有的 ETF 是 0.001,代码里统一用两位小数格式化,后者就被截断了;反过来,给 0.01 价位的标的传三位小数,也可能被判非法。
解决:把标的的价位精度做成配置或者从合约信息里取,格式化时按精度来,不要写死。数量单位同理,股和手必须在上层就统一,别指望柜台帮你换算。
5.4 回报层:成交回报先到,本地查不到订单
现象:日志里出现一条成交回报,但订单簿里没有对应的单,成交被丢弃,收盘对账差一笔。
原因:委托回报和成交回报走不同通道,到达顺序无保证。快速行情下成交回报跑到前面很正常。
解决:查不到就先暂存(示例里的orphan列表),等委托回报到了再合并;或者用ClientOrderId反查,很多柜台的成交回报里带这个字段。无论如何不要直接丢弃,丢了就是账实不符。
5.5 时间层:收盘前撤单,回报说“已撤”,持仓却没变
现象:14:59 发撤单,收到撤单成功回报,但结算时发现单子已经成交了。
原因:撤单请求和撮合在柜台侧是竞态的,回报顺序不代表业务顺序。撤单成功回报可能对应的是“撤单请求已受理”,真正的撤单结果要看最终的委托状态。
解决:撤单后不要立刻认为订单终结,等委托状态变成CANCELED且成交量不再变化再收尾。临近收盘的撤单请求,最好在本地做一次短间隔轮询确认,并记录最终成交量,收盘对账时以柜台查询结果为准。
6. 进阶:把示例改成能跑一天的骨架
示例代码解决的是“一笔单怎么发出去”,真正上生产还要回答“跑一整天不出事”。我一般的做法是先搭一个只做记录、不做决策的骨架,让它跟着真实行情跑满一个交易日,看日志里有没有异常断连、有没有孤儿回报、有没有状态卡在PENDING不动的订单,确认干净了再接策略。
骨架的主循环大致是这样,重点不在交易逻辑,而在异常兜底:
def run_trading_day(gw, book, risk, strategy, halt_file="HALT"): if not gw.connect(): logging.critical("gateway login failed, abort") return threading.Thread(target=gw.keepalive, daemon=True).start() while is_trading_time(): # 交易时段判断, 含午休 if os.path.exists(halt_file): # 熔断开关: 落地一个文件即停手 logging.critical("halt file found, stop sending") break sig = strategy.next_signal() # 策略只产出信号, 不下单 if sig is None: time.sleep(0.2) continue reason = risk.check(sig) if reason: logging.warning("risk reject %s: %s", sig["Symbol"], reason) continue place_order(gw, cid=next_cid(), **sig) time.sleep(0.05) # 主动节流, 别把柜台打满 reconcile(gw, book) # 收盘对账: 持仓、委托、成交三份比对halt_file这个土办法非常好用:出问题时不需要去杀进程,touch HALT就停手,比任何优雅退出都可靠。reconcile()是每天的必做项,把本地订单簿、柜台委托列表、柜台成交明细三方比对,差异写进当日报告。差异超过阈值,第二天就不许自动启动。
几个进阶参数值得单独调:发送节流间隔从 50ms 起,按柜台限频往下压,别一上来就设 1ms;重连退避的最大次数设 5 次,超过就报警而不是继续重试;日志按天切分并且保留原始报文,出问题时能回放。下面这张表是我自己用的上线前检查项:
| 检查项 | 通过标准 |
|---|---|
| 假死检测 | 断网 3 分钟后能自动重连并对账 |
| 幂等 | 同一 CID 重发两次, 柜台只有一笔委托 |
| 风控 | 故意传错价格, 被偏离度规则拦下 |
| 回报完整性 | 跑满一天, 孤儿回报数为 0 |
| 对账 | 本地与柜台持仓、成交完全一致 |
从那以后我每次接新柜台,都强制先跑一遍这张表,尤其是假死检测和幂等这两项——它们平时不显形,一旦出问题就是重复下单或者静默丢单,事后补都补不回来。希望这套拆法帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取