韩国实时行情 API 使用方法与注意事项

一、接口快速上手

1.1 代码格式

韩国股票代码统一格式:6 位数字 + .KS,KOSPI 和 KOSDAQ 共用这一格式。

复制代码
005930.KS   三星电子(KOSPI)
000660.KS   SK 海力士(KOSPI)
035420.KS   NAVER(KOSPI)
247540.KS   에코프로비엠(KOSDAQ)

注意:虽然数字段有一定规律(大致上 KOSPI 蓝筹集中在 0xxxxx,KOSDAQ 成长股在 2xxxxx3xxxxx),但这不是绝对规则,不要用来做板块判断。板块信息应通过独立的标的列表接口获取。

1.2 三个核心 REST 接口

实时成交明细

python 复制代码
import requests

HEADERS = {"apiKey": "YOUR_API_KEY"} #获取免费API KEY: www.infoway.io

def get_trade(codes: str) -> list:
    url = f"https://data.infoway.io/korea/batch_trade/{codes}"
    return requests.get(url, headers=HEADERS).json()["data"]

trades = get_trade("005930.KS,000660.KS")
for t in trades:
    print(f"{t['s']}: 价格={t['p']}, 成交量={t['v']}, 时间戳={t['t']}ms")
字段 含义
s 标的代码
t 成交时间(毫秒时间戳)
p 最新成交价(韩元)
v 成交量(股)
vw 成交额(韩元)
td 方向:0=未知,1=买入,2=卖出

最优盘口

python 复制代码
def get_depth(codes: str) -> list:
    url = f"https://data.infoway.io/korea/batch_depth/{codes}"
    return requests.get(url, headers=HEADERS).json()["data"]

for d in get_depth("005930.KS"):
    ask_p, ask_v = d["a"][0][0], d["a"][1][0]
    bid_p, bid_v = d["b"][0][0], d["b"][1][0]
    print(f"{d['s']}: 卖一 {ask_p}×{ask_v}  买一 {bid_p}×{bid_v}")

盘口返回的买卖各一档。a 是卖盘,b 是买盘,每个字段是两个子数组 [[价格列表], [数量列表]],用下标对应。

K 线

python 复制代码
def get_kline(codes: str, kline_type: int = 8, num: int = 10) -> list:
    url = "https://data.infoway.io/korea/v2/batch_kline"
    payload = {"klineType": kline_type, "klineNum": num, "codes": codes}
    return requests.post(url, json=payload, headers={**HEADERS, "Content-Type": "application/json"}).json()["data"]

klineType 取值:1=1分钟、2=5分钟、3=15分钟、4=30分钟、5=1小时、6=2小时、7=4小时、8=日K、9=周K、10=月K、11=季K、12=年K。


二、重点注意事项

2.1 时区,直接写死 UTC+9

韩国标准时间(KST)固定为 UTC+9,无夏令时。这一点比美股友好很多,不需要每年处理两次时钟切换。

但"无夏令时"正因为反直觉,反而容易出错。如果你的代码里有类似"根据月份判断时区偏移"的逻辑,在韩国数据上会产生错误。正确做法:

python 复制代码
from zoneinfo import ZoneInfo
from datetime import datetime

KST = ZoneInfo("Asia/Seoul")

def ts_ms_to_kst(ts_ms: int) -> datetime:
    """将毫秒时间戳转换为 KST 时间。"""
    return datetime.fromtimestamp(ts_ms / 1000, tz=KST)

# 示例
dt = ts_ms_to_kst(1735000000000)
print(dt.strftime("%Y-%m-%d %H:%M:%S %Z"))   # 2024-12-24 08:26:40 KST

Asia/Seoul 作为时区名,让系统库处理历史时区记录,不要手动加减 9 小时。

另外注意:行情接口返回的时间戳单位是毫秒t 字段),历史 K 线接口的翻页参数 timestamp。两处单位不同,混用会导致翻页到错误位置。


2.2 批量查询的两个隐藏限制

批量查询时有两个约束,文档里写了,但容易在实际使用中忽略:

限制一:单次请求最多 100 个标的

REST 接口(成交明细、盘口、K 线)单次请求的 codes 参数最多携带 100 个标的。如果你要覆盖 KOSPI 全市场 940 只股票,需要自己实现分批逻辑:

python 复制代码
from itertools import islice

def chunked(iterable, n):
    it = iter(iterable)
    while batch := list(islice(it, n)):
        yield batch

