首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >A股实时数据API(记录踩过的坑)

A股实时数据API(记录踩过的坑)

原创
作者头像
Always_Somewhere
发布2026-06-19 15:58:53
发布2026-06-19 15:58:53
100
举报

很多开发者第一次接 A 股行情数据的时候,以为和接美股差不多,无非是换个接口地址,改下代码。然后就会发现:分钟 K 线中间有两小时的空洞,历史价格在某一天突然跳了 30%,WebSocket 每天下午一点才重新有数据,策略回测的信号永远慢一天……

这些坑几乎每个人都会踩。本文从 A 股市场结构出发,结合实际接口代码,把这些容易被忽略的细节讲清楚。


一、A 股代码规则:不查文档就能判断板块

A 股有四大交易场所:上海证券交易所(SSE/上交所)、深圳证券交易所(SZSE/深交所)、北京证券交易所(BSE/北交所),以及面向上市公司但不单独开市的全国股转系统(新三板)。

股票代码后缀:上交所用 .SH,深交所用 .SZ,北交所用 .BJ

代码前缀本身就包含板块信息,不需要调接口就能判断:

代码前缀

交易所

板块

60xxxx

上交所

沪市主板(A 股)

688xxx

上交所

科创板(注册制,±20% 涨跌幅)

689xxx

上交所

科创板 CDR

000xxx / 001xxx

深交所

深市主板

002xxx / 003xxx

深交所

原中小板(2021 年并入主板)

300xxx / 301xxx

深交所

创业板(注册制,±20% 涨跌幅)

83xxxx / 87xxxx

北交所

北交所正式上市

43xxxx

北交所

北交所精选层

ST 股(特别处理)不通过前缀识别,而是通过股票名称中的 ST 标记,代码不变但涨跌幅限制收窄为 ±5%。

用 Python 写一个快速识别函数:

代码语言:python
复制
def classify_a_share(symbol: str) -> dict:
    """
    从股票代码识别板块信息,无需调用接口。
    symbol 格式:600519.SH / 300750.SZ / 688599.SH
    """
    code = symbol.split(".")[0]
    suffix = symbol.split(".")[-1] if "." in symbol else ""

    if code.startswith("6") and not code.startswith("688") and not code.startswith("689"):
        board, limit = "沪市主板", 0.10
    elif code.startswith("688") or code.startswith("689"):
        board, limit = "科创板", 0.20
    elif code.startswith("000") or code.startswith("001") or code.startswith("002") or code.startswith("003"):
        board, limit = "深市主板", 0.10
    elif code.startswith("300") or code.startswith("301"):
        board, limit = "创业板", 0.20
    elif code.startswith("83") or code.startswith("87") or code.startswith("43"):
        board, limit = "北交所", 0.30
    else:
        board, limit = "未知", 0.10

    return {"code": code, "suffix": suffix, "board": board, "daily_limit": limit}

print(classify_a_share("600519.SH"))   # 茅台 → 沪市主板, ±10%
print(classify_a_share("300750.SZ"))   # 宁德时代 → 创业板, ±20%
print(classify_a_share("688599.SH"))   # 天合光能 → 科创板, ±20%

二、交易时间:比想象中更复杂

两段式 + 收盘竞价

A 股不是简单的 9:30–15:00 连续交易,实际分为四个时间段:

阶段

时间

说明

开盘集合竞价

9:15–9:25

价格发现,产生当日开盘价;9:20–9:25 不允许撤单

连续竞价(上午)

9:30–11:30

正常撮合交易

午休

11:30–13:00

完全暂停,长达 1.5 小时

连续竞价(下午)

13:00–14:57

正常撮合交易

收盘集合竞价

14:57–15:00

产生收盘价;期间订单可撤,15:00 最终定价

这里有几个开发者经常忽略的细节:

