前段时间一个做商品策略的朋友找我,说想给自己的盯盘工具加上贵金属期货实时行情:黄金、白银价格一变就推送告警,最好还带上买卖报价和成交量。他一开始觉得"不就是用 requests 拼个 URL 嘛",结果折腾了两天,第一步就卡住了——网上搜到的所谓"实时金价接口",要么是网页爬虫解析出来的延迟数据,要么干脆是日频收盘价,根本给不了真正的期货盘口。后来换成正规的行情数据服务商,从注册到跑通核心链路只用了不到半小时。这篇文章就是那次经历的一个完整复盘:用 Python 接入贵金属期货实时行情 API 时,哪些准备工作必须先做、哪些坑必须先知道、哪些代码可以直接拿来改。
适合读这篇内容的人大概有三类:想把真实行情接进自己的策略或盯盘工具的量化爱好者,需要做贵金属行情监控告警的工程师,以及刚接触行情 API 但被各种"伪实时"接口坑过的新手。我会把从选型、鉴权、订阅到断线重连的全链路都讲清楚,代码以通用协议为例,域名和 Key 换成你自己用的服务商就能跑。
1. 行情源选型:为什么"直接连交易所"这条路基本走不通
1.1 你想象中的接口 vs 真实世界的行情链路
很多人拿到需求后的第一反应是直接去交易所拿行情。真实情况是,交易所通常只面向会员机构提供原始行情通道,门槛包括资质审核、资金门槛和专线成本,个人开发者基本绕不过去。
就算某个渠道能让你拿到原始分片报文,你还得自己处理十几种消息类型、维护全局快照、处理增量更新、拼接深度盘口,这个工作量等同于从零写一个行情中间件。所以业内做行情接入的普遍方案是:使用一家行情服务商提供的 API,由服务商把交易所原始数据解析好,再输出成容易调用的 REST 或 WebSocket 接口。你的核心工作就从"解析底层报文"简化成了"对接服务商协议"。
选服务商的时候,不要只盯着价格看。我见过有人选了一个看似便宜的服务商,结果行情延迟好几秒,连基本的秒级监控都做不了。做实时告警和日内策略,延迟就是命,几百毫秒和两秒钟的差别,直接决定了你的工具可不可用。
1.2 选行情服务商时要看的四个核心指标
这里是我踩过几次坑之后保留下来的选型清单:
- 行情覆盖范围:是否包含你想要的具体合约?有些服务商只提供连续主力合约,有些提供每个月份合约的完整盘口。如果策略需要做近远月价差或者合约展期,这一点必须提前确认。
- 数据延迟与推送方式:靠谱的服务商会明确说明链路,比如"从交易所撮合到 API 出口 200ms 以内",并且同时提供 REST 轮询和 WebSocket 推送两种通道。如果一个服务商只有 REST 接口却自称"实时行情",基本可以移出候选名单。
- 字段完整性:最新价、买一卖一、成交量、持仓量、开盘高低收这些核心字段不能缺。做告警只需要最新价和买卖价,但做更复杂的策略就需要完整盘口和逐笔成交。
- 费用与授权边界:按调用次数计费、按订阅数计费,还是按量打包?是否允许个人量化使用?是否允许缓存数据?这些条款决定了你上线之后会不会突然被停服。
一张表总结不同类型行情源的差异,方便快速做取舍:
| 对比项 | 交易所原始通道 | 商业行情服务商 | 免费网页/爬虫源 |
|---|---|---|---|
| 接入门槛 | 极高(资质、资金、专线) | 低,注册拿 Key 即可 | 无门槛 |
| 数据延迟 | 最低(毫秒级) | 低到中(数百毫秒到秒级) | 高(秒到分钟不等) |
| 字段完整性 | 完整 | 完整或可配置 | 残缺,经常只有价格 |
| 稳定性 | 高 | 高,取决于套餐和区域 | 低,经常改版或封 IP |
| 适合长期使用? | 不适合个人 | 适合个人/小团队 | 只适合学习用 Demo |
对个人开发者和中小团队来说,商业行情 API 是综合成本最低的方案。免费爬虫源拿来学习可以,拿来给真金白银的策略喂数据,那就是拿运气搏风险。
1.3 免费源到底能不能用来做实时监控
我的结论是:可以做,但要认清天花板。早期我试过一个免费的贵金属报价网站,接口确实开放,数据也能正常返回。但跑了一段时间后,我发现两个致命问题。
第一,推送频率不稳定。行情波动大的时段,网页端和接口返回的数据经常不一致,甚至同一分钟内返回的价格还能倒退到几秒之前。第二,网站没有正式的使用条款,某天直接把返回字段的格式改了,我的解析程序瞬间崩掉。这类源比较适合做"演示用 K 线"或者"非交易决策的展示面板",不适合作为自动化策略的数据源。
2. 接入前的硬准备:合约代码、时间戳、鉴权方式
2.1 先确定你要的"贵金属期货"到底是哪个合约
贵金属期货是个大类。做海外盘的人一般看 COMEX 的黄金和白银,代码类似GC、SI加上合约月份,比如用GC代表黄金主连、SI代表白银主连;做境内盘的人看的是沪金、沪银,连续主力合约的代码通常写成AU0、AG0这种形式。对实时盯盘场景而言,大多数服务商都提供"主连合约"代码,也就是按持仓量把主力合约拼接出来的连续报价,订阅起来最省事。
我自己踩过的一个坑是:把"主连"当成一个真实可交易的合约去做回测。后来才发现,主连价格是服务商根据持仓量最大的合约实时拼接出来的,手续费、保证金、最小变动价位都和具体月份合约有差异。所以接入之前,先想清楚你要的是"观察用主连"还是"交易用具体月份合约",这决定了你后续代码里订阅哪个代码。
另外,贵金属报价的单位也经常让人一开始怀疑人生。COMEX 黄金报价是美元/盎司,白银同样是美元/盎司,只是数量级比黄金低一截;境内沪金报价是元/克,沪银报价是元/千克。如果做跨市场比价,必须先把货币单位和重量单位统一,否则计算结果会差出好几倍。这个换算逻辑我建议直接写成一个独立的工具函数,别在业务代码里到处散落。
2.2 时间戳和"行情时间"的坑
实时行情数据里至少有三个时间:服务端推送时间、交易所撮合时间、你的本地接收时间。新手最容易犯的错误是拿本地time.time()和行情里的时间戳直接做差,用来评估延迟,结果发现差值偶尔是负数——因为部分服务商返回的时间戳是交易所服务器时间,而你本机时钟和交易所时钟不是同一套参考系。
我的建议是:评估链路延迟时,尽量使用服务商提供的exchange_time和server_time字段做差,而不是依赖客户端时钟。同时在存储行情记录时,把价格字段和这三个时间一起存下来,后面做性能分析才能分清楚延迟到底出现在网络环节,还是服务商出口环节。
另一个和时钟相关的坑是 K 线对齐。如果你要拿实时行情自己聚合 1 分钟 K 线,必须用交易所时间戳来切分边界,而不是本地时间。我有一阵子用本地时间切 K 线,行情数据偶尔延迟几百毫秒,本地分界线和交易所分界线就会错开,偶发性地把前后两根 K 线的数据混在一起。虽然单看影响不大,但回测样本里一旦混入这种脏数据,噪音会沿策略链路传导,排查起来很费劲。
2.3 怎么拿 Key、配白名单、定数据格式
绝大多数行情服务商的标准接入流程都是这几步:
- 注册账号,创建一个应用,拿到 API Key(有时还带一个 Secret);
- 如果使用 WebSocket 推送,通常需要把本机出口 IP 加入白名单;
- 查阅接口文档,确认鉴权方式,常见的有 Header 带 Key、Query 参数带 Key、签名鉴权三种。个人项目从最简单的 Header 方式开始即可。
这里最想提醒的是权限控制:API Key 千万别硬编码提交到 Git 仓库,哪怕只是个人项目,也建议放到环境变量或独立的配置文件里。你现在的代码可能只给自己看,但某天公开分享代码时,Key 如果跟着被公开,别人就能用你的配额刷行情,账单会非常难看。
另外,行情接口的返回几乎都是 JSON,但从第一行代码开始就建议写一个小而稳的解析函数。原因是行情数据结构通常嵌套较深,像data.quotes[0].bid[0].price这种路径很常见。以后切合约、加订阅,解析逻辑集中管理,能省掉大量重复劳动。
3. 两种接入方式:轮询拉取与 WebSocket 实时推送的完整代码
3.1 REST 轮询:适合低频任务和程序化校准
REST 轮询是理解行情 API 最自然的方式:你问一次,它答一次。适合每天只取几次参考价的低频任务,也适合作为实时推送之外的兜底通道。
用 Python 的requests库,几行代码就能拉到指定合约的最新报价:
import os import requests API_URL = "https://api.example-market.com/v1/quote" API_KEY = os.environ.get("MARKET_API_KEY", "") def fetch_quote(symbol: str) -> dict: headers = {"X-API-Key": API_KEY} params = {"symbol": symbol} resp = requests.get(API_URL, headers=headers, params=params, timeout=5) resp.raise_for_status() return resp.json() if __name__ == "__main__": quote = fetch_quote("GC") print(quote)返回的 JSON 一般长这样:
{ "symbol": "GC", "last_price": 2347.8, "bid": 2347.7, "ask": 2347.9, "volume": 152330, "open_interest": 401215, "exchange_time": 1710000123000, "server_time": 1710000123100 }这段代码没什么魔法,真正要注意的是timeout必须设置。行情接口偶尔会出现网络抖动,如果不设置超时,一个requests.get可能把你的监控程序卡住一整晚,看起来像死机一样。另外,raise_for_status()不要省,它会在接口返回 4xx/5xx 时立刻抛出异常,让程序能进入重试或告警分支。
轮询频率建议从 1 秒一次起步。除非你的服务商给你分配了专门的高频通道,否则没必要 100 毫秒一次去死磕。轮询太频繁反而会触发服务商限流,增加延迟,而且大部分低价套餐扛不住高频轮询。如果你的策略真的需要毫秒级价格,正确做法是走 WebSocket,而不是把 REST 接口当高频接口用。
3.2 WebSocket 实时推送:真正的持续数据流
WebSocket 是实时行情的主流接入方式。你的进程和服务商服务器之间维持一条长连接,行情一旦变化,服务端就把数据主动推给你。这样不需要反复发起 HTTP 请求,延迟也更低。
Python 生态里常用的库有两个:websocket-client和websockets。前者是同步库,适合监控脚本和相对简单的程序;后者是 asyncio 实现,适合嵌进异步策略框架。先以websocket-client为例,安装命令:
pip install websocket-client建立连接并订阅行情:
import json import os from websocket import create_connection WS_URL = "wss://quote.example-market.com/ws" API_KEY = os.environ.get("MARKET_API_KEY", "") def connect_and_subscribe(): ws = create_connection(WS_URL, header={"X-API-Key": API_KEY}, timeout=10) subscribe_msg = { "action": "subscribe", "channel": "quotes", "symbols": ["GC", "SI"], } ws.send(json.dumps(subscribe_msg)) return ws if __name__ == "__main__": ws = connect_and_subscribe() while True: raw = ws.recv() data = json.loads(raw) if data.get("type") == "ping": ws.send(json.dumps({"type": "pong"})) continue if data.get("channel") == "quotes": for item in data.get("data", []): print(item["symbol"], item["last_price"], item["exchange_time"])这段代码跑起来,你马上能感受到和轮询完全不同节奏:行情不是在被"应答",而是在被"推送"。从发出订阅请求到连续收到数据,通常 1 秒内就能跑通。
需要提醒的是,create_connection背后包含 TCP 连接、TLS 握手、WebSocket 握手,如果中间某个环节不通,异常不会在代码层面表现得很直观。所以正式写代码时,把连接动作放进一个独立的函数里,同时把连接异常单独处理,后面再接重连逻辑就会方便很多。
如果你用的是异步框架,websockets库的接入思路一样,只是把create_connection换成了connect,把recv()放在async for循环里。协议层的订阅、心跳、消息格式完全一致,不需要二次学习。
3.3 为什么我建议"推送为主、轮询兜底"的双通道架构
有经验的工程师往往会同时保留实时推送和轮询校准,而不是死磕某一条通道。
原因很简单:实时推送链路有可能出现"看起来连着但数据不再流动"的半死状态。这种状态非常难察觉,因为 TCP 连接没有断,你的recv()也一直阻塞等待,只是服务端不再推新数据了。如果进程里同时有一个低频轮询器在拉最新报价,就能及时发现主通道延迟异常,触发告警甚至自动切换。
我不是让你一开始就搞复杂的双通道高可用,但至少在代码结构上把"获取行情"和"业务处理"分成两个模块。两者独立之后,你今天用轮询,明天切 WebSocket,后天再加一个备用数据源,都不会动到业务核心代码。
4. Demo 能跑通只是开始:心跳、断线重连、去重与限流
4.1 心跳机制与断线重连的完整实现
实时行情连接在正式使用前,有三件小事必须写进去:心跳应答、断线重连、消息去重。
先说心跳。服务商为了确认你的连接还活着,会周期性发送ping消息,很多协议要求你收到后立刻回一个pong,否则服务端会在几十秒后主动断开。你可能会想:断了我再重连不就行了?问题在于,频繁断开重连不仅浪费握手时间,还会错过断开期间的行情,每次重连都是一段数据空窗。
下面是我常用的带心跳和重连的简版客户端:
import json import os import time from websocket import create_connection, WebSocketConnectionClosedException class QuoteClient: def __init__(self, url, api_key, symbols): self.url = url self.api_key = api_key self.symbols = symbols self.ws = None self.last_pong = time.time() def connect(self): self.ws = create_connection( self.url, header={"X-API-Key": self.api_key}, timeout=10, ) self.ws.send(json.dumps({ "action": "subscribe", "channel": "quotes", "symbols": self.symbols, })) self.last_pong = time.time() print("connected and subscribed") def run(self): self.connect() while True: try: raw = self.ws.recv() except (WebSocketConnectionClosedException, OSError) as e: print(f"connection lost: {e}, reconnecting in 3s") time.sleep(3) self.connect() continue data = json.loads(raw) if data.get("type") == "ping": self.ws.send(json.dumps({"type": "pong"})) self.last_pong = time.time() continue self.handle_message(data) def handle_message(self, data): if data.get("channel") == "quotes": for item in data.get("data", []): print(item["symbol"], item["last_price"])重连逻辑里有一个细节容易被忽略:重连前要加入退避策略。第一次失败等 3 秒,第二次失败等 6 秒,第三次等 12 秒,最大不超过 60 秒。如果所有人都用固定 1 秒重试,服务端抖动时容易形成"重连风暴",进一步拖垮服务。
4.2 收到重复消息不可怕,真正要防的是乱序
WebSocket 推送本质上是异步的,极端情况下你可能会对同一个价位收到两次推送,也可能先收到新价格、后收到旧价格。对告警类应用来说,重复问题不大,操作做成幂等即可;但记录类应用遇到乱序就很危险,会把旧价格覆盖到新价格上,产生假信号。
防乱序最简单的办法是:收到消息后,拿exchange_time和上一次记录的时间戳做比较,只有新消息的时间戳大于等于上一次时才更新状态。这个方法不能解决所有极端情况,但对常规监控场景完全够用。如果服务商额外提供了sequence或trade_id这类单调递增序号,优先用序号判断数据新旧,它比时间戳更可靠。
4.3 限流与频率控制:别把自己送进"小黑屋"
行情服务商通常会对每个账号做请求频次限制,超过之后返回 429 状态码,或者直接断开连接。很多人觉得"我只有一台机器一个进程,不可能触发限流",但实际操作中很容易踩到两个误区。
第一个误区是把 REST 轮询和 WebSocket 订阅混在一起计算流量。部分套餐限制的是"每账号每分钟 HTTP 请求数",你可能毫不知情地在另一段代码里写了每 5 秒刷新一次合约列表的定时任务,直接把额度吃满。第二个误区是多个进程共用同一把 Key。我见过有人把行情脚本同时部署到两台服务器,忘了改 Key,两边连着同一个账号的 WebSocket 配额,第二台一启动,第一台立刻被踢下线。
正确做法是在代码里显式控制请求频率。简单方式是用time.sleep控制轮询节奏;更体面的写法是实现一个令牌桶节流器。对个人项目来说,先把频率调到服务商文档推荐值的一半,跑一个晚上再慢慢往上加,是最稳妥的方式。
5. 上线前必须做的一次"延迟体检"
5.1 怎么测量一条行情从交易所到你的进程要多久
在把程序部署到正式环境前,强烈建议做一次延迟体检。方法并不复杂:在收到行情消息时,把本机时间和服务端返回的时间戳同时打印出来。
server_time_ms = data.get("server_time", 0) exchange_time_ms = data.get("exchange_time", 0) local_time_ms = int(time.time() * 1000) print(local_time_ms - server_time_ms, server_time_ms - exchange_time_ms)第一段差值代表从服务商出口到你进程的网络耗时和消息排队耗时;第二段差值代表从交易所撮合到服务商出口的耗时。把这两个差值连续记录 100 条以上,去掉最大最小值,取中位数和 P95,你就对自己行情链路的整体水平有了底。
如果 P95 超过 1 秒,就要开始排查了。可能原因无非几种:部署区域离行情服务商太远,正在使用一个被严重过载的低价套餐,或者服务商的"实时"本身就没有做到秒级。
5.2 实测中影响延迟的几个常见因素
从我自己部署的经验看,影响行情延迟最常见的有三个因素。
第一是服务器区域。行情服务商通常有推荐的接入区域,两者网络越近,延迟越低。有人为了省成本把程序部署在离业务区域很远的机房,结果同一条行情,比本地测试多出几百毫秒。
第二是链路里套了太多层代理。如果程序前面还有网关、代理、转发服务,每一层都会增加一小段延迟。个人项目不要自作主张地在行情接口和你自己的程序之间再封装一层服务,除非有多端复用的硬需求。中间环节越少,故障点越少。
第三是操作系统时钟同步。延迟测量严重依赖时间戳,如果你的机器时钟偏差很大,所有延迟结论都会失真。简单的排查方法是找授时服务,连续对比机器时间与服务端时间,确认偏差在几百毫秒以内。超过这个范围,你的延迟体检数据基本不可信。
5.3 从 Demo 到生产:部署时的几个稳健性建议
跑通 Demo 之后,再往下走就是部署了。我给三个强烈建议。
一是行情数据一定要落盘。很多人觉得"我不是做实盘策略,不需要保留历史",这个想法很危险。行情数据是典型的"过期不候"型资产。即便一开始只存原始 JSON,后面要做回测、复盘、分析"当时为什么会触发告警",这些原始数据就是最可靠的证据。建议先用按天分文件的 JSON 存储,数据量上来后再上时序数据库。
二是给你的行情进程做守护。WebSocket 长连接特别怕机器半夜自动重启。建议用一个进程管理工具守护它,并且让主程序支持启动后自动恢复订阅。这样即使进程被杀或机器重启,也能自己回到正常状态。没人愿意凌晨三点爬起来手动重跑脚本。
三是设置"无数据"监控告警。这是所有监控系统里最容易漏掉的一项。可以写一个简单的定时器:超过 30 秒没有收到任何一条行情消息,就推一条告警到你的消息通知渠道。这个信号通常比程序抛出异常更早出现,也更能真实反映数据链路状态。等真正上线之后你会发现,这个告警比行情本身的任何字段都值钱。
我自己在实际调试中的体会是:贵金属期货实时行情的接入,代码量真的不大,大部分时间反而花在弄清楚"你要的是哪一条路的数据"这件事上。先把合约、时间戳、单位这些看起来琐碎的定义定清楚,后面写代码基本一路绿灯。如果你也准备上手,希望这份从选型到上线的过程记录,能帮你少走几趟弯路。