all_codes = ["005930.KS", "000660.KS", ...]  # 全部标的列表

all_trades = []
for batch in chunked(all_codes, 100):
    codes_str = ",".join(batch)
    all_trades.extend(get_trade(codes_str))

限制二:多标的 K 线,klineNum 最多填 2

这个限制是很多人第一次批量拉 K 线时发现的:当 codes 包含多个标的时,klineNum 最大值是 2,只返回最近 2 根 K 线。

要获取某个标的较长时间段的 K 线,必须单独查询:

python 复制代码
# 正确:单标的,最多 500 根
def get_kline_history(code: str, kline_type: int, num: int = 500):
    return get_kline(code, kline_type, min(num, 500))

# 需要翻页时,传入最早一根 K 线的秒时间戳(减 1)
def get_kline_page(code: str, kline_type: int, before_ts_sec: int):
    url = "https://data.infoway.io/korea/v2/batch_kline"
    payload = {
        "klineType": kline_type,
        "klineNum": 500,
        "codes": code,
        "timestamp": before_ts_sec - 1
    }
    resp = requests.post(url, json=payload, headers={**HEADERS, "Content-Type": "application/json"})
    return resp.json()["data"][0]["respList"]

2.3 ±30% 涨跌停:不能套 A 股逻辑

这是最容易出错的一点。韩国股票的单日涨跌幅限制是 ±30%,远高于 A 股主板的 ±10%。如果你的代码有这样的判断:

python 复制代码
# 错误:A股逻辑,套在韩股上会产生大量误报
if abs(change_pct) > 0.095:
    flag_as_limit_hit()

韩国正确的判断方式:

python 复制代码
KOREA_LIMIT_THRESHOLD = 0.295   # 留 0.5% 的浮动空间

def is_limit_hit(current_price: float, prev_close: float) -> str:
    change = (current_price - prev_close) / prev_close
    if change >= KOREA_LIMIT_THRESHOLD:
        return "涨停"
    if change <= -KOREA_LIMIT_THRESHOLD:
        return "跌停"
    return ""

K 线返回的 pc 字段(涨跌幅,百分比形式)也可以直接用于判断:

python 复制代码
for bar in kline_data:
    pct = float(bar["pc"])   # 已是百分比数字,如 -12.5 表示跌 12.5%
    if abs(pct) >= 29.5:
        print(f"{bar['s']} 触及涨跌停,涨跌幅: {pct}%")

值得一提的是,韩国 KOSDAQ 生物医药和二线科技股在重大消息(新药临床数据、监管审批结果、并购公告)发布后,直接从开盘打到涨跌停并不罕见。如果你的系统有基于价格变动幅度的告警,在接入韩国市场时一定要重新校准阈值,否则会产生大量噪声。


2.4 集合竞价时段的数据处理

韩国交易所的集合竞价分两段:

时段 KST 北京时间
开盘集合竞价 08:30--09:00 07:30--08:00
收盘集合竞价 15:20--15:30 14:20--14:30

集合竞价期间,行情接口依然会推送数据,但成交价是竞价撮合中产生的预计开盘/收盘价,可能和正式交易开始后的首笔成交有差距。如果你的策略逻辑依赖开盘价,建议等 09:00(KST)正式交易开始后的第一笔成交数据,而不是直接取 08:30 之后最早的推送价格。

一个简单的过滤函数:

python 复制代码
from datetime import time

MARKET_OPEN_KST  = time(9, 0)
MARKET_CLOSE_KST = time(15, 20)

def is_continuous_trading(ts_ms: int) -> bool:
    """判断时间戳是否在正式连续交易时段(09:00--15:20 KST)。"""
    dt_kst = ts_ms_to_kst(ts_ms)
    t = dt_kst.time()
    return MARKET_OPEN_KST <= t < MARKET_CLOSE_KST

2.5 非交易时段的 WebSocket 行为

韩国市场收盘后(KST 18:00 以后),WebSocket 连接保持正常,心跳会正常收到响应,但不再有行情推送。这是正常现象,不是连接异常。

在策略代码里,要区分"连接正常但无数据"和"连接断开"两种状态,避免把盘后静默误判为数据管道故障:

python 复制代码
import time