1. API 返回的交易时间段是 13:00-14:57,不是 15:00。最后三分钟的收盘竞价产生的成交数据会并入 14:57 这根分钟 K,或以独立方式处理,具体取决于数据源。如果你在 14:58 调用实时成交接口,拿到的 t 时间戳可能仍然是 14:57 的。

2. 分钟 K 线中会有 1.5 小时的空洞(11:30–13:00)。如果你在 backtest 框架里用等间距索引,这段空洞会被错误地填充为"价格没有变化",导致均线等指标的计算偏差。正确做法是用交易日历过滤出有效的分钟 bar。

3. 开盘集合竞价(9:15–9:25)期间的行情数据性质不同:这段时间内没有真实成交,只有委托的"虚拟"价格。如果你的 WebSocket 在 9:15 之后才订阅,可能会收到竞价阶段的推送,不要把它当作连续竞价的交易数据处理。


三、行情接口实战:成交、盘口、K 线

A 股(沪市 + 深市)使用 /stock/ 接口路径,与美股、港股共用同一套接口体系,通过股票代码后缀区分市场。

3.1 实时成交明细

代码语言:python
复制
import requests

API_KEY = "YOUR_API_KEY"
BASE    = "https://data.infoway.io"

# A 股代码格式:600519.SH(沪市)或 000858.SZ(深市)
codes = "600519.SH,000858.SZ,300750.SZ,688599.SH"

resp = requests.get(
    f"{BASE}/stock/batch_trade/{codes}",
    headers={"apiKey": API_KEY, "Accept": "application/json"}
)
for tick in resp.json()["data"]:
    direction = {0: "—", 1: "↑买", 2: "↓卖"}.get(tick["td"], "?")
    print(f"{tick['s']:<15} 价格={tick['p']:>8}  成交量={tick['v']:>10}手  方向={direction}")

注意:返回的 v 字段是股数(股),不是手数。A 股 1 手 = 100 股,显示时记得换算:

代码语言:python
复制
shares = int(tick["v"])
lots   = shares // 100
print(f"成交 {lots} 手 ({shares} 股)")

返回字段:

字段

说明

s

股票代码(含后缀)

t

成交时间戳(毫秒,UTC)

p

最新成交价(元,精度 0.01)

v

成交量(,非手)

vw

成交额(元)

td

方向:0=默认,1=主买,2=主卖

3.2 实时盘口

代码语言:python
复制
resp = requests.get(
    f"{BASE}/stock/batch_depth/600519.SH",
    headers={"apiKey": API_KEY, "Accept": "application/json"}
)
book = resp.json()["data"][0]

asks = list(zip(book["a"][0], book["a"][1]))
bids = list(zip(book["b"][0], book["b"][1]))

print("卖盘(ask)")
for price, vol in asks[:5]:
    print(f"  {price:>8} 元   {int(vol)//100:>6} 手")

spread = float(asks[0][0]) - float(bids[0][0])
print(f"\n  价差:{spread:.2f} 元\n")

print("买盘(bid)")
for price, vol in bids[:5]:
    print(f"  {price:>8} 元   {int(vol)//100:>6} 手")

a[0]a[1] 是两个平行数组,分别是卖盘价格和卖盘量,通过下标对应(index 0 是最优卖一);b[0] / b[1] 同理对应买盘。


四、历史 K 线:三个容易踩的坑

坑一:分钟 K 线有时间空洞

A 股每天有效的分钟 bar 只有 241 根(9:30–11:30 共 120 根,13:00–14:57 共 117 根,加上集合竞价/收盘竞价各 1-2 根)。如果你按等间距索引处理,11:30 到 13:00 之间会产生 90 个"假的"空 bar。

建议在数据清洗阶段就过滤掉非交易时间的 bar:

代码语言:python
复制
from datetime import time

def is_valid_cn_bar(ts_seconds: int) -> bool:
    """判断秒时间戳(UTC)是否落在 A 股交易时段内(转为北京时间 UTC+8)。"""
    from datetime import datetime, timezone, timedelta
    CST = timezone(timedelta(hours=8))
    t = datetime.fromtimestamp(ts_seconds, tz=CST).time()
    morning   = time(9, 30) <= t < time(11, 30)
    afternoon = time(13, 0) <= t < time(15, 0)
    return morning or afternoon

