一、接口快速上手
1.1 代码格式
韩国股票代码统一格式:6 位数字 + .KS,KOSPI 和 KOSDAQ 共用这一格式。
005930.KS 三星电子(KOSPI)
000660.KS SK 海力士(KOSPI)
035420.KS NAVER(KOSPI)
247540.KS 에코프로비엠(KOSDAQ)
注意:虽然数字段有一定规律(大致上 KOSPI 蓝筹集中在 0xxxxx,KOSDAQ 成长股在 2xxxxx 和 3xxxxx),但这不是绝对规则,不要用来做板块判断。板块信息应通过独立的标的列表接口获取。
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()
五、常见问题
盘口接口的 a 和 b 数组里有时会出现空列表,怎么处理?**
非交易时段或标的未有成交时,买卖盘可能为空。解析前先检查长度:
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% 涨跌停的判断逻辑、非交易时段的静默处理,以及断线重连后的重新订阅。这些地方踩一次坑,改起来都不复杂,但如果在生产环境才发现,排查起来会比较费时。
做多市场数据接入,最省力的方式是为每个市场单独维护一份"差异备忘",而不是假设所有市场的规则都和你最熟悉的那个市场一样。韩国市场整体对开发者比较友好(时区简单、无午休、代码格式统一),值得放进多市场数据源的候选清单里。