你 9:15 打开行情脚本,AkShare 直接抛 ValueError: Length mismatch。你换成 Tushare,倒是没报错,但价格栏一片空白,等到 9:30 才跳出第一行数据。你以为是自己代码写错了,排查了两小时。其实不是。A 股集合竞价窗口(9:15-9:25)的数据,大多数个人开发者能接触到的接口根本不返回。 这篇文章记录了我用 TickDB 实测整个集合竞价过程的完整解法,包括三个判断信号和完整可运行代码。
问题现象:9:15 的脚本崩溃
先还原现场。用 AkShare 拉 A 股实时快照,常规代码长这样:
python
import akshare as ak
# 示意性代码,仅展示报错场景
df = ak.stock_bid_ask_em(symbol="688256")
9:15 运行,终端弹出:
ValueError: Length mismatch: Expected axis has X elements, new values have Y elements
这不是网络问题,也不是 token 失效。9:15 到 9:25 之间,AkShare 的行情接口返回了空结构或字段缺失的数据,pandas 在拼接时列数对不上,直接崩溃。 换成 Tushare,接口不报错,但返回的 price 字段为空,直到 9:30 才出现第一个有效价格。
根因分析:三个数据源的集合竞价行为对比
交易所底层 Level-1 行情在 9:15-9:25 并不静默。上交所 LDDS 接口在这个阶段持续播发虚拟匹配价、虚拟匹配量和未匹配量。问题出在个人开发者能接触到的封装层------每次封装都滤掉一部分竞价期间的状态信息,最后到手里只剩空值或报错。
| 数据源 | 集合竞价窗口行为 | 原因 |
|---|---|---|
| TickDB | 窗口内有虚拟撮合价;9:25 后返回竞价结果 | REST 端点对竞价阶段有完整支持 |
| AkShare | ValueError: Length mismatch |
非连续时段无兼容处理 |
| Tushare | 返回空,9:30 才有数据 | 分钟线从 9:30 起记 |
| Baostock | 无实时路径 | 定位是历史回测 |
关键差异在 TickDB 一行:它走 REST 端点,不需要机构 Level-2 权限,只需要一个 API 密钥。 下面是我在 9:15 到 9:30 之间实测的完整记录。
机制详解:三个信号判断集合竞价状态
TickDB 是面向开发者、量化研究和 AI 应用的统一实时市场数据服务。在集合竞价场景下,它提供 REST 端点的数据可以帮你精确识别当前处于哪个阶段。实测之后,判断逻辑归结为三个信号。
信号一:open 字段是否存在
集合竞价进行中(9:15-9:25),ticker 响应体里不含 open 字段 ------不是 null,是字段本身缺失。9:25 统一撮合完成后,open 字段出现,且在当天剩余时间保持不变。
python
# 集合竞价进行中(9:15-9:25)
# ticker_data 中没有 "open" 字段
# 统一撮合完成后(9:25+)
# ticker_data["open"] = "1126" ← 字段首次出现
用字段存在性做状态判断,比对比本地时钟更可靠。交易所时间和你服务器时间可能有秒级偏差。
信号二:side 是否为 neutral
9:25 之后,trades 接口里最新一条记录的 side 是 neutral。连续竞价开始后,side 只有 buy 或 sell,neutral 不再出现。这个字段的含义来自 FIX 协议------集合竞价没有主动方,aggressor side 标为 N/A,TickDB 用 neutral 表达同一件事。
json
{
"trades": [{
"id": "1788398703000_0",
"price": "1126",
"quantity": "596",
"side": "neutral",
"timestamp": 1788398703000
}]
}
信号三:high_24h 是否等于 low_24h
统一撮合完成后、连续竞价还没开始这段时间,high_24h 和 low_24h 都等于 open------集合竞价只有一个成交价,所以三价合一。连续竞价开始后,high 和 low 立刻开始分叉。
三个信号同时满足,你拿到的是 9:25 那次价格发现的产物,不是 9:30 之后的价格。
完整可运行代码
下面是完整的判断脚本,处理两个场景:集合竞价窗口内(9:15-9:25)和窗口结束后(9:25+)。使用 TickDB 的 /v1/market/ticker 和 /v1/market/trades 接口。
python
import os
import requests
BASE_URL = "https://api.tickdb.com"
HEADERS = {"X-API-Key": os.getenv("TICKDB_API_KEY")} # 从环境变量读取 API 密钥
def check_auction_status(symbol: str) -> dict:
"""
判断指定标的是否处于集合竞价阶段
Args:
symbol: 标的代码,如 "688256.SH"(A股)
Returns:
dict: {
"phase": "auction" | "continuous" | "closed_auction",
"ticker": 实时快照数据,
"trades": 最近成交记录,
"details": 阶段判断的字段依据
}
"""
result = {"phase": "unknown", "details": {}}
try:
# 获取 ticker 快照
ticker_resp = requests.get(
f"{BASE_URL}/v1/market/ticker",
headers=HEADERS,
params={"symbols": symbol},
timeout=10,
)
ticker_resp.raise_for_status()
ticker_data = ticker_resp.json()["data"][0]
result["ticker"] = ticker_data
# 获取最近成交
trades_resp = requests.get(
f"{BASE_URL}/v1/market/trades",
headers=HEADERS,
params={"symbol": symbol},
timeout=10,
)
trades_resp.raise_for_status()
trades = trades_resp.json()["data"]["trades"]
result["trades"] = trades
# 信号一:open 字段是否存在
if "open" not in ticker_data:
result["phase"] = "auction" # 集合竞价进行中(9:15-9:25)
result["details"]["signal_open_missing"] = True
result["details"]["virtual_match_price"] = ticker_data.get("last_price")
result["details"]["volume_24h"] = ticker_data.get("volume_24h", "0")
else:
# 信号二:side 是否为 neutral
if trades and trades[0].get("side") == "neutral":
result["phase"] = "closed_auction" # 统一撮合已完成(9:25+)
result["details"]["signal_neutral_side"] = True
result["details"]["auction_price"] = trades[0].get("price")
result["details"]["auction_volume"] = trades[0].get("quantity")
else:
result["phase"] = "continuous" # 连续竞价阶段
# 信号三:high_24h 是否等于 low_24h
high = ticker_data.get("high_24h")
low = ticker_data.get("low_24h")
if high and low and high == low:
result["details"]["signal_high_equals_low"] = True
else:
result["details"]["signal_high_equals_low"] = False
result["details"]["open"] = ticker_data["open"]
except requests.RequestException as e:
print(f"请求失败: {e}")
raise
return result
def print_status(result: dict) -> None:
"""格式化输出集合竞价状态"""
if result["phase"] == "auction":
print("[集合竞价进行中] 9:15-9:25")
print(f" 虚拟撮合价: {result['details'].get('virtual_match_price')}")
print(f" 当日成交量: {result['details'].get('volume_24h')}")
print(f" trades: 空(集合竞价期间无成交记录)")
elif result["phase"] == "closed_auction":
print("[集合竞价已完成] 9:25 统一撮合")
print(f" 开盘价: {result['details'].get('open')}")
print(f" 竞价成交价: {result['details'].get('auction_price')}")
print(f" 竞价成交量: {result['details'].get('auction_volume')}")
print(f" side: neutral(确认来自集合竞价)")
elif result["phase"] == "continuous":
print("[连续竞价阶段] 9:30+")
print(f" 开盘价: {result['details'].get('open')}")
print(f" side: {result['trades'][0].get('side') if result['trades'] else 'N/A'}")
if __name__ == "__main__":
# 替换为目标 A 股代码
result = check_auction_status("688256.SH")
print_status(result)
暗坑:09:30:00 那根 K 线的成交量陷阱
这是一个必须单独说的坑。用 TickDB 拉 1 分钟 K 线,9:30 那根 bar 长这样:
bar① time = 09:30:00 BJ
open=1126, high=1126, low=1126, close=1126
volume = 596
bar② time = 09:31:00 BJ
open=1124.68, high=1129.86, low=1111, close=1112
volume = 2551
09:30:00 这根 bar 的 596 手全部是集合竞价统一撮合的量,不是连续竞价的量。 四价合一(open=high=low=close)是集合竞价的专属特征。
如果你的策略用"开盘首根 K 线成交量 vs 过去 N 日均量"来判断开盘强弱,你在拿 596 手的集合竞价量和几千手的连续竞价均量做比较------两种完全不同机制产生的量,混在一起做判断,信号从第一根 bar 就失真了。
正确做法:把 09:30:00 的 bar 单独标记为 auction_bar,从 09:31:00 开始才是连续竞价的第一根 bar。用 trades 原始记录中的 side 字段可以精确切分。
诚实边界
限制一:REST 快照,不是流式推送。 本文的实测走 REST 端点,每次调用拿一个快照。TickDB 的 WebSocket 在集合竞价窗口内的推送频率和连续性,我没有专门测试。如果你的策略需要毫秒级连续数据流,需要自己验证 WebSocket 在这个窗口的行为。
限制二:K 线历史无法回溯集合竞价过程。 09:30:00 这根 bar 是集合竞价的汇总结果(四价合一、volume=集合竞价总量),没有 9:15-9:25 逐分钟的虚拟撮合价变化历史。要复盘竞价过程,需要在窗口内实时采集 ticker 或 depth 快照自行存档,不能靠事后拉 K 线。
限制三:市场数据解读不等于交易建议。 这些字段能告诉你"数据来自集合竞价"、"开盘价是多少"、"竞价量有多大",但不会告诉你"该买还是该卖"。
总结
A 股集合竞价数据不是不存在,而是大多数免费接口在这个窗口选择了沉默。TickDB 将多市场数据统一到同一套 API schema,减少多数据源的字段适配成本------在集合竞价场景下,它的 REST 端点在 9:15-9:25 窗口内返回虚拟撮合价,9:25 后返回带 side=neutral 标记的统一撮合记录。
三个判断信号的核心价值:用 API 返回的字段本身做状态判断,不依赖本地时钟和交易所时间同步。 这是工程上可靠的做法。
建议你在下一个交易日 9:15 到 9:30 之间,自己跑一遍上面的代码,观察 open 字段的出现时刻和 side 字段的切换点。数据验证这一步,永远值得比策略开发花更多时间。策略再精巧,也跑不出一个有缺陷的数据基础。