坑二:不做复权处理,历史价格会突然跳变

A 股上市公司送股、分红、配股频繁,除权后股价会出现跳变。如果你直接用未复权的历史价格计算均线或动量指标,会在除权日前后产生虚假信号。

以贵州茅台(600519.SH)为例:2020 年每股分红 17.02 元,除权日前后原始价格会出现约 1.3% 的向下跳变,这不是市场行情,而是数学事件。

前复权(backward adjusted)是回测中最常用的方式:把历史所有价格乘以复权因子,使价格序列连续。获取复权因子:

代码语言:python
复制
import requests

def get_adjustment_factors(symbol: str, begin_day: str, end_day: str) -> list[dict]:
    resp = requests.get(
        "https://data.infoway.io/common/basic/symbols/adjustment_factors",
        params={"symbol": symbol, "market": "CN",
                "beginDay": begin_day, "endDay": end_day},
        headers={"apiKey": "YOUR_API_KEY"}
    )
    return resp.json().get("data", [])

factors = get_adjustment_factors("600519.SH", "20200101", "20261231")
# 返回每个交易日的 forward_factor(前复权因子)

# 应用复权因子到 K 线数据
factor_map = {f["trade_date"]: f["forward_factor"] for f in factors}

def apply_forward_adj(candle: dict, trade_date: str) -> dict:
    factor = factor_map.get(trade_date, 1.0)
    return {
        **candle,
        "o": str(round(float(candle["o"]) * factor, 4)),
        "h": str(round(float(candle["h"]) * factor, 4)),
        "l": str(round(float(candle["l"]) * factor, 4)),
        "c": str(round(float(candle["c"]) * factor, 4)),
    }

坑三:涨跌停日的 K 线形态异常

当股票涨停(或跌停)后,连续竞价阶段成交量极少甚至为零,价格全天锁死在涨停价。这会产生上影线/下影线极短或为零的特殊 K 线形态。如果你的策略对影线有假设(比如"长上影线意味着压力"),需要特别处理涨跌停日的 bar。

下面是完整的历史 K 线下载函数,带翻页:

代码语言:python
复制
import requests, json, time as _time

def fetch_cn_klines(symbol: str, kline_type: int = 8,
                    count: int = 500, until_ts: int | None = None) -> list[dict]:
    payload = {"klineType": kline_type, "klineNum": count, "codes": symbol}
    if until_ts:
        payload["timestamp"] = until_ts
    r = requests.post(
        "https://data.infoway.io/stock/v2/batch_kline",
        headers={"apiKey": "YOUR_API_KEY", "Content-Type": "application/json"},
        data=json.dumps(payload)
    )
    r.raise_for_status()
    for item in r.json().get("data", []):
        if item["s"] == symbol:
            return item.get("respList", [])
    return []

def download_full_history(symbol: str) -> list[dict]:
    """翻页下载全部日 K 线历史。"""
    all_bars: list[dict] = []
    cursor: int | None = None

    while True:
        batch = fetch_cn_klines(symbol, kline_type=8, count=500, until_ts=cursor)
        if not batch:
            break
        all_bars.extend(batch)
        oldest = int(batch[-1]["t"])
        if cursor and oldest >= cursor:
            break
        cursor = oldest
        _time.sleep(0.3)

    all_bars.sort(key=lambda b: int(b["t"]))
    return all_bars

bars = download_full_history("600519.SH")
print(f"茅台日K共 {len(bars)} 根,最早 {bars[0]['t']},最新 {bars[-1]['c']} 元")

K 线字段说明:

字段

说明

t

K 线开盘时间(Unix 秒,UTC)

o / h / l / c

开 / 高 / 低 / 收(元)

v