class DataHealthChecker:
    def __init__(self, timeout_sec: int = 120):
        self.last_data_ts = time.time()
        self.timeout = timeout_sec

    def on_message(self):
        self.last_data_ts = time.time()

    def is_stale(self) -> bool:
        """数据静默超过 timeout 秒。"""
        return time.time() - self.last_data_ts > self.timeout

    def is_trading_hours(self) -> bool:
        """判断当前是否在交易时段(KST 09:00--15:30,含集合竞价)。"""
        from datetime import datetime
        now_kst = datetime.now(tz=KST)
        t = now_kst.time()
        weekday = now_kst.weekday()
        if weekday >= 5:   # 周六日
            return False
        return time(8, 30) <= t <= time(15, 30)

    def should_alert(self) -> bool:
        return self.is_stale() and self.is_trading_hours()

只在交易时段数据静默时才触发告警,非交易时段的静默忽略。


2.6 WebSocket 断线重连后必须重新订阅

这一点在文档里有说明,但实际操作中还是经常忘记:WebSocket 断线重连后,之前的订阅状态不会自动恢复,服务端是无状态的。

重连后如果没有重新发送订阅请求,连接建立成功,但不会有任何行情推送。正确做法是把"建立连接"和"发送订阅"绑定在一起:

python 复制代码
async def _connect_once(self):
    async with websockets.connect(self.ws_url) as ws:
        self.ws = ws
        await self._subscribe_all()   # 每次连接后立即重新订阅
        self._start_heartbeat()
        async for message in ws:
            self._on_message(message)

不要把订阅请求放在只执行一次的初始化函数里,每次重连都要重新调用。


2.7 请求频率控制

REST 接口有频率限制,具体额度取决于套餐。如果同时并发请求多个批次,容易触发限频返回 429。一个简单的令牌桶实现:

python 复制代码
import threading
import time as _time

class RateLimiter:
    def __init__(self, calls_per_second: float):
        self.interval = 1.0 / calls_per_second
        self._lock = threading.Lock()
        self._last_call = 0.0

    def wait(self):
        with self._lock:
            now = _time.monotonic()
            elapsed = now - self._last_call
            if elapsed < self.interval:
                _time.sleep(self.interval - elapsed)
            self._last_call = _time.monotonic()

# 使用示例:每秒最多 5 次请求
limiter = RateLimiter(calls_per_second=5)

for batch in chunked(all_codes, 100):
    limiter.wait()
    trades = get_trade(",".join(batch))

三、小科普:韩国为什么不用夏令时

顺带提一个容易被忽略的历史背景,了解这个对时区处理会有更清晰的认知。

韩国曾经实行过夏令时。1987 年首尔奥运会前夕,韩国试验性地引入夏令时,目的是与国际时间接轨、减少夏季用电高峰。但实施后社会反响很差:上班族发现夏天要在天还没亮的时候上班,农村地区的作息根本不配合时钟变化,加上实际节电效果存疑,1988 年奥运会结束后当年就正式废除。

此后韩国再未重新实行夏令时,KST 固定在 UTC+9。相比之下,相邻的日本(同为 UTC+9)在二战前后也短暂实行过夏令时,同样以废除告终。东亚主要市场(中国、日本、韩国)如今全部没有夏令时,和美股比起来,时区处理要简单很多。


四、完整示例:带异常处理的生产级查询脚本

把上面的注意事项整合成一个实用的多标的行情采集脚本:

python 复制代码
import requests
import time
from datetime import datetime, time as dtime
from zoneinfo import ZoneInfo
from itertools import islice

KST = ZoneInfo("Asia/Seoul")
HEADERS = {"apiKey": "YOUR_API_KEY"}
KOREA_LIMIT_PCT = 29.5


def chunked(iterable, n):
    it = iter(iterable)
    while batch := list(islice(it, n)):
        yield batch


def is_trading_hours() -> bool:
    now = datetime.now(tz=KST)
    if now.weekday() >= 5:
        return False
    t = now.time()
    return dtime(9, 0) <= t <= dtime(15, 30)


def safe_get(url: str, params: dict = None, retries: int = 3) -> dict | None:
    for attempt in range(retries):
        try:
            resp = requests.get(url, headers=HEADERS, params=params, timeout=10)
            resp.raise_for_status()
            return resp.json()
        except requests.HTTPError as e:
            if resp.status_code == 429:
                wait = 2 ** attempt
                print(f"限频,{wait}s 后重试...")
                time.sleep(wait)
            else:
                print(f"HTTP 错误 {resp.status_code}: {e}")
                return None
        except requests.RequestException as e:
            print(f"请求失败({attempt+1}/{retries}): {e}")
            time.sleep(1)
    return None


