股票的世界很规整:一只股票只在一个交易所撮合,每天九点半开盘、下午收盘,收盘后你尽可以从容跑日终任务。可原油、黄金、天然气这些大宗商品完全不是这个节奏,它们几乎全天候在交易,周一到周五近乎 24 小时不停,只在周末休市;更麻烦的是,交易时段还会随着欧美的夏令时、冬令时来回平移一小时。你按股票的思路写个「北京时间下午三点抓收盘价」的定时任务,放到黄金上直接就废了,因为那个时刻黄金正在剧烈交易,根本没有收盘价这回事。
再加上大宗商品之间盘根错节的联动,原油涨、通胀预期升、黄金跟着动;美元走强、以美元计价的商品普遍承压,想做一点像样的分析,你得同时、实时地盯住好几个品种。这篇文章就讲清楚:大宗商品实时行情到底该怎么接,原油、黄金、天然气的代码怎么写,交易时段和夏令时这些坑怎么绕,以及怎么用一套接口把它们和汇率、股指放在一起统一处理。
一、大宗商品行情,和股票行情差在哪
动手写代码前,先把这个市场的三个「反直觉」特征搞清楚,后面的设计才不会跑偏。
第一,没有统一的「开盘收盘」,近乎全天交易。 大宗商品(尤其是以差价合约 / 现货形式报价的原油、贵金属)在全球市场接力交易,工作日几乎 24 小时连续。它不像股票有明确的收盘价,也不像加密货币那样连周末都不停,它是「工作日近乎全天 + 周末休市」的混合节奏。这决定了你的采集程序既要能长时间在线,又要能正确识别周末与节假日的休市空窗。
第二,交易时段随夏令时平移。 因为报价时区锚定在 GMT,而欧美每年在三月、十一月切换夏令时/冬令时,大宗商品的每日交易起止时间会整体前移或后移一小时。比如美原油,夏令时是周日 22:00 开始到周五 21:00(GMT),冬令时则变成周日 23:00 到周五 22:00。如果你把交易时段硬编码成固定的 UTC 小时,一年里就有两段时间会判断错误。
第三,品种之间强联动,需要「一把抓」。 分析原油离不开天然气、离不开美元指数;分析黄金要看白银(金银比)、看实际利率。所以大宗商品的数据接入,几乎天然就是多品种、跨类别的需求,你很少只盯一个品种。
正因如此,与其分别去对接不同的商品交易所或数据源,不如用一个把能源、贵金属、外汇、期货统一成同一套格式的行情 API,一次请求就把一篮子品种全查回来。下面用一套真实可跑的接口演示。
二、接口概览:能源、贵金属、期货,同一套接口
我们以 Infoway API 的接口为例。它的一个便利之处是:股票、加密货币走各自的专用前缀,而外汇、能源、贵金属、期货这几类「非股票」品种统一走 /common/ 前缀 ,WebSocket 则统一用 business=common。也就是说,你写一套代码,就能同时拿原油、黄金和欧元汇率。
常用的大宗商品代码(可调用产品列表接口,分别传 type=ENERGY、type=METAL、type=FUTURES 获取完整清单):
| 类别 | 品种 | 代码 |
|---|---|---|
| 能源 | 美原油(WTI) | USOIL / WTI |
| 能源 | 布伦特原油 | UKOIL |
| 能源 | 天然气 | NGAS |
| 贵金属 | 黄金 | XAUUSD |
| 贵金属 | 白银 | XAGUSD |
| 贵金属 | 铂金 / 钯金 | XPTUSD / XPDUSD |
| 基本金属 | 现货铜 / 镍 / 铅 | XCUUSD / XNIUSD / XPBUSD |
| 期货 | 国际股指、国债、外汇期货等 | 采用「主力连续合约」代码(形如 6E1!、10Y1!,1! 表示自动拼接的近月连续合约) |
这里顺带说一个对期货用户很关键的点:代码里 1! 后缀代表主力连续合约,数据源已经帮你把不同到期月份的合约按主力换月规则自动拼接好了。这意味着你不用自己处理「合约到期、移仓换月、价格跳空」这套让无数人头疼的展期逻辑,直接拿连续序列做分析即可。
三、REST 接口:查实时价格、盘口与历史K线
三个接口共用一个放在请求头里的 apiKey。注意大宗商品全部走 /common/ 路径。
3.1 实时成交明细:一次拿一篮子价格
python
import requests
BASE = "https://data.infoway.io"
HEADERS = {"apiKey": "你的APIKey"}
def get_trades(codes):
"""查询大宗商品最新成交明细,codes 逗号分隔,最多 100 个"""
url = f"{BASE}/common/batch_trade/{codes}"
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
return resp.json()["data"]
# 一次把原油、黄金、天然气、白银全查回来
for item in get_trades("USOIL,XAUUSD,NGAS,XAGUSD"):
print(f'{item["s"]:8} 价格={item["p"]:>12} 成交量={item["v"]:>10} '
f'时间={item["t"]}')
返回的每条记录里,s 是品种代码,t 是毫秒 时间戳,p 是最新价,v 是成交量,vw 是成交额。因为一次可以查上百个代码,把相关联的品种放在同一次请求里,既省配额又能保证它们的时间戳基本对齐,方便做联动分析。
3.2 盘口深度:估算滑点
python
def get_depth(codes):
url = f"{BASE}/common/batch_depth/{codes}"
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
return resp.json()["data"]
for d in get_depth("XAUUSD"):
ask_px, bid_px = d["a"][0], d["b"][0] # 卖盘、买盘的价格数组
best_ask, best_bid = float(ask_px[0]), float(bid_px[0])
print(f'{d["s"]} 买一={best_bid} 卖一={best_ask} '
f'点差={best_ask - best_bid:.3f}')
盘口里 a 是卖盘、b 是买盘,各自是「价格数组 + 挂单量数组」两个平行数组,下标一一对应。对大宗商品来说,点差(买一卖一之差)是衡量流动性和交易成本的核心指标,下单前用它估算滑点很有必要。
3.3 历史K线:主力连续合约的长序列
回测和画图要用历史 K 线。接口是 POST,klineType 指定周期(1=1 分钟,5=1 小时,8=日 K......),klineNum 指定数量。
有一个必须记住的限制:单个品种一次最多取 500 根;但一次传多个品种时,每个只返回最近 2 根。所以「批量查最新」和「拉深度历史」不可兼得,要深历史,就得一个品种一个品种地翻页。
python
import time
def get_kline(code, kline_type=8, num=500, end_ts=None):
body = {"klineType": kline_type, "klineNum": num, "codes": code}
if end_ts:
body["timestamp"] = end_ts # 秒级时间戳,向前翻历史
url = f"{BASE}/common/v2/batch_kline"
resp = requests.post(url, headers=HEADERS, json=body, timeout=15)
resp.raise_for_status()
return resp.json()["data"][0]["respList"]
def fetch_history(code, kline_type=8, total=2000):
"""翻页拉取更长历史:每页 500 根,用最老一根的时间戳继续向前"""
bars, end_ts = [], None
while len(bars) < total:
page = get_kline(code, kline_type, 500, end_ts)
if not page:
break
bars.extend(page)
end_ts = int(page[-1]["t"]) - 1 # respList 从新到旧,取最老一根往前翻
time.sleep(0.3) # 翻页间歇,避免触发限流
return bars[:total]
daily = fetch_history("XAUUSD", kline_type=8, total=2000)
print(f"黄金日K共 {len(daily)} 根,最新收盘 = {daily[0]['c']}")
K 线返回是按时间从新到旧排列 的,翻页时把当前页最老一根的时间戳减 1 传给 timestamp,即可继续往历史深处走。要注意:只有分钟 K、小时 K 支持 timestamp 向前翻页,日 K 及以上周期不受此限制。因为代码用的是主力连续合约,拉回来的历史序列是已经处理过换月的连续价格,可以直接算指标、跑回测。
四、实战:交易时段判断与「金油比」联动监控
把前面的接口拼起来,来解决两个大宗商品特有的实际问题。
4.1 正确判断「现在是不是交易时段」
这是新手最容易栽的地方。以美原油为例,它工作日近乎全天交易,但每天有一个短暂的结算休市,且交易起止时间随夏令时平移。与其自己硬编码时段,更稳妥的做法是「用数据判断」:如果最新成交的时间戳距现在超过了一个合理阈值(比如几分钟),就认为当前不在活跃交易时段。
python
# 用真实数据来源时间做判断,天然免疫夏令时切换------因为你不去猜时段,
# 而是看「最近一笔成交距现在有多久」。
def is_market_active(code, stale_seconds=300):
"""最近一笔成交在 stale_seconds 内,则认为处于活跃交易时段"""
import time as _t
data = get_trades(code)
if not data:
return False
last_ms = data[0]["t"] # 毫秒时间戳
age = _t.time() - last_ms / 1000
return age < stale_seconds
print("美原油当前是否在交易:", is_market_active("USOIL"))
这个思路的妙处在于:你完全不需要维护一张「夏令时几点到几点、冬令时几点到几点」的时段表,也不用关心周末和节假日休市,数据本身的新鲜度就是最可靠的信号。夏令时切换、临时休市、节假日,全都自动兼容。
4.2 监控「金油比」:一个经典的大宗商品联动指标
金油比(黄金价格 ÷ 原油价格)是宏观交易里常被拿来观察的比值:它大致反映避险情绪与通胀预期的此消彼长。一次请求把两个品种查回来,算比值即可。
python
def gold_oil_ratio():
data = {d["s"]: float(d["p"]) for d in get_trades("XAUUSD,USOIL")}
gold, oil = data["XAUUSD"], data["USOIL"]
ratio = gold / oil
print(f"黄金={gold} 原油={oil} 金油比={ratio:.1f}")
return ratio
gold_oil_ratio()
因为两个价格来自同一次请求、时间戳几乎一致,算出来的比值不会有「一个是刚才的价、一个是几分钟前的价」那种时间错配问题。要盯盘的话,把它放进一个循环里定时跑,或者干脆改用 WebSocket 订阅实时推送。
五、WebSocket 实时订阅:让一篮子商品持续推送
要持续跟踪而不是查一次快照,就该上 WebSocket。大宗商品走 business=common。协议约定很清晰:客户端用某协议号发订阅请求,服务端用「请求号 +1」确认、用「请求号 +2」推送数据。
| 数据 | 订阅请求码 | 确认码 | 推送码 |
|---|---|---|---|
| 成交明细 | 10000 | 10001 | 10002 |
| 盘口深度 | 10003 | 10004 | 10005 |
| K 线 | 10006 | 10007 | 10008 |
| 心跳 | 10010 | --- | 10010 |
下面是一个生产可用的 Python 客户端,重点做对三件事:断线指数退避重连、定时心跳、以及重连后必须重新订阅。
python
import asyncio
import json
import uuid
import logging
import websockets
from websockets.exceptions import ConnectionClosed
logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger("commodity-ws")
REQ_TRADE, REQ_DEPTH, REQ_KLINE, REQ_HEARTBEAT = 10000, 10003, 10006, 10010
PUSH_CONNECTED = 200
PUSH_TRADE, PUSH_DEPTH, PUSH_KLINE = 10002, 10005, 10008
ACK_CODES = {10001, 10004, 10007}
class CommodityWSClient:
def __init__(self, api_key, symbols="USOIL,XAUUSD,NGAS"):
# 大宗商品统一走 business=common
self.url = f"wss://data.infoway.io/ws?business=common&apikey={api_key}"
self.symbols = symbols
self.ws = None
self.running = True
self.heartbeat_interval = 30
self.reconnect_base, self.reconnect_max = 5, 60
async def _send(self, msg):
await self.ws.send(json.dumps(msg))
async def _subscribe_all(self):
"""连接建立后、以及每次重连后都要重新订阅"""
t = lambda: str(uuid.uuid4())
await self._send({"code": REQ_TRADE, "trace": t(),
"data": {"codes": self.symbols}})
await self._send({"code": REQ_DEPTH, "trace": t(),
"data": {"codes": self.symbols}})
await self._send({"code": REQ_KLINE, "trace": t(),
"data": {"arr": [{"type": 1, "codes": self.symbols}]}})
logger.info("已订阅:%s", self.symbols)
async def _heartbeat(self):
try:
while True:
await asyncio.sleep(self.heartbeat_interval)
if self.ws and self.ws.close_code is None:
await self._send({"code": REQ_HEARTBEAT, "trace": str(uuid.uuid4())})
except (ConnectionClosed, asyncio.CancelledError):
pass
def _dispatch(self, message):
try:
msg = json.loads(message)
except json.JSONDecodeError:
return
code, data = msg.get("code"), msg.get("data", {})
if code == PUSH_CONNECTED:
logger.info("连接成功: %s", msg.get("msg"))
elif code == PUSH_TRADE:
logger.info("成交: %s", data)
elif code == PUSH_DEPTH:
logger.info("盘口: %s", data)
elif code == PUSH_KLINE:
logger.info("K线: %s", data)
elif code in ACK_CODES:
logger.info("订阅确认 code=%s", code)
async def _connect_once(self):
async with websockets.connect(self.url) as ws:
self.ws = ws
logger.info("WebSocket 已连接")
await self._subscribe_all() # 关键:连上立即订阅
hb = asyncio.create_task(self._heartbeat())
try:
async for message in ws:
self._dispatch(message)
finally:
hb.cancel()
self.ws = None
async def start(self):
backoff = self.reconnect_base
while self.running:
try:
await self._connect_once()
backoff = self.reconnect_base
except Exception as e:
logger.warning("连接断开: %s,%s 秒后重连", e, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, self.reconnect_max) # 指数退避
if __name__ == "__main__":
client = CommodityWSClient(api_key="你的APIKey",
symbols="USOIL,UKOIL,XAUUSD,XAGUSD,NGAS")
asyncio.run(client.start())
几个关键设计和大宗商品场景直接相关:
- 全程单连接 + 指数退避。 断线后千万别开并行的重连任务,一瞬间冒出好几条连接,很容易被服务端当异常流量限流。始终只维护一条连接,断了退避重连、退避时间翻倍封顶。
- 重连后一定重新订阅。 WebSocket 连接不携带订阅状态,旧订阅随断线消失。每次建连都调
_subscribe_all(),否则会出现「连着但收不到数据」的怪象。 - 周末休市不是故障。 大宗商品周末不交易,此时收不到推送是正常现象,不要把它误判成连接故障而疯狂重连。可以用第四节的「数据新鲜度」思路来区分「休市」与「真断线」。
六、常见问题(FAQ)
原油、黄金这些大宗商品,交易时间到底怎么算?
它们工作日近乎 24 小时连续交易、周末休市,每日交易起止时间锚定 GMT,并随欧美夏令时/冬令时平移一小时。最稳妥的做法不是硬编码时段表,而是用「最近一笔成交距现在多久」来判断是否处于活跃交易时段,天然兼容夏令时切换和节假日。
为什么代码里带 1!?和普通合约有什么区别?
1! 表示主力连续合约,数据源已按主力换月规则把不同到期月份自动拼接成一条连续序列。你不用自己处理合约到期、移仓换月和跳空,直接拿来做指标和回测即可。现货类品种(如 XAUUSD、USOIL)则没有这个后缀。
能同时拿原油、黄金、汇率、股指吗?
可以,而且用同一套接口。外汇、能源、贵金属、期货都走 /common/ 前缀,WebSocket 用 business=common,一次请求最多可查上百个代码。做金油比、金银比、美元与商品联动这类分析特别方便。
REST 和 WebSocket 怎么选?
要「此刻一个快照」(如计算一次金油比、下单前确认价格)用 REST 查一次;要「持续实时流」(盯盘、看板、策略)用 WebSocket 订阅。别用高频轮询 REST 去模拟实时流,既费配额又容易触发限流。
历史数据能拉多深?可回测吗?
用 K 线接口翻页拉取,单品种每页 500 根,靠时间戳向前翻可以取到很长的历史,配合主力连续合约足够做回测。可回溯深度取决于套餐权限;多品种同查时每个只返回最近 2 根,深历史要逐个品种拉。
时间戳精度要注意什么?
成交明细和盘口的时间戳 t 是毫秒 级,K 线的 t 是秒级,两者混用会导致时间对不齐。做跨数据类型的对齐时记得先统一单位。
大宗商品行情的接入难点,不在于查一个价格,而在于它那套「近乎全天交易、随夏令时平移、品种彼此联动」的独特节奏。把交易时段交给数据新鲜度去判断、把换月交给主力连续合约去处理、再用一套 /common/ 接口把原油、黄金、天然气和汇率一并拿下,剩下的精力就可以还给真正重要的分析与策略了。