成交量(,注意换算成手)

vw

成交额(元)

pc

较前一根 K 的涨跌幅(%)

pca

较前一根 K 的涨跌额(元)


五、涨跌停识别算法

A 股涨跌停规则因板块不同而异,总结如下:

板块

涨跌幅限制

备注

沪/深主板

±10%

ST 股收窄为 ±5%

创业板(注册制)

±20%

2020 年改革后

科创板

±20%

上市前 5 日无限制

北交所

±30%

退市整理期

±10%(仅下跌)

实际上可以上涨但不超过 10%

涨跌停价格计算(四舍五入至分,即 0.01 元精度):

代码语言:python
复制
import math

def calc_limit_prices(prev_close: float, board: str,
                      is_st: bool = False) -> tuple[float, float]:
    """
    返回 (涨停价, 跌停价)。
    board 参考接口返回的 board 字段:
      SHMainConnect / SHMainNonConnect / SZMainConnect / SZMainNonConnect
      SHSTAR / SZGEMConnect / SZGEMNonConnect
    """
    if is_st:
        pct = 0.05
    elif board in ("SHSTAR", "SZGEMConnect", "SZGEMNonConnect"):
        pct = 0.20
    else:
        pct = 0.10

    # 交易所规则:涨停价向下取整到分,跌停价向上取整到分
    up   = math.floor(prev_close * (1 + pct) * 100) / 100
    down = math.ceil(prev_close  * (1 - pct) * 100) / 100
    return up, down

# 示例:茅台前收 1800.00 元,主板 ±10%
up, down = calc_limit_prices(1800.00, "SHMainConnect")
print(f"涨停价:{up:.2f}  跌停价:{down:.2f}")
# → 涨停价:1980.00  跌停价:1620.00

从实时成交数据判断是否触板

代码语言:python
复制
def is_limit(current_price: float, prev_close: float,
             board: str, is_st: bool = False) -> str:
    up, down = calc_limit_prices(prev_close, board, is_st)
    if abs(current_price - up) < 0.005:
        return "涨停"
    if abs(current_price - down) < 0.005:
        return "跌停"
    return "正常"

一个重要的细节:涨停板不等于无成交。创业板/科创板实行"涨停后仍可撤单"机制,股票触及 20% 涨停后,挂单方可以撤单,导致涨停打开再封回的"开板"行为。判断是否"封板"需要结合盘口数据:如果卖一挂单量为零,说明已封板。


六、WebSocket 实时行情订阅

A 股 WebSocket 接入地址与美股、港股相同:

代码语言:python
复制
wss://data.infoway.io/ws?business=stock&apikey=YOUR_API_KEY

以下是一个处理 A 股特殊场景的完整 Python 客户端,重点处理了午休期间的静默状态和涨跌停日志:

代码语言:python
复制
import asyncio, json, uuid, logging
from datetime import datetime, time, timezone, timedelta
from typing import Optional
import websockets
from websockets.exceptions import ConnectionClosed

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("a-share-ws")

CST = timezone(timedelta(hours=8))

def cn_market_status() -> str:
    """返回当前 A 股市场状态。"""
    now = datetime.now(CST)
    if now.weekday() >= 5:
        return "休市"
    t = now.time()
    if time(9, 15) <= t < time(9, 30):
        return "集合竞价"
    if time(9, 30) <= t < time(11, 30):
        return "上午交易"
    if time(11, 30) <= t < time(13, 0):
        return "午休"
    if time(13, 0) <= t < time(14, 57):
        return "下午交易"
    if time(14, 57) <= t < time(15, 0):
        return "收盘竞价"
    return "休市"

# 监控的股票列表(示例:茅台、宁德时代、中芯国际、比亚迪)
SYMBOLS = "600519.SH,300750.SZ,688981.SH,002594.SZ"