def fetch_all_trades(codes: list[str]) -> dict[str, dict]:
    result = {}
    for batch in chunked(codes, 100):
        codes_str = ",".join(batch)
        data = safe_get(f"https://data.infoway.io/korea/batch_trade/{codes_str}")
        if data and "data" in data:
            for item in data["data"]:
                result[item["s"]] = item
        time.sleep(0.2)   # 批次间间隔,避免触发限频
    return result


def check_limit_status(trade: dict, prev_close: float) -> str:
    current = float(trade["p"])
    pct = (current - prev_close) / prev_close * 100
    if pct >= KOREA_LIMIT_PCT:
        return f"涨停 (+{pct:.1f}%)"
    if pct <= -KOREA_LIMIT_PCT:
        return f"跌停 ({pct:.1f}%)"
    return f"{pct:+.2f}%"


def main():
    watchlist = [
        "005930.KS",   # 三星电子
        "000660.KS",   # SK 海力士
        "035420.KS",   # NAVER
        "207940.KS",   # 三星生物
        "373220.KS",   # LG 新能源
    ]

    if not is_trading_hours():
        print(f"当前不在交易时段(KST {datetime.now(tz=KST).strftime('%H:%M')}),退出。")
        return

    print(f"开始采集,共 {len(watchlist)} 个标的,当前 KST: {datetime.now(tz=KST).strftime('%H:%M:%S')}")
    trades = fetch_all_trades(watchlist)

    for code, trade in trades.items():
        ts_dt = datetime.fromtimestamp(int(trade["t"]) / 1000, tz=KST)
        direction = {0: "---", 1: "↑买", 2: "↓卖"}.get(trade.get("td", 0), "---")
        print(
            f"{code:12s} | 价格: ₩{float(trade['p']):>10,.0f} "
            f"| 量: {float(trade['v']):>8,.0f} "
            f"| 方向: {direction} "
            f"| 时间: {ts_dt.strftime('%H:%M:%S')}"
        )


if __name__ == "__main__":
    main()

五、常见问题

盘口接口的 ab 数组里有时会出现空列表,怎么处理?**

非交易时段或标的未有成交时,买卖盘可能为空。解析前先检查长度:

python 复制代码
asks = list(zip(d["a"][0], d["a"][1])) if d["a"] and len(d["a"][0]) > 0 else []
bids = list(zip(d["b"][0], d["b"][1])) if d["b"] and len(d["b"][0]) > 0 else []

K 线的 pc 字段是基于什么计算的?

pc(percent change)是相对于上一根 K 线收盘价的涨跌幅,单位是百分比(如 -5.2 表示跌 5.2%)。对于日K,这就是昨日收盘价到当日当前价的涨跌幅。注意当日 K 线在收盘前是"未完成"状态,pc 会随最新成交价实时变化。

WebSocket 心跳间隔设多少合适?

建议 30 秒发一次心跳(协议号 10010)。间隔太长(超过 60 秒)可能被服务端主动断开,间隔太短会增加不必要的网络开销。

能否通过 K 线接口判断某一天是否是交易日?**

可以间接判断:查询日K,如果某个日期没有对应的 K 线记录,则说明当天是非交易日(节假日或周末)。但更推荐的做法是维护一份韩国交易日历,KRX 官网每年发布次年的交易日历,可以提前下载缓存。

免费套餐可以订阅多少个韩国标的的 WebSocket?

免费套餐全市场(包括韩国、港股、日股等所有市场)共 10 个 WebSocket 订阅额度。如果只需要少量核心标的做测试,免费套餐完全够用。

批量查询时,哪些标的代码查不到数据?

代码停牌或退市时,REST 接口可能不返回该标的的记录(不是报错,而是 data 数组里缺少该条目)。处理批量结果时,要用返回结果里的 s 字段(实际返回的代码)作为索引,而不是假设顺序和请求顺序一致。


小结

接入韩国行情 API 本身不难,但有几个细节值得在上线前专门确认:时区写死 UTC+9、批量 K 线的 klineNum 限制、±30% 涨跌停的判断逻辑、非交易时段的静默处理,以及断线重连后的重新订阅。这些地方踩一次坑,改起来都不复杂,但如果在生产环境才发现,排查起来会比较费时。

做多市场数据接入,最省力的方式是为每个市场单独维护一份"差异备忘",而不是假设所有市场的规则都和你最熟悉的那个市场一样。韩国市场整体对开发者比较友好(时区简单、无午休、代码格式统一),值得放进多市场数据源的候选清单里。