class AShareClient:
    def __init__(self, api_key: str):
        self.url = f"wss://data.infoway.io/ws?business=stock&apikey={api_key}"
        self.ws: Optional[websockets.WebSocketClientProtocol] = None
        self.running = True
        self._hb_task: Optional[asyncio.Task] = None

    def _trace(self) -> str:
        return str(uuid.uuid4())

    async def _send(self, msg: dict) -> None:
        if self.ws:
            await self.ws.send(json.dumps(msg))

    async def _subscribe(self) -> None:
        # 订阅实时成交(代码 10000)
        await self._send({"code": 10000, "trace": self._trace(),
                          "data": {"codes": SYMBOLS}})
        await asyncio.sleep(5)

        # 订阅盘口(代码 10003)
        await self._send({"code": 10003, "trace": self._trace(),
                          "data": {"codes": SYMBOLS}})
        await asyncio.sleep(5)

        # 订阅 1 分钟 K 线(代码 10006)
        await self._send({"code": 10006, "trace": self._trace(),
                          "data": {"arr": [{"type": 1, "codes": SYMBOLS}]}})

        log.info("订阅完成 | 市场状态:%s", cn_market_status())

    def _start_heartbeat(self) -> None:
        if self._hb_task and not self._hb_task.done():
            self._hb_task.cancel()

        async def beat():
            while True:
                await asyncio.sleep(30)
                if not self.ws or self.ws.close_code is not None:
                    break
                try:
                    await self._send({"code": 10010, "trace": self._trace()})
                except Exception:
                    break

        self._hb_task = asyncio.create_task(beat())

    def _dispatch(self, raw: str) -> None:
        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            return

        code = msg.get("code")
        data = msg.get("data", {})

        if code == 10002:   # 成交推送
            direction = {0: "—", 1: "↑主买", 2: "↓主卖"}.get(data.get("td"), "?")
            vol_lots  = int(data.get("v", 0)) // 100
            log.info("成交 %-14s 价格=%-9s 成交量=%-6d手 %s",
                     data.get("s"), data.get("p"), vol_lots, direction)

        elif code == 10005: # 盘口推送
            best_bid = data.get("b", [[None]])[0][0]
            best_ask = data.get("a", [[None]])[0][0]
            if best_bid and best_ask:
                spread = round(float(best_ask) - float(best_bid), 2)
                log.info("盘口 %-14s 买一=%-9s 卖一=%-9s 价差=%.2f",
                         data.get("s"), best_bid, best_ask, spread)

        elif code == 10008: # K 线推送
            pct = data.get("pfr", "0%")
            log.info("K线  %-14s 开=%-7s 高=%-7s 低=%-7s 收=%-7s 涨跌=%s",
                     data.get("s"), data.get("o"), data.get("h"),
                     data.get("l"), data.get("c"), pct)

        elif code in (10001, 10004, 10007):
            log.info("订阅确认 code=%s", code)

    async def _connect_once(self) -> None:
        async with websockets.connect(self.url) as ws:
            self.ws = ws
            log.info("已连接 | 市场状态:%s", cn_market_status())
            await self._subscribe()
            self._start_heartbeat()
            try:
                async for message in ws:
                    self._dispatch(message)
            finally:
                if self._hb_task:
                    self._hb_task.cancel()
                self.ws = None

    async def start(self) -> None:
        backoff = 5
        while self.running:
            status = cn_market_status()
            # 午休和休市期间暂停重连,避免无意义的断线重连
            if status in ("午休", "休市"):
                log.info("当前状态:%s,暂停连接(60s后再检查)", status)
                await asyncio.sleep(60)
                continue
            try:
                await self._connect_once()
                backoff = 5
            except ConnectionClosed as e:
                log.warning("连接断开:%s", e)
            except Exception as e:
                log.error("连接异常:%s", e)
            if not self.running:
                break
            log.info("%ds 后重连...", backoff)
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 60)

    def stop(self) -> None:
        self.running = False


async def main():
    client = AShareClient(api_key="YOUR_API_KEY")
    await client.start()

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        log.info("已停止")

七、T+1 制度:量化开发的隐藏约束

A 股实行 T+1 交易制度:当天买入的股票,最早次日才能卖出。这与美股(T+0,可当天高抛低吸)和港股(T+0 日内交易可做)完全不同。

T+1 对策略开发的影响:

1. 日内信号无法当日平仓:如果你的策略在 14:00 产生卖出信号,但这只股票是当天 9:30 买入的,无法执行。回测框架必须在信号逻辑中加入"当日买入"标记。

2. 滑点模型不同:A 股买入委托通常在涨停位挂单等待成交,而美股的 market order 几乎即时成交。回测中使用过于乐观的成交假设,实盘会出现较大偏差。

3. 融券 T+0 是例外:通过融券(borrowing)买入的股票可以当日卖出,但融券需要开通两融账户,普通投资者通常不适用。

一个简单的 T+1 约束检查函数,可以嵌入回测框架:

代码语言:python
复制
from collections import defaultdict
from datetime import date

class T1PositionTracker:
    """跟踪 A 股 T+1 约束:今天买入的不能今天卖出。"""

    def __init__(self):
        self.positions: dict[str, int] = defaultdict(int)       # symbol → 可卖数量(股)
        self.today_bought: dict[str, int] = defaultdict(int)    # 今日新买,次日才能卖

    def buy(self, symbol: str, shares: int) -> None:
        self.today_bought[symbol] += shares

    def sell(self, symbol: str, shares: int) -> bool:
        available = self.positions[symbol]
        if shares > available:
            print(f"⚠ {symbol} 可卖 {available} 股,不足 {shares} 股(T+1 限制)")
            return False
        self.positions[symbol] -= shares
        return True

    def next_day(self) -> None:
        """每日收盘后调用,将今日买入转为可卖持仓。"""
        for symbol, shares in self.today_bought.items():
            self.positions[symbol] += shares
        self.today_bought.clear()

八、开发常见错误速查

错误码

说明

处理建议

ret: 508

股票代码不存在或已退市

检查代码格式是否含 .SH / .SZ 后缀

ret: 429

请求频率超限

降低轮询频率,或升级套餐

ret: 503

K 线查询数量超出限制

单次最多 500 根,多标的同查时每标的仅返回 2 根

ret: 401

API Key 认证失败

检查请求头是否设置 apiKey 而非 Authorization

WS 错误 513

WebSocket 心跳超时

每 30 秒发送一次心跳(code: 10010),超 60 秒断连

WS 错误 501

订阅频率超过每分钟 60 次

订阅请求加间隔,不要在连接建立后立即批量订阅


小结

A 股行情接口的核心难点不在接口本身,而在于市场机制与数据之间的对应关系:

  • 代码规则决定涨跌幅上限,不需要接口就能判断
  • 分钟 K 线有 1.5 小时午休空洞,回测框架需要过滤
  • 复权因子是历史回测的前提,直接用原始价格会产生虚假信号
  • 涨跌停计算因板块不同有 ±5% / ±10% / ±20% / ±30% 的差异
  • T+1 制度意味着日内买入当日不可卖出,回测框架需要显式建模
  • WebSocket 在午休期间流量会中断,需要区分"市场暂停"与"连接断开"

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
作者已关闭评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、A 股代码规则:不查文档就能判断板块
  • 二、交易时间:比想象中更复杂
    • 两段式 + 收盘竞价
  • 三、行情接口实战:成交、盘口、K 线
    • 3.1 实时成交明细
    • 3.2 实时盘口
  • 四、历史 K 线:三个容易踩的坑
    • 坑一:分钟 K 线有时间空洞
    • 坑二:不做复权处理,历史价格会突然跳变
    • 坑三:涨跌停日的 K 线形态异常
  • 五、涨跌停识别算法
  • 六、WebSocket 实时行情订阅
  • 七、T+1 制度:量化开发的隐藏约束
  • 八、开发常见错误速查
  • 小结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档